diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a1b81113..766bd8f8 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -274,6 +274,10 @@ async function validateProvisioningRequest( return { valid: false, error: 'cwd must be an absolute path' }; } + if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { + return { valid: false, error: 'prompt must be a string' }; + } + try { await fs.promises.mkdir(cwd, { recursive: true }); } catch { @@ -290,10 +294,6 @@ async function validateProvisioningRequest( return { valid: false, error: 'cwd must be a directory' }; } - if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { - return { valid: false, error: 'prompt must be a string' }; - } - return { valid: true, value: { diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 43c06c94..2c4ec347 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; -import type { TeamConfig, TeamSummary } from '@shared/types'; +import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; const logger = createLogger('Service:TeamConfigReader'); @@ -40,11 +40,23 @@ export class TeamConfigReader { continue; } - const memberNames = new Set(); + const memberMap = new Map(); + + const addMember = (m: TeamMember): void => { + const name = m.name?.trim(); + if (!name) return; + const existing = memberMap.get(name); + memberMap.set(name, { + name, + role: m.role?.trim() || existing?.role, + color: m.color?.trim() || existing?.color, + }); + }; + if (Array.isArray(config.members)) { for (const member of config.members) { - if (typeof member?.name === 'string' && member.name.trim().length > 0) { - memberNames.add(member.name.trim()); + if (member && typeof member.name === 'string') { + addMember(member); } } } @@ -52,9 +64,7 @@ export class TeamConfigReader { try { const metaMembers = await this.membersMetaStore.getMembers(entry.name); for (const member of metaMembers) { - if (member.name.trim().length > 0) { - memberNames.add(member.name.trim()); - } + addMember(member); } } catch { logger.debug(`Failed to read members.meta.json for team: ${entry.name}`); @@ -68,15 +78,16 @@ export class TeamConfigReader { continue; } const inboxName = inbox.slice(0, -'.json'.length).trim(); - if (inboxName.length > 0) { - memberNames.add(inboxName); + if (inboxName.length > 0 && !memberMap.has(inboxName)) { + memberMap.set(inboxName, { name: inboxName }); } } } catch { // Inbox folder may not exist yet. } - const memberCount = memberNames.size; + const memberCount = memberMap.size; + const members = Array.from(memberMap.values()); summaries.push({ teamName: entry.name, displayName: config.name, @@ -86,6 +97,7 @@ export class TeamConfigReader { ? config.color : undefined, memberCount, + members: members.length > 0 ? members : undefined, taskCount: 0, lastActivity: null, projectPath: diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0e60c01d..d89948da 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -5,6 +5,7 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { getMemberColor } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; @@ -74,14 +75,19 @@ export class TeamDataService { }); } - return rawTasks.map((task) => { - const info = teamInfoMap.get(task.teamName); - return { - ...task, - teamDisplayName: info?.displayName ?? task.teamName, - projectPath: task.projectPath ?? info?.projectPath, - }; - }); + // Only include tasks that belong to a known team. + // ~/.claude/tasks/ may also contain solo session task dirs (UUID-named) + // which have no corresponding team in ~/.claude/teams/. + return rawTasks + .filter((task) => teamInfoMap.has(task.teamName)) + .map((task) => { + const info = teamInfoMap.get(task.teamName)!; + return { + ...task, + teamDisplayName: info.displayName, + projectPath: task.projectPath ?? info.projectPath, + }; + }); } async updateConfig( @@ -304,7 +310,6 @@ export class TeamDataService { await fs.promises.mkdir(teamDir, { recursive: true }); await fs.promises.mkdir(tasksDir, { recursive: true }); - const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const; const joinedAt = Date.now(); const config = { name: request.displayName?.trim() || request.teamName, @@ -319,7 +324,7 @@ export class TeamDataService { name: member.name, role: member.role?.trim() || undefined, agentType: 'general-purpose', - color: memberColors[index % memberColors.length], + color: getMemberColor(index), joinedAt, })) ); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e45a30e5..ff403aef 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -278,7 +278,7 @@ Steps (execute in this exact order): - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Send ONE message to the team lead: 'Hello! My name is {name} ({role}). I'm ready.' Then wait for task assignments. + - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then wait for task assignments. ${taskProtocol}" @@ -320,7 +320,7 @@ Steps (execute in this exact order): - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Send ONE message to the team lead: 'Hello! My name is {name} ({role}). I'm ready.' Then check TaskList for pending work and resume. + - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then check TaskList for pending work and resume. ${taskProtocol}" diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 386af8d9..4c1ba78c 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { ChevronRight } from 'lucide-react'; @@ -22,9 +22,9 @@ export const CollapsibleTeamSection = ({ }: CollapsibleTeamSectionProps): React.JSX.Element => { const [open, setOpen] = useState(defaultOpen); - useEffect(() => { - if (forceOpen) setOpen(true); - }, [forceOpen]); + if (forceOpen && !open) { + setOpen(true); + } return (
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 141f89a1..025b7995 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -450,6 +450,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={data.members} isTeamAlive={data.isAlive} onMemberClick={setSelectedMember} + onSendMessage={(member) => { + setSendDialogRecipient(member.name); + setSendDialogOpen(true); + }} + onAssignTask={(member) => { + openCreateTaskDialog('', '', member.name); + }} /> diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 5c151994..c492d035 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -37,6 +37,7 @@ function getRecentProjects(team: TeamSummary): string[] { } function folderName(fullPath: string): string { + // eslint-disable-next-line sonarjs/slow-regex -- Anchored regex on short path strings, no backtracking risk const parts = fullPath.replace(/\/+$/, '').split('/'); return parts[parts.length - 1] || fullPath; } @@ -381,10 +382,39 @@ export const TeamListView = (): React.JSX.Element => {

{team.description || 'No description'}

-
- - Members: {team.memberCount} - +
+ {team.members && team.members.length > 0 ? ( + team.members.map((m) => { + const memberColor = m.color ? getTeamColorSet(m.color) : null; + return ( + + + {m.name} + + {m.role ? ( + + {m.role} + + ) : null} + + ); + }) + ) : ( + + Members: {team.memberCount} + + )} Tasks: {team.taskCount} diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 7133fc40..f3ed53f3 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { @@ -19,7 +20,7 @@ import { SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; -import { Textarea } from '@renderer/components/ui/textarea'; +import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import type { ResolvedTeamMember, TeamTask } from '@shared/types'; @@ -54,18 +55,23 @@ export const CreateTaskDialog = ({ submitting = false, }: CreateTaskDialogProps): React.JSX.Element => { const [subject, setSubject] = useState(defaultSubject); - const [description, setDescription] = useState(defaultDescription); + const descriptionDraft = useDraftPersistence({ + key: 'createTask:description', + initialValue: defaultDescription || undefined, + }); const [owner, setOwner] = useState(defaultOwner); const [blockedBy, setBlockedBy] = useState([]); - const [prompt, setPrompt] = useState(''); + const promptDraft = useDraftPersistence({ key: 'createTask:prompt' }); const [prevOpen, setPrevOpen] = useState(false); if (open && !prevOpen) { setSubject(defaultSubject); - setDescription(defaultDescription); + if (defaultDescription) { + descriptionDraft.setValue(defaultDescription); + } setOwner(defaultOwner); setBlockedBy([]); - setPrompt(''); + promptDraft.clearDraft(); } if (open !== prevOpen) { setPrevOpen(open); @@ -86,11 +92,13 @@ export const CreateTaskDialog = ({ if (!canSubmit) return; onSubmit( subject.trim(), - description.trim(), + descriptionDraft.value.trim(), owner || undefined, blockedBy.length > 0 ? blockedBy : undefined, - prompt.trim() || undefined + promptDraft.value.trim() || undefined ); + descriptionDraft.clearDraft(); + promptDraft.clearDraft(); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -127,24 +135,32 @@ export const CreateTaskDialog = ({
-