diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index c5d66eae..c7d4bd35 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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: "", from: "" }`), diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index e5804b46..d1173ece 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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` + '\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n', }, ], diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index ae166e26..10c44f6e 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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, diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index d4d684b3..45e0a29b 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -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); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 26c0439a..ec73534e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { // 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 { 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(); @@ -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 []; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 87459242..b9e43ff8 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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(); 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 }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 85713ce8..10e495d1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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> = []; 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 }); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 6dd1a5cd..17bbe3e9 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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); diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index d15ade7c..6a9ba147 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -69,7 +69,7 @@ export function getTaskUnreadCount( readState: ReturnType, teamName: string, taskId: string, - comments: { createdAt: string }[] | undefined + comments: { id?: string; createdAt: string }[] | undefined ): number { return getUnreadCount(readState, teamName, taskId, comments ?? []); } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e4e9099d..c34f5741 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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)} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 8ac8da36..e9cfda4f 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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(null); + const [launchDialogOpen, setLaunchDialogOpen] = useState(false); + const [launchDialogTeamName, setLaunchDialogTeamName] = useState(''); + const [launchDialogMembers, setLaunchDialogMembers] = useState([]); + const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState(); + 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 = ( + setLaunchDialogOpen(false)} + onLaunch={handleLaunchSubmit} + /> + ); + const renderHeader = (): React.JSX.Element => (
@@ -979,6 +1027,7 @@ export const TeamListView = (): React.JSX.Element => { {renderHeader()} {renderContent()} {createDialogElement} + {launchDialogElement}
); diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 4b222685..57d551f6 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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 = ({ - + setOwner(v ?? '')} + placeholder={requiresOwner ? 'Select a member' : 'Select member...'} + allowUnassigned={!requiresOwner} + />
); return ( - + Create Task @@ -294,51 +256,199 @@ export const CreateTaskDialog = ({ {assigneeField} -
- - descChipDraft.setChips([...descChipDraft.chips, chip])} - minRows={3} - maxRows={12} - footerRight={ - descriptionDraft.isSaved ? ( - Saved - ) : null - } - /> -
+ {/* Toggle button for optional fields */} + -
- - Saved - ) : null - } - /> + {/* Collapsible optional fields */} +
+
+
+
+ + +
+ +
+ + Saved + ) : null + } + /> +
+ + {availableTasks.length > 0 ? ( +
+ +
+ {availableTasks.length > 3 ? ( +
+ + 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" + /> +
+ ) : null} +
+ {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 ( + + ); + })} +
+
+ {blockedBy.length > 0 ? ( +

+ Task will be blocked by:{' '} + {blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} +

+ ) : null} +
+ ) : null} + + {availableTasks.length > 0 ? ( +
+ +
+ {availableTasks.length > 3 ? ( +
+ + 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" + /> +
+ ) : null} +
+ {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 ( + + ); + })} +
+
+ {related.length > 0 ? ( +

+ Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} +

+ ) : null} +
+ ) : null} +
+
{owner ? ( @@ -364,147 +474,6 @@ export const CreateTaskDialog = ({ ) : null}
) : null} - - {availableTasks.length > 0 ? ( -
- -
- {availableTasks.length > 3 ? ( -
- - 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" - /> -
- ) : null} -
- {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 ( - - ); - })} -
-
- {blockedBy.length > 0 ? ( -

- Task will be blocked by:{' '} - {blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} -

- ) : null} -
- ) : null} - - {availableTasks.length > 0 ? ( -
- -
- {availableTasks.length > 3 ? ( -
- - 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" - /> -
- ) : null} -
- {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 ( - - ); - })} -
-
- {related.length > 0 ? ( -

- Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')} -

- ) : null} -
- ) : null} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 6730b1ff..311dea6e 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index d60ee621..275058b6 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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 = ({
(); 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'; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index d0fd37e1..c47d2ef8 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 = ({ />
- {displayMemberName(member.name)} + + {displayMemberName(member.name)} + {member.gitBranch ? ( {member.gitBranch} ) : null} - {activityTask && activityLabel ? ( + {currentTask ? ( ) : null} + {reviewTask ? ( + + ) : null} {!activityTask && isAwaitingReply ? ( <> onUpdateRole(member.name, newRole) : undefined } @@ -186,7 +187,7 @@ export const MemberDetailDialog = ({ Assign Task - {onRemoveMember && member.agentType !== 'team-lead' && ( + {onRemoveMember && !isLeadAgentType(member.agentType) && (
{/* Current task */} - {activityTask && ( + {currentTask && (
onOpenTask(activityTask) : undefined} + activityLabel="working on" + onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined} + /> +
+ )} + + {/* Review task */} + {reviewTask && ( +
+ onOpenTask(reviewTask) : undefined} />
)} diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 88e207fe..f7a6a9d3 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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)} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index d579629e..1897dd00 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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(() => { - 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) => { diff --git a/src/renderer/constants/teamRoles.ts b/src/renderer/constants/teamRoles.ts index 9ea6ca35..cd1ec048 100644 --- a/src/renderer/constants/teamRoles.ts +++ b/src/renderer/constants/teamRoles.ts @@ -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']); diff --git a/src/renderer/hooks/useViewportCommentRead.ts b/src/renderer/hooks/useViewportCommentRead.ts index ea26aac2..95e8d8f0 100644 --- a/src/renderer/hooks/useViewportCommentRead.ts +++ b/src/renderer/hooks/useViewportCommentRead.ts @@ -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>(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 }; } diff --git a/src/renderer/services/commentReadStorage.ts b/src/renderer/services/commentReadStorage.ts index b96d27be..b3a62201 100644 --- a/src/renderer/services/commentReadStorage.ts +++ b/src/renderer/services/commentReadStorage.ts @@ -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; // 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; // key = "teamName/taskId" + +// Legacy format for migration (v1 stored a single timestamp per task) +type LegacyReadState = Record; + +// --- 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); + 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 | 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 { + 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 { 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(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(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 { export async function cleanupStale(): Promise { 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; } diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts index e02426e1..010427a8 100644 --- a/src/renderer/store/slices/projectSlice.ts +++ b/src/renderer/store/slices/projectSlice.ts @@ -61,7 +61,6 @@ export const createProjectSlice: StateCreator = selectProject: (id: string) => { set({ selectedProjectId: id, - sidebarCollapsed: false, // Ensure session list is visible when a project is selected ...getSessionResetState(), }); diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index c27e7e7c..1eb6af45 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -118,7 +118,6 @@ export const createRepositorySlice: StateCreator 0 ? `processing (${Math.round(leadContextPercent)}%)` diff --git a/src/shared/utils/leadDetection.ts b/src/shared/utils/leadDetection.ts new file mode 100644 index 00000000..0cbf2e72 --- /dev/null +++ b/src/shared/utils/leadDetection.ts @@ -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'; +}