feat: normalize project paths and enhance team member handling

- Updated WorktreeGrouper to normalize project paths before resolving identities and branches, ensuring consistent behavior across platforms.
- Enhanced TeamDataService to improve lead name resolution and prevent self-notification for comments on lead-owned tasks in solo teams.
- Added new utility functions in TeamDataService for better lead name handling and solo team validation.
- Improved TeamMemberLogsFinder to exclude owner sessions when the owner is the team lead, refining log retrieval logic.
- Updated UI components to reflect changes in team member suggestions based on solo team status.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-04 00:47:35 +02:00
parent 032d9b478b
commit 1d07d6bb96
9 changed files with 177 additions and 33 deletions

View file

@ -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

View file

@ -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 "<your reply>" --from "<your-name>"`,
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<string> {
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,

View file

@ -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

View file

@ -426,20 +426,22 @@ export const CreateTeamDialog = ({
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
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}

View file

@ -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<HTMLDivElement>;
@ -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}
/>

View file

@ -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 = ({
)}
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
{file.isNewFile && (onAcceptNewFile || onRejectNewFile) && (
<div className="mr-1 flex items-center gap-1.5">
{onAcceptNewFile && (
<button
onClick={() => onAcceptNewFile(file.filePath)}
disabled={applying}
className={[
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
fileDecision === 'accepted'
? 'bg-green-500/25 text-green-300'
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25',
].join(' ')}
>
Accept
</button>
)}
{onRejectNewFile && (
<button
onClick={() => onRejectNewFile(file.filePath)}
disabled={applying}
className={[
'rounded px-2 py-1 text-xs font-medium transition-colors disabled:opacity-50',
fileDecision === 'rejected'
? 'bg-red-500/25 text-red-300'
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25',
].join(' ')}
>
Reject
</button>
)}
</div>
)}
{canRestore && restoreContent != null && (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -158,6 +158,11 @@ const TreeItem = ({
>
{node.name}
</span>
{node.data.isNewFile && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
new
</span>
)}
<span className="ml-1 flex shrink-0 items-center gap-1">
{node.data.linesAdded > 0 && (
<span className="text-green-400">+{node.data.linesAdded}</span>

View file

@ -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);
});
});

View file

@ -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<string, unknown>[];
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');
});