diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts index 6d547d0f..07fd8e66 100644 --- a/src/main/services/discovery/WorktreeGrouper.ts +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -60,11 +60,12 @@ export class WorktreeGrouper { await Promise.all( projects.map(async (project) => { - const identity = await gitIdentityResolver.resolveIdentity(project.path); + const normalizedProjectPath = path.normalize(project.path); + const identity = await gitIdentityResolver.resolveIdentity(normalizedProjectPath); projectIdentities.set(project.id, identity); // Also get branch name for display - const branch = await gitIdentityResolver.getBranch(project.path); + const branch = await gitIdentityResolver.getBranch(normalizedProjectPath); projectBranches.set(project.id, branch); }) ); @@ -137,19 +138,18 @@ export class WorktreeGrouper { for (const [groupId, group] of repoGroups) { const worktrees: Worktree[] = await Promise.all( group.projects.map(async (project) => { + const normalizedProjectPath = path.normalize(project.path); const branch = group.branches.get(project.id) ?? null; - const isMainWorktree = !(await gitIdentityResolver.isWorktree(project.path)); + const isMainWorktree = !(await gitIdentityResolver.isWorktree(normalizedProjectPath)); // Use filtered sessions instead of raw sessions const filteredSessions = projectFilteredSessions.get(project.id) ?? []; // Detect worktree source for badge display // project.path may use forward slashes (e.g. decodePath() returns "C:/..."). // detectWorktreeSource splits on path.sep, so normalize to the current platform first. - const source = await gitIdentityResolver.detectWorktreeSource( - path.normalize(project.path) - ); + const source = await gitIdentityResolver.detectWorktreeSource(normalizedProjectPath); // Use source-aware display name generation const displayName = await gitIdentityResolver.getWorktreeDisplayName( - project.path, + normalizedProjectPath, source, branch, isMainWorktree diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index c6113291..be40b631 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -914,11 +914,14 @@ export class TeamDataService { const comment = await this.taskWriter.addComment(teamName, taskId, text); try { - const [tasks, toolPath] = await Promise.all([ + const [tasks, toolPath, config, metaMembers] = await Promise.all([ this.taskReader.getTasks(teamName), this.toolsInstaller.ensureInstalled(), + this.configReader.getConfig(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const task = tasks.find((t) => t.id === taskId); + const leadName = this.resolveLeadNameFromConfig(config); // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) @@ -927,6 +930,15 @@ export class TeamDataService { } if (task?.owner) { + // Solo team UX: if the user comments on a lead-owned task, don't echo the + // comment back as an inbox notification from the lead. The comment is already visible. + if ( + this.isSoloTeamFromMembers(config, metaMembers, leadName) && + this.isLeadOwner(task.owner, leadName) + ) { + return comment; + } + const parts = [ `Comment on task #${taskId} "${task.subject}":\n\n${text}`, `\n${AGENT_BLOCK_OPEN}`, @@ -934,7 +946,6 @@ export class TeamDataService { `node "${toolPath}" --team ${teamName} task comment ${taskId} --text "" --from ""`, AGENT_BLOCK_CLOSE, ]; - const leadName = await this.resolveLeadName(teamName); await this.sendMessage(teamName, { member: task.owner, from: leadName, @@ -953,17 +964,48 @@ export class TeamDataService { return this.inboxWriter.sendMessage(teamName, request); } + private resolveLeadNameFromConfig(config: TeamConfig | null): string { + if (!config) return 'team-lead'; + const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); + return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; + } + private async resolveLeadName(teamName: string): Promise { try { const config = await this.configReader.getConfig(teamName); - if (!config) return 'team-lead'; - const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead')); - return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; + return this.resolveLeadNameFromConfig(config); } catch { return 'team-lead'; } } + private isLeadOwner(owner: string, leadName: string): boolean { + const normalized = owner.trim(); + if (!normalized) return false; + return normalized === leadName || normalized === 'team-lead'; + } + + private isSoloTeamFromMembers( + config: TeamConfig | null, + metaMembers: TeamMember[], + leadName: string + ): boolean { + const configMembers = config?.members ?? []; + const combined = [...configMembers, ...(metaMembers ?? [])]; + + const activeNonLead = combined.filter((m) => { + const name = m.name?.trim(); + if (!name) return false; + if (m.removedAt) return false; + if (m.agentType === 'team-lead') return false; + if (name === 'team-lead') return false; + if (name === leadName) return false; + return true; + }); + + return activeNonLead.length === 0; + } + async sendDirectToLead( teamName: string, leadName: string, diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6345664e..7501eaa9 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -170,12 +170,19 @@ export class TeamMemberLogsFinder { } } + const normalizedOwner = + typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; + const isLeadOwner = + typeof normalizedOwner === 'string' && + normalizedOwner.length > 0 && + normalizedOwner.toLowerCase() === leadMemberName.toLowerCase(); const includeOwnerSessions = options?.status === 'in_progress' && - typeof options?.owner === 'string' && - options.owner.trim().length > 0; + typeof normalizedOwner === 'string' && + normalizedOwner.length > 0 && + !isLeadOwner; if (includeOwnerSessions) { - const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim()); + const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner); const TASK_LOG_INTERVAL_GRACE_MS = 10_000; const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index b7a07094..920145e0 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -426,20 +426,22 @@ export const CreateTeamDialog = ({ const mentionSuggestions = useMemo( () => - members - .filter((m) => m.name.trim()) - .map((m, index) => ({ - id: m.id, - name: m.name.trim(), - subtitle: - m.roleSelection === CUSTOM_ROLE - ? m.customRole.trim() || undefined - : m.roleSelection && m.roleSelection !== NO_ROLE - ? m.roleSelection - : undefined, - color: getMemberColor(index), - })), - [members] + soloTeam + ? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }] + : members + .filter((m) => m.name.trim()) + .map((m, index) => ({ + id: m.id, + name: m.name.trim(), + subtitle: + m.roleSelection === CUSTOM_ROLE + ? m.customRole.trim() || undefined + : m.roleSelection && m.roleSelection !== NO_ROLE + ? m.roleSelection + : undefined, + color: getMemberColor(index), + })), + [members, soloTeam] ); const effectiveModel = useMemo( @@ -711,7 +713,7 @@ export const CreateTeamDialog = ({ maxRows={12} value={prompt} onValueChange={promptDraft.setValue} - suggestions={mentionSuggestions} + suggestions={soloTeam ? [] : mentionSuggestions} projectPath={effectiveCwd || null} chips={promptChipDraft.chips} onChipRemove={promptChipDraft.removeChip} diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 4f4f43e8..968640d0 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -38,6 +38,8 @@ interface ContinuousScrollViewProps { onContentChanged: (filePath: string, content: string) => void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; + onAcceptNewFile: (filePath: string) => void; + onRejectNewFile: (filePath: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void; onVisibleFileChange: (filePath: string) => void; scrollContainerRef: React.RefObject; @@ -72,6 +74,8 @@ export const ContinuousScrollView = ({ onContentChanged, onDiscard, onSave, + onAcceptNewFile, + onRejectNewFile, onRestoreMissingFile, onVisibleFileChange, scrollContainerRef, @@ -224,6 +228,8 @@ export const ContinuousScrollView = ({ onToggleCollapse={handleToggleCollapse} onDiscard={onDiscard} onSave={onSave} + onAcceptNewFile={onAcceptNewFile} + onRejectNewFile={onRejectNewFile} onRestoreMissingFile={onRestoreMissingFile} /> diff --git a/src/renderer/components/team/review/FileSectionHeader.tsx b/src/renderer/components/team/review/FileSectionHeader.tsx index 318ab17b..cb02ef42 100644 --- a/src/renderer/components/team/review/FileSectionHeader.tsx +++ b/src/renderer/components/team/review/FileSectionHeader.tsx @@ -26,6 +26,8 @@ interface FileSectionHeaderProps { onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; onRestoreMissingFile?: (filePath: string, content: string) => void; + onAcceptNewFile?: (filePath: string) => void; + onRejectNewFile?: (filePath: string) => void; } export const FileSectionHeader = ({ @@ -39,6 +41,8 @@ export const FileSectionHeader = ({ onDiscard, onSave, onRestoreMissingFile, + onAcceptNewFile, + onRejectNewFile, }: FileSectionHeaderProps): React.ReactElement => { const isMissingOnDisk = fileContent?.contentSource === 'unavailable'; const restoreContent = @@ -141,6 +145,38 @@ export const FileSectionHeader = ({ )}
+ {file.isNewFile && (onAcceptNewFile || onRejectNewFile) && ( +
+ {onAcceptNewFile && ( + + )} + {onRejectNewFile && ( + + )} +
+ )} {canRestore && restoreContent != null && ( diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index b410502b..b847c95d 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -158,6 +158,11 @@ const TreeItem = ({ > {node.name} + {node.data.isNewFile && ( + + new + + )} {node.data.linesAdded > 0 && ( +{node.data.linesAdded} diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 39ad0a1a..9ce05b56 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -427,4 +427,50 @@ describe('TeamMemberLogsFinder', () => { true ); }); + + it('findLogsForTask does not auto-include owner sessions when owner is team-lead', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-lead-owner-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 't6'; + const projectPath = '/Users/test/proj6'; + const projectId = '-Users-test-proj6'; + const leadSessionId = 's6'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + // Lead session exists but does NOT reference taskId 42. + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }, + }) + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const logs = await finder.findLogsForTask(teamName, '42', { + owner: 'team-lead', + status: 'in_progress', + intervals: [{ startedAt: '2026-01-01T10:00:00.000Z' }], + }); + + // We only want sessions that explicitly reference the task id. + expect(logs).toHaveLength(0); + }); }); diff --git a/test/main/services/team/teamctl.test.ts b/test/main/services/team/teamctl.test.ts index 4e31e447..ec5095df 100644 --- a/test/main/services/team/teamctl.test.ts +++ b/test/main/services/team/teamctl.test.ts @@ -740,10 +740,10 @@ describe('teamctl.js', () => { expect(String(comments[0].createdAt)).toMatch(ISO_RE); }); - it('defaults author to "agent" when --from is not specified', () => { + it('defaults author to inferred lead name when --from is not specified', () => { run(claudeDir, ['task', 'comment', '1', '--text', 'No author']); const comments = readTask(claudeDir, '1').comments as Record[]; - expect(comments[0].author).toBe('agent'); + expect(comments[0].author).toBe('alice'); }); it('sends inbox notification to owner (skip self-notification)', () => { @@ -1899,7 +1899,7 @@ describe('teamctl.js', () => { // from=true → not a string → defaults to 'agent' expect(exitCode).toBe(0); const comments = readTask(claudeDir, '1').comments as { author: string; text: string }[]; - expect(comments[0].author).toBe('agent'); // not "--text" + expect(comments[0].author).toBe('alice'); // not "--text" expect(comments[0].text).toBe('Hello'); });