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:
parent
032d9b478b
commit
1d07d6bb96
9 changed files with 177 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue