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:
iliya 2026-03-15 17:45:10 +02:00
parent 2f73682ff1
commit 194bd1bf1e
29 changed files with 669 additions and 415 deletions

View file

@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) {
);
}
function quoteMarkdown(text) {
return String(text)
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
function buildAssignmentMessage(context, task, options = {}) {
const description =
typeof options.description === 'string' && options.description.trim()
@ -74,7 +81,7 @@ function buildCommentNotificationMessage(context, task, comment) {
`**Comment on task ${taskLabel}**`,
`> ${task.subject}`,
``,
comment.text,
quoteMarkdown(comment.text),
``,
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),

View file

@ -427,7 +427,7 @@ describe('agent-teams-controller API', () => {
timestamp: '2026-02-23T11:00:00.000Z',
read: false,
text:
`**Comment on task #${task.displayId}**\n> Ship migration\n\nHeads up\n\n` +
`**Comment on task #${task.displayId}**\n> Ship migration\n\n> Heads up\n\n` +
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
},
],

View file

@ -1,5 +1,6 @@
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto';
@ -194,7 +195,7 @@ export class CrossTeamService {
}
if (!config || config.deletedAt) continue;
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
const lead = config.members?.find((m) => isLeadMember(m));
targets.push({
teamName: entry,

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import {
createCliAutoSuffixNameGuard,
@ -207,7 +208,7 @@ export class TeamConfigReader {
const name = m.name?.trim();
if (!name) return;
// Summary/memberCount should represent teammates (exclude the lead process).
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
if (name === 'user' || isLeadMember(m)) return;
const key = name.toLowerCase();
// If meta marks this name removed, do not surface it in summaries
if (removedKeys.has(key)) return;
@ -227,7 +228,7 @@ export class TeamConfigReader {
const name = member.name?.trim();
if (!name) continue;
// Summary/memberCount should represent teammates (exclude the lead process).
if (name === 'team-lead' || name === 'user' || member.agentType === 'team-lead') continue;
if (name === 'user' || isLeadMember(member)) continue;
const key = name.toLowerCase();
if (member.removedAt) {
removedKeys.add(key);

View file

@ -14,6 +14,7 @@ import {
stripAgentBlocks,
} from '@shared/constants/agentBlocks';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -585,7 +586,7 @@ export class TeamDataService {
config: TeamConfig
): Promise<void> {
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
const leadEntry = config.members?.find((m) => m.name === 'team-lead');
const leadEntry = config.members?.find((m) => isLeadMember(m));
const leadCwd = leadEntry?.cwd ?? config.projectPath;
if (!leadCwd) return;
@ -737,7 +738,7 @@ export class TeamDataService {
): Promise<{ oldRole: string | undefined; changed: boolean }> {
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
if (member.removedAt) throw new Error(`Member "${memberName}" is removed`);
if (member.agentType === 'team-lead') throw new Error('Cannot change team lead role');
if (isLeadAgentType(member.agentType)) throw new Error('Cannot change team lead role');
const oldRole = member.role;
const normalized = typeof newRole === 'string' && newRole.trim() ? newRole.trim() : undefined;
@ -753,9 +754,7 @@ export class TeamDataService {
request: { members: { name: string; role?: string; workflow?: string }[] }
): Promise<void> {
const existing = await this.membersMetaStore.getMembers(teamName);
const isTeamLead = (m: TeamMember): boolean =>
m.agentType === 'team-lead' || m.name.trim().toLowerCase() === 'team-lead';
const existingLead = existing.find(isTeamLead) ?? null;
const existingLead = existing.find(isLeadMember) ?? null;
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
const joinedAt = Date.now();
const nextByName = new Set<string>();
@ -788,7 +787,7 @@ export class TeamDataService {
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
const nextRemoved: TeamMember[] = [];
for (const prev of existing) {
if (isTeamLead(prev)) continue;
if (isLeadMember(prev)) continue;
const prevName = prev.name.trim();
if (!prevName) continue;
const key = prevName.toLowerCase();
@ -815,7 +814,7 @@ export class TeamDataService {
if (member.removedAt) {
throw new Error(`Member "${memberName}" is already removed`);
}
if (member.agentType === 'team-lead') {
if (isLeadAgentType(member.agentType)) {
throw new Error('Cannot remove team lead');
}
@ -1544,16 +1543,14 @@ export class TeamDataService {
// Check config.json members first (Claude Code-created teams)
if (config?.members?.length) {
const lead = config.members.find(
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
const lead = config.members.find((m) => isLeadMember(m));
if (lead?.name) return lead.name;
}
// Fallback: check members.meta.json (UI-created teams)
const metaMembers = await this.membersMetaStore.getMembers(teamName);
if (metaMembers.length > 0) {
const lead = metaMembers.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
const lead = metaMembers.find((m) => isLeadMember(m));
if (lead?.name) return lead.name;
return metaMembers[0]?.name ?? null;
}
@ -1844,7 +1841,7 @@ export class TeamDataService {
return [];
}
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
const leadName = config.members?.find((m) => isLeadAgentType(m.agentType))?.name ?? 'team-lead';
const sessionIds = this.getRecentLeadSessionIds(config);
if (sessionIds.length === 0) {
return [];

View file

@ -1,4 +1,5 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
import { createReadStream } from 'fs';
@ -109,7 +110,7 @@ export class TeamMemberLogsFinder {
const results: MemberLogSummary[] = [];
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
if (isLeadMember && config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
const leadSummary = await this.parseLeadSessionSummary(
@ -200,7 +201,7 @@ export class TeamMemberLogsFinder {
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
const results: MemberLogSummary[] = [];
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
if (config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
@ -422,7 +423,7 @@ export class TeamMemberLogsFinder {
const refs: { filePath: string; memberName: string; sortTime: number }[] = [];
const seen = new Set<string>();
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
const pushRef = (filePath: string, memberName: string, sortTime = 0): void => {
const key = `${memberName.toLowerCase()}:${filePath}`;
@ -817,7 +818,7 @@ export class TeamMemberLogsFinder {
if (!discovery) return null;
const { config } = discovery;
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
return { ...discovery, isLeadMember };
}

View file

@ -31,6 +31,7 @@ import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
@ -3582,7 +3583,7 @@ export class TeamProvisioningService {
if (!config) return 0;
const leadName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead';
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
@ -4855,11 +4856,11 @@ export class TeamProvisioningService {
try {
const config = await this.configReader.getConfig(run.teamName);
if (config?.members) {
const configLead = config.members.find((m) => m?.agentType === 'team-lead');
const configLead = config.members.find((m) => isLeadAgentType(m?.agentType));
leadName = configLead?.name?.trim() || 'team-lead';
// Convert config members (excluding lead) to TeamCreateRequest member format.
currentMembers = config.members
.filter((m) => m?.agentType !== 'team-lead' && m?.name)
.filter((m) => !isLeadAgentType(m?.agentType) && m?.name)
.map((m) => ({
name: m.name,
role: m.role ?? undefined,
@ -5310,7 +5311,8 @@ export class TeamProvisioningService {
members?: { name?: string; agentType?: string }[];
};
const suffixed = (config.members ?? []).filter(
(m) => typeof m.name === 'string' && /-\d+$/.test(m.name) && m.agentType !== 'team-lead'
(m) =>
typeof m.name === 'string' && /-\d+$/.test(m.name) && !isLeadAgentType(m.agentType)
);
if (suffixed.length > 0) {
logger.warn(
@ -6155,7 +6157,7 @@ export class TeamProvisioningService {
const name = typeof m.name === 'string' ? m.name.trim() : '';
const agentType = typeof m.agentType === 'string' ? m.agentType : '';
if (!name) continue;
if (agentType === 'team-lead' || name === 'team-lead' || name === 'user') {
if (isLeadMember(m) || name === 'user') {
nextMembers.push(m);
continue;
}
@ -6196,7 +6198,7 @@ export class TeamProvisioningService {
const name = m.name?.trim() ?? '';
if (!name) return false;
const lower = name.toLowerCase();
if (lower === 'team-lead' || lower === 'user' || m.agentType === 'team-lead') return true;
if (lower === 'user' || isLeadMember(m)) return true;
if (!m.removedAt && !keepName(name)) {
removedFromMeta.push(name);
return false;
@ -6299,9 +6301,8 @@ export class TeamProvisioningService {
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) continue;
const lower = name.toLowerCase();
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
if (agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') continue;
if (isLeadMember(member) || lower === 'user') continue;
const leadAgentId = config.leadAgentId;
if (
@ -6343,7 +6344,7 @@ export class TeamProvisioningService {
// Keep only the lead entry.
const leadMembers = members.filter((member) => {
const agentType = member.agentType;
if (typeof agentType === 'string' && agentType === 'team-lead') {
if (typeof agentType === 'string' && isLeadAgentType(agentType)) {
return true;
}
const leadAgentId = config.leadAgentId;
@ -6381,7 +6382,7 @@ export class TeamProvisioningService {
if (
name &&
agentType &&
agentType !== 'team-lead' &&
!isLeadAgentType(agentType) &&
name !== 'team-lead' &&
name !== 'user'
) {
@ -6652,7 +6653,7 @@ export class TeamProvisioningService {
for (const member of metaMembers) {
const rawName = member.name?.trim() ?? '';
const lower = rawName.toLowerCase();
if (member.agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') {
if (isLeadMember(member) || lower === 'user') {
continue;
}
const name = rawName;
@ -6771,13 +6772,7 @@ export class TeamProvisioningService {
for (const member of parsed.members) {
const rawName = typeof member?.name === 'string' ? member.name.trim() : '';
const lower = rawName.toLowerCase();
if (
!member ||
member.agentType === 'team-lead' ||
lower === 'team-lead' ||
lower === 'user'
)
continue;
if (!member || isLeadMember(member) || lower === 'user') continue;
const name = rawName;
if (!name) continue;
byName.set(name, { name });

View file

@ -2,6 +2,8 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { parentPort } from 'node:worker_threads';
import { isLeadMember } from '@shared/utils/leadDetection';
interface ListTeamsPayload {
teamsDir: string;
largeConfigBytes: number;
@ -263,7 +265,7 @@ function mergeMember(
): void {
const name = typeof m.name === 'string' ? m.name.trim() : '';
if (!name) return;
if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return;
if (name === 'user' || isLeadMember(m)) return;
const key = name.toLowerCase();
if (removedKeys.has(key)) return;
const existing = memberMap.get(key);
@ -433,7 +435,7 @@ async function listTeams(
if (!isRawMember(member)) continue;
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) continue;
if (name === 'team-lead' || member.agentType === 'team-lead') continue;
if (isLeadMember(member)) continue;
const key = name.toLowerCase();
if (member.removedAt) {
removedKeys.add(key);

View file

@ -69,7 +69,7 @@ export function getTaskUnreadCount(
readState: ReturnType<typeof getSnapshot>,
teamName: string,
taskId: string,
comments: { createdAt: string }[] | undefined
comments: { id?: string; createdAt: string }[] | undefined
): number {
return getUnreadCount(readState, teamName, taskId, comments ?? []);
}

View file

@ -50,6 +50,7 @@ import {
UserPlus,
Users,
} from 'lucide-react';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { useShallow } from 'zustand/react/shallow';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
@ -693,7 +694,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}, [filteredTasks, kanbanSearch]);
const activeTeammateCount = useMemo(
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
() => activeMembers.filter((m) => !isLeadMember(m)).length,
[activeMembers]
);
@ -1744,7 +1745,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')}
currentMembers={data.members.filter((m) => !isLeadAgentType(m.agentType))}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}

View file

@ -23,6 +23,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import {
CheckCircle,
Clock,
@ -38,12 +39,19 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type { TeamCreateRequest, TeamSummary, TeamSummaryMember } from '@shared/types';
import type {
ResolvedTeamMember,
TeamCreateRequest,
TeamLaunchRequest,
TeamSummary,
TeamSummaryMember,
} from '@shared/types';
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
@ -467,7 +475,7 @@ export const TeamListView = (): React.JSX.Element => {
const existingNames = teams.map((t) => t.teamName);
const uniqueName = generateUniqueName(teamName, existingNames);
const members = (data.members ?? [])
.filter((m) => !m.removedAt && m.agentType !== 'team-lead')
.filter((m) => !m.removedAt && !isLeadAgentType(m.agentType))
.map((m) => {
let role = m.role;
if (!role && m.agentType && m.agentType !== 'general-purpose') {
@ -505,14 +513,39 @@ export const TeamListView = (): React.JSX.Element => {
}, []);
const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null);
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [launchDialogTeamName, setLaunchDialogTeamName] = useState('');
const [launchDialogMembers, setLaunchDialogMembers] = useState<ResolvedTeamMember[]>([]);
const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState<string | undefined>();
const handleLaunchTeam = useCallback(
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
e.stopPropagation();
if (!projectPath) return;
setLaunchingTeamName(teamName);
try {
await launchTeam({ teamName, cwd: projectPath });
openTeamTab(teamName, projectPath);
const data = await api.teams.getData(teamName);
setLaunchDialogTeamName(teamName);
setLaunchDialogMembers(data.members ?? []);
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
setLaunchDialogOpen(true);
} catch (err) {
console.error('Failed to load team data for launch dialog:', err);
// Fallback: open dialog with minimal data
setLaunchDialogTeamName(teamName);
setLaunchDialogMembers([]);
setLaunchDialogDefaultPath(projectPath);
setLaunchDialogOpen(true);
}
},
[]
);
const handleLaunchSubmit = useCallback(
async (request: TeamLaunchRequest) => {
setLaunchingTeamName(request.teamName);
try {
await launchTeam(request);
openTeamTab(request.teamName, request.cwd);
} catch (err) {
console.error('Failed to launch team:', err);
} finally {
@ -587,6 +620,21 @@ export const TeamListView = (): React.JSX.Element => {
/>
);
const launchDialogElement = (
<LaunchTeamDialog
mode="launch"
open={launchDialogOpen}
teamName={launchDialogTeamName}
members={launchDialogMembers}
defaultProjectPath={launchDialogDefaultPath}
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
clearProvisioningError={clearProvisioningError}
activeTeams={activeTeams}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={handleLaunchSubmit}
/>
);
const renderHeader = (): React.JSX.Element => (
<div className="mb-4">
<div className="flex items-center justify-between">
@ -979,6 +1027,7 @@ export const TeamListView = (): React.JSX.Element => {
{renderHeader()}
{renderContent()}
{createDialogElement}
{launchDialogElement}
</div>
</TooltipProvider>
);

View file

@ -13,21 +13,14 @@ import {
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { TiptapEditor } from '@renderer/components/ui/tiptap';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
@ -36,7 +29,7 @@ import {
} from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, Search } from 'lucide-react';
import { AlertTriangle, ChevronDown, ChevronRight, Search } from 'lucide-react';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -99,6 +92,7 @@ export const CreateTaskDialog = ({
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState('');
const [showOptionalFields, setShowOptionalFields] = useState(false);
const prevOpenRef = useRef(false);
// Reset form when dialog opens (avoid setState during render)
@ -124,6 +118,7 @@ export const CreateTaskDialog = ({
promptDraft.clearDraft();
setBlockedBySearch('');
setRelatedSearch('');
setShowOptionalFields(false);
}
prevOpenRef.current = open;
}, [
@ -170,14 +165,6 @@ export const CreateTaskDialog = ({
);
};
const handleDescChipRemove = (chipId: string): void => {
const chip = descChipDraft.chips.find((c) => c.id === chipId);
if (chip) {
descriptionDraft.setValue(removeChipTokenFromText(descriptionDraft.value, chip));
}
descChipDraft.setChips(descChipDraft.chips.filter((c) => c.id !== chipId));
};
const handleSubmit = (): void => {
if (!canSubmit) return;
const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
@ -214,44 +201,19 @@ export const CreateTaskDialog = ({
<Label className={requiresOwner ? undefined : 'label-optional'}>
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder={requiresOwner ? 'Select a member' : 'Unassigned'} />
</SelectTrigger>
<SelectContent>
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? <span className="text-[var(--color-text-muted)]">({role})</span> : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<MemberSelect
members={members}
value={owner || null}
onChange={(v) => setOwner(v ?? '')}
placeholder={requiresOwner ? 'Select a member' : 'Select member...'}
allowUnassigned={!requiresOwner}
/>
</div>
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogContent className="sm:max-w-[580px]">
<DialogHeader>
<DialogTitle>Create Task</DialogTitle>
<DialogDescription>
@ -294,51 +256,199 @@ export const CreateTaskDialog = ({
{assigneeField}
<div className="grid gap-2">
<Label htmlFor="task-description" className="label-optional">
Description (optional)
</Label>
<MentionableTextarea
id="task-description"
placeholder="Task details..."
value={descriptionDraft.value}
onValueChange={descriptionDraft.setValue}
suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
chips={descChipDraft.chips}
onChipRemove={handleDescChipRemove}
projectPath={projectPath}
onFileChipInsert={(chip) => descChipDraft.setChips([...descChipDraft.chips, chip])}
minRows={3}
maxRows={12}
footerRight={
descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
</div>
{/* Toggle button for optional fields */}
<button
type="button"
className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setShowOptionalFields((prev) => !prev)}
>
{showOptionalFields ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{showOptionalFields ? 'Hide optional fields' : 'Show optional fields'}</span>
</button>
<div className="grid gap-2">
<Label htmlFor="task-prompt" className="label-optional">
Prompt for assignee (optional)
</Label>
<MentionableTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath}
minRows={3}
maxRows={12}
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
{/* Collapsible optional fields */}
<div
className="grid overflow-hidden transition-all duration-200 ease-in-out"
style={{ gridTemplateRows: showOptionalFields ? '1fr' : '0fr' }}
>
<div className="min-h-0 overflow-hidden">
<div className="grid gap-4">
<div className="grid gap-2">
<Label className="label-optional">Description (optional)</Label>
<TiptapEditor
content={descriptionDraft.value}
onChange={descriptionDraft.setValue}
placeholder="Task details (supports markdown)"
minHeight="100px"
maxHeight="200px"
toolbar
/>
</div>
<div className="grid gap-2">
<Label htmlFor="task-prompt" className="label-optional">
Prompt for assignee (optional)
</Label>
<MentionableTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath}
minRows={3}
maxRows={12}
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
</div>
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Blocked by tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!blockedBySearch ||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
t.id.includes(blockedBySearch) ||
t.displayId?.includes(blockedBySearch)
)
.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleBlockedBy(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-blue-400 bg-blue-500/30 text-blue-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
{formatTaskDisplayLabel(t)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Task will be blocked by:{' '}
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Related tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={relatedSearch}
onChange={(e) => setRelatedSearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!relatedSearch ||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
t.id.includes(relatedSearch) ||
t.displayId?.includes(relatedSearch)
)
.map((t) => {
const isSelected = related.includes(t.id);
return (
<button
key={`related:${t.id}`}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-purple-500/15 text-purple-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleRelated(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-purple-400 bg-purple-500/30 text-purple-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
{formatTaskDisplayLabel(t)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{related.length > 0 ? (
<p className="text-[11px] text-purple-300">
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>
) : null}
</div>
</div>
</div>
{owner ? (
@ -364,147 +474,6 @@ export const CreateTaskDialog = ({
) : null}
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Blocked by tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!blockedBySearch ||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
t.id.includes(blockedBySearch) ||
t.displayId?.includes(blockedBySearch)
)
.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleBlockedBy(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-blue-400 bg-blue-500/30 text-blue-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
{formatTaskDisplayLabel(t)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Task will be blocked by:{' '}
{blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Related tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={relatedSearch}
onChange={(e) => setRelatedSearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!relatedSearch ||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
t.id.includes(relatedSearch) ||
t.displayId?.includes(relatedSearch)
)
.map((t) => {
const isSelected = related.includes(t.id);
return (
<button
key={`related:${t.id}`}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-purple-500/15 text-purple-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleRelated(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-purple-400 bg-purple-500/30 text-purple-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
{formatTaskDisplayLabel(t)}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{related.length > 0 ? (
<p className="text-[11px] text-purple-300">
Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
</p>
) : null}
</div>
) : null}
</div>
<DialogFooter>

View file

@ -31,6 +31,7 @@ import {
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
@ -128,7 +129,7 @@ export const SendMessageDialog = ({
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
const selectedMember = members.find((m) => m.name === member);
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && isLeadRecipient;
const shouldAutoDelegate = canDelegate;

View file

@ -70,7 +70,7 @@ interface TaskCommentsSectionProps {
* Ref callback factory from useViewportCommentRead.
* When provided, each comment element is registered for viewport-based read tracking.
*/
registerCommentForViewport?: (timestampMs: number) => (el: HTMLElement | null) => void;
registerCommentForViewport?: (commentId: string) => (el: HTMLElement | null) => void;
}
export const TaskCommentsSection = ({
@ -216,9 +216,7 @@ export const TaskCommentsSection = ({
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
<div
ref={
registerCommentForViewport
? registerCommentForViewport(new Date(comment.createdAt).getTime())
: undefined
registerCommentForViewport ? registerCommentForViewport(comment.id) : undefined
}
className={[
'group min-w-0 overflow-hidden px-4 py-2.5',

View file

@ -30,7 +30,7 @@ import { Input } from '@renderer/components/ui/input';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { TiptapEditor } from '@renderer/components/ui/tiptap';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
import { getLegacyCutoff, getReadCommentIds } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { useTheme } from '@renderer/hooks/useTheme';
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
@ -46,6 +46,7 @@ import {
} from '@renderer/utils/memberHelpers';
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
import {
@ -252,12 +253,14 @@ export const TaskDetailDialog = ({
unreadSnapshotRef.current = new Set();
return;
}
const lastRead = getLastReadTimestamp(teamName, currentTask.id);
const readIds = getReadCommentIds(teamName, currentTask.id);
const cutoff = getLegacyCutoff(teamName, currentTask.id);
const unread = new Set<string>();
for (const c of comments) {
if (new Date(c.createdAt).getTime() > lastRead) {
unread.add(c.id);
}
if (readIds.has(c.id)) continue;
const ts = new Date(c.createdAt).getTime();
if (cutoff > 0 && ts <= cutoff) continue;
unread.add(c.id);
}
unreadSnapshotRef.current = unread;
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
@ -507,8 +510,7 @@ export const TaskDetailDialog = ({
.map((t) => t.id);
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
const leadName =
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
const leadName = members.find((m) => isLeadMember(m))?.name ?? 'team-lead';
const isLeadOwnedTask =
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';

View file

@ -37,6 +37,7 @@ interface MemberCardProps {
spawnStatus?: MemberSpawnStatus;
spawnError?: string;
onOpenTask?: () => void;
onOpenReviewTask?: () => void;
onClick?: () => void;
onSendMessage?: () => void;
onAssignTask?: () => void;
@ -56,6 +57,7 @@ export const MemberCard = ({
spawnStatus,
spawnError,
onOpenTask,
onOpenReviewTask,
onClick,
onSendMessage,
onAssignTask,
@ -88,7 +90,6 @@ export const MemberCard = ({
const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const activityTask = currentTask ?? reviewTask ?? null;
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
: reviewTask
@ -131,21 +132,31 @@ export const MemberCard = ({
/>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">{displayMemberName(member.name)}</span>
<span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)}
</span>
{member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
{member.gitBranch}
</span>
) : null}
{activityTask && activityLabel ? (
{currentTask ? (
<CurrentTaskIndicator
task={activityTask}
task={currentTask}
borderColor={colors.border}
activityLabel={activityLabel}
activityLabel="working on"
onOpenTask={onOpenTask}
/>
) : null}
{reviewTask ? (
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
activityLabel="reviewing"
onOpenTask={onOpenReviewTask}
/>
) : null}
{!activityTask && isAwaitingReply ? (
<>
<Loader2

View file

@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
@ -98,7 +99,7 @@ export const MemberDetailDialog = ({
member={member}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}
@ -186,7 +187,7 @@ export const MemberDetailDialog = ({
<ListPlus size={14} />
Assign Task
</Button>
{onRemoveMember && member.agentType !== 'team-lead' && (
{onRemoveMember && !isLeadAgentType(member.agentType) && (
<Button
variant="outline"
size="sm"

View file

@ -10,6 +10,7 @@ import {
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
@ -47,7 +48,10 @@ export const MemberDetailHeader = ({
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole =
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
!isLeadAgentType(member.agentType) &&
!member.removedAt &&
!isTeamProvisioning &&
!!onUpdateRole;
return (
<div className="flex items-center gap-3">

View file

@ -15,6 +15,7 @@ import {
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
@ -62,28 +63,26 @@ export const MemberHoverCard = ({
member,
isTeamAlive,
false,
member.agentType === 'team-lead' ? leadActivity : undefined
isLeadAgentType(member.agentType) ? leadActivity : undefined
);
const dotClass = getMemberDotClass(
member,
isTeamAlive,
false,
member.agentType === 'team-lead' ? leadActivity : undefined
isLeadAgentType(member.agentType) ? leadActivity : undefined
);
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const reviewTask: TeamTaskWithKanban | null =
!currentTask && tasks
? (tasks.find(
(task) =>
task.reviewer === member.name &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const activityTask = currentTask ?? reviewTask;
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : 'working on';
const reviewTask: TeamTaskWithKanban | null = tasks
? (tasks.find(
(task) =>
task.reviewer === member.name &&
task.id !== member.currentTaskId &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
return (
<HoverCard openDelay={300} closeDelay={200}>
@ -131,14 +130,27 @@ export const MemberHoverCard = ({
</div>
{/* Current task */}
{activityTask && (
{currentTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={activityTask}
task={currentTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel={activityLabel}
onOpenTask={onOpenTask ? () => onOpenTask(activityTask) : undefined}
activityLabel="working on"
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
/>
</div>
)}
{/* Review task */}
{reviewTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={reviewTask}
borderColor={colors.border}
maxSubjectLength={28}
activityLabel="reviewing"
onOpenTask={onOpenTask ? () => onOpenTask(reviewTask) : undefined}
/>
</div>
)}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { MemberCard } from './MemberCard';
@ -69,8 +70,8 @@ export const MemberList = ({
const activeMembers = members
.filter((m) => !m.removedAt)
.sort((a, b) => {
if (a.agentType === 'team-lead') return -1;
if (b.agentType === 'team-lead') return 1;
if (isLeadAgentType(a.agentType)) return -1;
if (isLeadAgentType(b.agentType)) return 1;
return 0;
});
const removedMembers = members.filter((m) => m.removedAt);
@ -87,14 +88,14 @@ export const MemberList = ({
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewTask =
!currentTask && taskMap
? (Array.from(taskMap.values()).find(
(task) =>
task.reviewer === member.name &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const reviewTask = taskMap
? (Array.from(taskMap.values()).find(
(task) =>
task.reviewer === member.name &&
task.id !== member.currentTaskId &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name);
return (
@ -105,18 +106,15 @@ export const MemberList = ({
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
onOpenTask={
!isRemoved && (currentTask ?? reviewTask)
? () => onOpenTask?.((currentTask ?? reviewTask)!)
: undefined
}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}

View file

@ -24,6 +24,7 @@ import {
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -86,7 +87,7 @@ export const MessageComposer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalTextareaRef]);
const [recipient, setRecipient] = useState<string>(() => {
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
const lead = members.find((m) => isLeadMember(m));
return lead?.name ?? members[0]?.name ?? '';
});
const [recipientOpen, setRecipientOpen] = useState(false);
@ -166,7 +167,7 @@ export const MessageComposer = ({
if (recipient && members.some((m) => m.name === recipient)) {
return;
}
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
const lead = members.find((m) => isLeadMember(m));
const next = lead?.name ?? members[0]?.name ?? '';
if (next && next !== recipient) {
queueMicrotask(() => setRecipient(next));
@ -203,7 +204,7 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const isLeadRecipient = selectedMember ? isLeadMember(selectedMember) : false;
const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
const shouldAutoDelegate = isLeadRecipient && canDelegate;
@ -681,8 +682,8 @@ export const MessageComposer = ({
);
}
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
const aIsLead = isLeadMember(a) ? 1 : 0;
const bIsLead = isLeadMember(b) ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {
@ -787,8 +788,8 @@ export const MessageComposer = ({
);
}
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
const aIsLead = isLeadMember(a) ? 1 : 0;
const bIsLead = isLeadMember(b) ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {

View file

@ -17,4 +17,4 @@ export const CUSTOM_ROLE = '__custom__';
export const NO_ROLE = '__none__';
/** Roles that cannot be assigned manually (reserved for system use). */
export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead']);
export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead', 'orchestrator']);

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { markCommentsRead } from '@renderer/services/commentReadStorage';
import { useViewportObserver } from './useViewportObserver';
@ -20,31 +20,31 @@ interface UseViewportCommentReadOptions {
/**
* Marks task comments as read based on viewport visibility.
*
* Instead of marking all comments read on mount, this hook uses
* IntersectionObserver (via useViewportObserver) to detect which
* comment elements are visible in the scroll container and updates
* the per-task read timestamp to the newest visible comment.
* Uses IntersectionObserver (via useViewportObserver) to detect which
* comment elements are visible in the scroll container and records
* their individual IDs as read via per-comment ID tracking.
*
* Each comment element should be registered via the returned
* `registerComment(commentTimestampMs)` ref callback.
* `registerComment(commentId)` ref callback.
*
* Compatible with the existing per-task timestamp storage format
* in commentReadStorage no storage schema changes needed.
* Only comments that have actually been scrolled into view are marked
* as read fixes the bug where DESC-sorted comments caused all
* comments to be marked read when the newest was visible at the top.
*/
export function useViewportCommentRead({
teamName,
taskId,
scrollContainerRef,
}: UseViewportCommentReadOptions): {
/** Ref callback factory. Call with the comment's createdAt timestamp (ms). */
registerComment: (timestampMs: number) => (el: HTMLElement | null) => void;
/** Ref callback factory. Call with the comment's unique ID. */
registerComment: (commentId: string) => (el: HTMLElement | null) => void;
/**
* Flush the highest observed timestamp now. Call on dialog close
* Flush all observed comment IDs now. Call on dialog close
* as a safety fallback (e.g. if IO did not fire for portal reasons).
*/
flush: () => void;
} {
const highestSeenRef = useRef(0);
const seenIdsRef = useRef<Set<string>>(new Set());
const teamNameRef = useRef(teamName);
const taskIdRef = useRef(taskId);
teamNameRef.current = teamName;
@ -52,23 +52,31 @@ export function useViewportCommentRead({
// Reset tracked state when team/task changes
useEffect(() => {
highestSeenRef.current = 0;
seenIdsRef.current = new Set();
}, [teamName, taskId]);
const handleVisibleChange = useCallback((visibleValues: string[]) => {
let maxTs = 0;
for (const v of visibleValues) {
const ts = Number(v);
if (Number.isFinite(ts) && ts > maxTs) {
maxTs = ts;
}
}
if (maxTs > 0 && maxTs > highestSeenRef.current) {
highestSeenRef.current = maxTs;
markAsRead(teamNameRef.current, taskIdRef.current, maxTs);
const persistSeen = useCallback(() => {
if (seenIdsRef.current.size > 0) {
markCommentsRead(teamNameRef.current, taskIdRef.current, Array.from(seenIdsRef.current));
}
}, []);
const handleVisibleChange = useCallback(
(visibleValues: string[]) => {
let changed = false;
for (const id of visibleValues) {
if (id && !seenIdsRef.current.has(id)) {
seenIdsRef.current.add(id);
changed = true;
}
}
if (changed) {
persistSeen();
}
},
[persistSeen]
);
const { registerElement } = useViewportObserver({
rootRef: scrollContainerRef,
threshold: 0.1,
@ -76,15 +84,13 @@ export function useViewportCommentRead({
});
const registerComment = useCallback(
(timestampMs: number) => registerElement(String(timestampMs)),
(commentId: string) => registerElement(commentId),
[registerElement]
);
const flush = useCallback(() => {
if (highestSeenRef.current > 0) {
markAsRead(teamNameRef.current, taskIdRef.current, highestSeenRef.current);
}
}, []);
persistSeen();
}, [persistSeen]);
return { registerComment, flush };
}

View file

@ -1,21 +1,34 @@
import { get, set } from 'idb-keyval';
const IDB_KEY = 'comment-read-state';
const LS_KEY = 'comment-read-state';
const IDB_KEY = 'comment-read-state-v2';
const LS_KEY = 'comment-read-state-v2';
const LEGACY_IDB_KEY = 'comment-read-state';
const LEGACY_LS_KEY = 'comment-read-state';
const SAVE_DEBOUNCE_MS = 300;
const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
/**
* Per-task read state: tracks individual comment IDs that have been seen.
* `lastUpdated` is used for stale cleanup (prune entries older than 30 days).
*/
interface TaskReadEntry {
readIds: string[];
lastUpdated: number;
}
// --- localStorage fallback ---
type ReadState = Record<string, TaskReadEntry>; // key = "teamName/taskId"
// Legacy format for migration (v1 stored a single timestamp per task)
type LegacyReadState = Record<string, number>;
// --- localStorage helpers ---
function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
return parsed as ReadState;
} catch {
return null;
}
@ -29,10 +42,57 @@ function lsSave(state: ReadState): void {
}
}
function lsLoadLegacy(): LegacyReadState | null {
try {
const raw = localStorage.getItem(LEGACY_LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
// Verify it's the old format (values are numbers, not objects)
const entries = Object.entries(parsed as Record<string, unknown>);
if (entries.length > 0 && typeof entries[0][1] === 'number') {
return parsed as LegacyReadState;
}
return null;
} catch {
return null;
}
}
/**
* Migrate legacy per-task timestamp to per-comment ID format.
* Since we don't have comment IDs from the old format, we treat all
* comments with timestamps <= the old lastRead as "read" by storing
* a sentinel marker. The actual per-comment tracking starts fresh.
*/
function migrateLegacy(legacy: LegacyReadState): ReadState {
const migrated: ReadState = {};
for (const [key, timestamp] of Object.entries(legacy)) {
if (typeof timestamp === 'number' && timestamp > 0) {
// Store legacy timestamp as a sentinel — getUnreadCount will use it
// for comments older than migration, and per-ID for newer ones.
migrated[key] = {
readIds: [],
lastUpdated: timestamp,
};
}
}
return migrated;
}
// Synchronous init from localStorage — guarantees first render sees read state
const lsInitial = lsLoad();
let cache: ReadState = lsInitial ?? {};
let loaded = lsInitial !== null && Object.keys(lsInitial).length > 0;
let cache: ReadState = {};
const v2Data = lsLoad();
if (v2Data && Object.keys(v2Data).length > 0) {
cache = v2Data;
} else {
const legacyData = lsLoadLegacy();
if (legacyData && Object.keys(legacyData).length > 0) {
cache = migrateLegacy(legacyData);
}
}
let loaded = Object.keys(cache).length > 0;
let idbAvailable = true; // flips to false on first IndexedDB failure
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
@ -51,31 +111,109 @@ export function getSnapshot(): ReadState {
}
// --- Mutations ---
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
/**
* Mark specific comment IDs as read for a given team/task.
*/
export function markCommentsRead(teamName: string, taskId: string, commentIds: string[]): void {
if (commentIds.length === 0) return;
const key = `${teamName}/${taskId}`;
const prev = cache[key] ?? 0;
if (latestTimestamp <= prev) return;
cache = { ...cache, [key]: latestTimestamp };
const prev = cache[key];
const prevSet = new Set(prev?.readIds ?? []);
let changed = false;
for (const id of commentIds) {
if (!prevSet.has(id)) {
prevSet.add(id);
changed = true;
}
}
if (!changed) return;
cache = {
...cache,
[key]: {
readIds: Array.from(prevSet),
lastUpdated: Date.now(),
},
};
notify();
scheduleSave();
}
/**
* @deprecated Use markCommentsRead() instead. Kept for backward compatibility
* with code that hasn't migrated yet (e.g. flush fallback).
*/
export function markAsRead(teamName: string, taskId: string, latestTimestamp: number): void {
const key = `${teamName}/${taskId}`;
const prev = cache[key];
// Update lastUpdated to at least this timestamp (for legacy migration support)
const prevLastUpdated = prev?.lastUpdated ?? 0;
if (latestTimestamp <= prevLastUpdated && prev) return;
cache = {
...cache,
[key]: {
readIds: prev?.readIds ?? [],
lastUpdated: Math.max(prevLastUpdated, latestTimestamp),
},
};
notify();
scheduleSave();
}
/**
* Count unread comments for a task.
* A comment is unread if:
* 1. Its ID is NOT in the readIds set, AND
* 2. Its timestamp is AFTER the lastUpdated migration marker (for legacy data)
*/
export function getUnreadCount(
readState: ReadState,
teamName: string,
taskId: string,
comments: { createdAt: string }[]
comments: { id?: string; createdAt: string }[]
): number {
if (!comments || comments.length === 0) return 0;
const key = `${teamName}/${taskId}`;
const lastRead = readState[key] ?? 0;
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
const entry = readState[key];
if (!entry) return comments.length;
const readSet = new Set(entry.readIds);
const legacyCutoff = entry.lastUpdated;
let count = 0;
for (const c of comments) {
// If comment has an ID and it's in the read set → read
if (c.id && readSet.has(c.id)) continue;
// If comment was created before/at the legacy cutoff → read (migrated data)
const ts = new Date(c.createdAt).getTime();
if (legacyCutoff > 0 && ts <= legacyCutoff) continue;
// Otherwise → unread
count++;
}
return count;
}
/** Return the last-read timestamp for a team/task pair (0 if never read). */
/**
* Get the set of read comment IDs for a team/task pair.
*/
export function getReadCommentIds(teamName: string, taskId: string): Set<string> {
const key = `${teamName}/${taskId}`;
const entry = cache[key];
return new Set(entry?.readIds ?? []);
}
/**
* Get the legacy migration cutoff timestamp for a team/task pair (0 if none).
*/
export function getLegacyCutoff(teamName: string, taskId: string): number {
const key = `${teamName}/${taskId}`;
return cache[key]?.lastUpdated ?? 0;
}
/** @deprecated Use getReadCommentIds() + getLegacyCutoff() instead. */
export function getLastReadTimestamp(teamName: string, taskId: string): number {
const key = `${teamName}/${taskId}`;
return cache[key] ?? 0;
return cache[key]?.lastUpdated ?? 0;
}
// --- Internal ---
@ -98,17 +236,48 @@ function scheduleSave(): void {
async function load(): Promise<void> {
if (loaded) return;
// IDB may have fresher data — merge with max timestamp per key
if (hasIndexedDB() && idbAvailable) {
try {
// Try v2 format first
const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') {
const merged = { ...cache };
for (const [k, v] of Object.entries(stored)) {
merged[k] = Math.max(merged[k] ?? 0, v);
if (!v || typeof v !== 'object') continue;
const entry = v as TaskReadEntry;
const prev = merged[k];
if (!prev) {
merged[k] = entry;
} else {
// Merge: union of readIds, max lastUpdated
const mergedIds = new Set([...prev.readIds, ...entry.readIds]);
merged[k] = {
readIds: Array.from(mergedIds),
lastUpdated: Math.max(prev.lastUpdated, entry.lastUpdated),
};
}
}
cache = merged;
notify();
} else {
// Try legacy IDB format
const legacy = await get<LegacyReadState>(LEGACY_IDB_KEY);
if (legacy && typeof legacy === 'object') {
const migrated = migrateLegacy(legacy);
const merged = { ...cache };
for (const [k, v] of Object.entries(migrated)) {
if (!merged[k]) {
merged[k] = v;
} else {
merged[k] = {
readIds: [...new Set([...merged[k].readIds, ...v.readIds])],
lastUpdated: Math.max(merged[k].lastUpdated, v.lastUpdated),
};
}
}
cache = merged;
notify();
}
}
} catch {
idbAvailable = false;
@ -134,31 +303,27 @@ async function save(): Promise<void> {
export async function cleanupStale(): Promise<void> {
const now = Date.now();
const clean = (state: ReadState): { cleaned: ReadState; changed: boolean } => {
const result: ReadState = {};
let changed = false;
for (const [k, v] of Object.entries(state)) {
if (now - v < STALE_THRESHOLD_MS) {
result[k] = v;
} else {
changed = true;
}
let changed = false;
const result: ReadState = {};
for (const [k, v] of Object.entries(cache)) {
if (now - v.lastUpdated < STALE_THRESHOLD_MS) {
result[k] = v;
} else {
changed = true;
}
return { cleaned: result, changed };
};
}
const { cleaned, changed } = clean(cache);
if (!changed) return;
// Update in-memory cache
cache = cleaned;
cache = result;
notify();
// Persist to both storages
lsSave(cleaned);
lsSave(result);
if (idbAvailable && hasIndexedDB()) {
try {
await set(IDB_KEY, cleaned);
await set(IDB_KEY, result);
} catch {
idbAvailable = false;
}

View file

@ -61,7 +61,6 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
selectProject: (id: string) => {
set({
selectedProjectId: id,
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
...getSessionResetState(),
});

View file

@ -118,7 +118,6 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
selectedWorktreeId: worktreeToSelect.id,
selectedProjectId: worktreeToSelect.id,
activeProjectId: worktreeToSelect.id,
sidebarCollapsed: false, // Ensure session list is visible when a project is selected
...getSessionResetState(),
});
// Fetch sessions for this worktree

View file

@ -35,7 +35,6 @@ export function getWorktreeNavigationState(repoId: string, worktreeId: string):
selectedWorktreeId: worktreeId,
selectedProjectId: worktreeId,
activeProjectId: worktreeId,
sidebarCollapsed: false,
...getSessionResetState(),
};
}

View file

@ -3,6 +3,7 @@ import {
MEMBER_COLOR_PALETTE,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import type {
LeadActivityState,
@ -42,7 +43,7 @@ export function getMemberDotClass(
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
if (leadActivity && member.agentType === 'team-lead') {
if (leadActivity && isLeadAgentType(member.agentType)) {
return leadActivity === 'active'
? `${STATUS_DOT_COLORS.active} animate-pulse`
: STATUS_DOT_COLORS.active;
@ -62,7 +63,7 @@ export function getPresenceLabel(
if (member.status === 'terminated') return 'terminated';
if (isTeamProvisioning) return 'connecting';
if (isTeamAlive === false) return 'offline';
if (leadActivity && member.agentType === 'team-lead') {
if (leadActivity && isLeadAgentType(member.agentType)) {
if (leadActivity === 'active') {
return leadContextPercent != null && leadContextPercent > 0
? `processing (${Math.round(leadContextPercent)}%)`

View 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';
}