feat: enhance team member management with color coding and improved prompts

- Added member color assignment using a new utility function for better visual representation in the UI.
- Updated team member prompts to encourage brief introductions, aligning with project guidelines.
- Introduced a draft persistence mechanism for message and task creation dialogs to enhance user experience.
- Refactored team configuration handling to support new member structures and improve data integrity.

This update aims to streamline team interactions and improve the overall user experience in team management features.
This commit is contained in:
iliya 2026-02-22 16:41:16 +02:00
parent 9b64576377
commit 0867966d17
28 changed files with 741 additions and 146 deletions

View file

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

View file

@ -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<string>();
const memberMap = new Map<string, TeamSummaryMember>();
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:

View file

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

View file

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

View file

@ -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 (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">

View file

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

View file

@ -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 => {
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex items-center gap-2">
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">
{m.role}
</span>
) : null}
</span>
);
})
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
<Badge variant="secondary" className="text-[10px] font-normal">
Tasks: {team.taskCount}
</Badge>

View file

@ -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<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
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 = ({
<div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label>
<Textarea
<AutoResizeTextarea
id="task-description"
placeholder="Task details..."
value={description}
rows={3}
onChange={(e) => setDescription(e.target.value)}
value={descriptionDraft.value}
minRows={3}
maxRows={12}
onChange={(e) => descriptionDraft.setValue(e.target.value)}
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
<Textarea
<AutoResizeTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={prompt}
rows={3}
onChange={(e) => setPrompt(e.target.value)}
value={promptDraft.value}
minRows={3}
maxRows={12}
onChange={(e) => promptDraft.setValue(e.target.value)}
/>
{promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="grid gap-2">

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
@ -21,9 +22,10 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { getMemberColor } from '@shared/constants/memberColors';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
const TEAM_COLOR_NAMES = [
@ -233,8 +235,8 @@ export const CreateTeamDialog = ({
const isDev = process.env.NODE_ENV !== 'production';
const [teamName, setTeamName] = useState('');
const [description, setDescription] = useState('');
const [prompt, setPrompt] = useState('');
const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' });
const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' });
const [members, setMembers] = useState<MemberDraft[]>([]);
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
@ -257,8 +259,8 @@ export const CreateTeamDialog = ({
const resetFormState = (): void => {
setTeamName('');
setDescription('');
setPrompt('');
descriptionDraft.clearDraft();
promptDraft.clearDraft();
setMembers([]);
setTeamColor('');
setCwdMode('project');
@ -359,7 +361,7 @@ export const CreateTeamDialog = ({
if (initialData) {
setTeamName(initialData.teamName);
setDescription(initialData.description ?? '');
descriptionDraft.setValue(initialData.description ?? '');
setTeamColor(initialData.color ?? '');
setMembers(
initialData.members.map((m) => {
@ -403,10 +405,11 @@ export const CreateTeamDialog = ({
if (teamName.trim().length === 0) {
setTeamName(DEV_DEFAULT_TEAM.teamName);
}
if (description.trim().length === 0) {
setDescription(DEV_DEFAULT_TEAM.description);
if (descriptionDraft.value.trim().length === 0) {
descriptionDraft.setValue(DEV_DEFAULT_TEAM.description);
}
}, [open, isDev, teamName, description, initialData]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev default, intentional deps
}, [open, isDev, teamName, initialData]);
useEffect(() => {
if (cwdMode !== 'project') {
@ -420,6 +423,9 @@ export const CreateTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const description = descriptionDraft.value;
const prompt = promptDraft.value;
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: teamName.trim(),
@ -585,14 +591,18 @@ export const CreateTeamDialog = ({
<Label htmlFor="team-description" className="text-xs text-[var(--color-text-muted)]">
description (optional)
</Label>
<Textarea
<AutoResizeTextarea
id="team-description"
className="min-h-[40px] resize-none text-xs"
rows={2}
className="text-xs"
minRows={2}
maxRows={8}
value={description}
onChange={(event) => setDescription(event.target.value)}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
@ -629,59 +639,77 @@ export const CreateTeamDialog = ({
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">members</Label>
<div className="space-y-2">
{members.map((member, index) => (
<div
key={member.id}
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
>
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
onChange={(event) => updateMemberName(member.id, event.target.value)}
placeholder="member-name"
/>
<div className="space-y-1">
<Select
value={member.roleSelection || NO_ROLE}
onValueChange={(roleSelection) => updateMemberRole(member.id, roleSelection)}
>
<SelectTrigger
className="h-8 text-xs"
aria-label={`Member ${index + 1} role`}
>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>No role</SelectItem>
{PRESET_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
</SelectContent>
</Select>
{member.roleSelection === CUSTOM_ROLE ? (
<Input
className="h-8 text-xs"
value={member.customRole}
aria-label={`Member ${index + 1} custom role`}
onChange={(event) => updateMemberCustomRole(member.id, event.target.value)}
placeholder="e.g. architect"
/>
) : null}
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => removeMember(member.id)}
{members.map((member, index) => {
const memberColorSet = getTeamColorSet(getMemberColor(index));
return (
<div
key={member.id}
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
style={{
borderLeftWidth: '3px',
borderLeftColor: memberColorSet.border,
}}
>
Remove
</Button>
</div>
))}
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
onChange={(event) => updateMemberName(member.id, event.target.value)}
placeholder="member-name"
style={
member.name.trim()
? {
color: memberColorSet.text,
}
: undefined
}
/>
<div className="space-y-1">
<Select
value={member.roleSelection || NO_ROLE}
onValueChange={(roleSelection) =>
updateMemberRole(member.id, roleSelection)
}
>
<SelectTrigger
className="h-8 text-xs"
aria-label={`Member ${index + 1} role`}
>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>No role</SelectItem>
{PRESET_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
</SelectContent>
</Select>
{member.roleSelection === CUSTOM_ROLE ? (
<Input
className="h-8 text-xs"
value={member.customRole}
aria-label={`Member ${index + 1} custom role`}
onChange={(event) =>
updateMemberCustomRole(member.id, event.target.value)
}
placeholder="e.g. architect"
/>
) : null}
</div>
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => removeMember(member.id)}
>
Remove
</Button>
</div>
);
})}
<Button
variant="outline"
size="sm"
@ -716,14 +744,18 @@ export const CreateTeamDialog = ({
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt for team lead (optional)
</Label>
<Textarea
<AutoResizeTextarea
id="team-prompt"
className="min-h-[40px] resize-none text-xs"
rows={3}
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onChange={(event) => promptDraft.setValue(event.target.value)}
placeholder="Instructions for the team lead during provisioning..."
/>
{promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
) : null}

View file

@ -128,6 +128,7 @@ export const EditTeamDialog = ({
/>
</div>
<div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
<label className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]">
Color (optional)
</label>

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -18,7 +19,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, SendMessageResult } from '@shared/types';
@ -47,7 +48,7 @@ export const SendMessageDialog = ({
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
const [member, setMember] = useState('');
const [text, setText] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
const [summary, setSummary] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
@ -55,7 +56,6 @@ export const SendMessageDialog = ({
// Reset form when dialog opens
if (open && !prevOpen) {
setMember(defaultRecipient ?? '');
setText('');
setSummary('');
setPrevResult(lastResult);
}
@ -64,20 +64,20 @@ export const SendMessageDialog = ({
}
// Auto-close on successful send (lastResult changed while dialog is open)
useEffect(() => {
if (open && lastResult && lastResult !== prevResult) {
setMember('');
setText('');
setSummary('');
onClose();
}
}, [open, lastResult, prevResult, onClose]);
if (open && lastResult && lastResult !== prevResult) {
setMember('');
textDraft.clearDraft();
setSummary('');
setPrevResult(lastResult);
onClose();
}
const canSend = member.trim().length > 0 && text.trim().length > 0 && !sending;
const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && !sending;
const handleSubmit = (): void => {
if (!canSend) return;
onSend(member.trim(), text.trim(), summary.trim() || undefined);
onSend(member.trim(), textDraft.value.trim(), summary.trim() || undefined);
textDraft.clearDraft();
};
const handleOpenChange = (nextOpen: boolean): void => {
@ -131,13 +131,17 @@ export const SendMessageDialog = ({
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<Textarea
<AutoResizeTextarea
id="smd-message"
placeholder="Write your message..."
value={text}
rows={4}
onChange={(e) => setText(e.target.value)}
value={textDraft.value}
minRows={4}
maxRows={12}
onChange={(e) => textDraft.setValue(e.target.value)}
/>
{textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}

View file

@ -132,6 +132,7 @@ export const KanbanFilterPopover = ({
{member.name}
</label>
))}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs italic text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedOwners.has(UNASSIGNED_OWNER)}

View file

@ -1,6 +1,7 @@
import { Badge } from '@renderer/components/ui/badge';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ListPlus, MessageSquare } from 'lucide-react';
import type { ResolvedTeamMember } from '@shared/types';
@ -8,12 +9,16 @@ interface MemberCardProps {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
onClick?: () => void;
onSendMessage?: () => void;
onAssignTask?: () => void;
}
export const MemberCard = ({
member,
isTeamAlive,
onClick,
onSendMessage,
onAssignTask,
}: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive);
const presenceLabel = getPresenceLabel(member, isTeamAlive);
@ -68,6 +73,30 @@ export const MemberCard = ({
>
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</Badge>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Send Message"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
title="Assign Task"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<ListPlus size={13} />
</button>
</div>
</div>
);
};

View file

@ -59,7 +59,7 @@ export const MemberDetailDialog = ({
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="sm:max-w-screen-sm">
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<MemberDetailHeader member={member} />
</DialogHeader>

View file

@ -6,12 +6,16 @@ interface MemberListProps {
members: ResolvedTeamMember[];
isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
onAssignTask?: (member: ResolvedTeamMember) => void;
}
export const MemberList = ({
members,
isTeamAlive,
onMemberClick,
onSendMessage,
onAssignTask,
}: MemberListProps): React.JSX.Element => {
if (members.length === 0) {
return (
@ -29,6 +33,8 @@ export const MemberList = ({
member={member}
isTeamAlive={isTeamAlive}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
))}
</div>

View file

@ -0,0 +1,80 @@
import * as React from 'react';
import { cn } from '@renderer/lib/utils';
interface AutoResizeTextareaProps extends React.ComponentProps<'textarea'> {
minRows?: number;
maxRows?: number;
}
const AutoResizeTextarea = React.forwardRef<HTMLTextAreaElement, AutoResizeTextareaProps>(
({ className, minRows = 2, maxRows = 12, onChange, ...props }, forwardedRef) => {
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
// eslint-disable-next-line no-param-reassign -- ref merging requires mutation
forwardedRef.current = node;
}
},
[forwardedRef]
);
const adjustHeight = React.useCallback(() => {
const textarea = internalRef.current;
if (!textarea) return;
const computedStyle = window.getComputedStyle(textarea);
const lineHeight = parseFloat(computedStyle.lineHeight) || 20;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
const borderTop = parseFloat(computedStyle.borderTopWidth) || 0;
const borderBottom = parseFloat(computedStyle.borderBottomWidth) || 0;
const minHeight =
minRows * lineHeight + paddingTop + paddingBottom + borderTop + borderBottom;
const maxHeight =
maxRows * lineHeight + paddingTop + paddingBottom + borderTop + borderBottom;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const clampedHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
textarea.style.height = `${clampedHeight}px`;
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
}, [minRows, maxRows]);
React.useEffect(() => {
adjustHeight();
}, [adjustHeight, props.value]);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e);
adjustHeight();
},
[onChange, adjustHeight]
);
return (
<textarea
className={cn(
'flex w-full rounded-md border border-[var(--color-border)] bg-transparent px-3 py-2 text-sm shadow-sm transition-[height] duration-100 ease-in-out placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={setRefs}
rows={minRows}
onChange={handleChange}
{...props}
/>
);
}
);
AutoResizeTextarea.displayName = 'AutoResizeTextarea';
export { AutoResizeTextarea };
export type { AutoResizeTextareaProps };

View file

@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { draftStorage } from '@renderer/services/draftStorage';
interface UseDraftPersistenceOptions {
key: string;
initialValue?: string;
enabled?: boolean;
debounceMs?: number;
}
interface UseDraftPersistenceResult {
value: string;
setValue: (v: string) => void;
isSaved: boolean;
clearDraft: () => void;
}
export function useDraftPersistence({
key,
initialValue,
enabled = true,
debounceMs = 500,
}: UseDraftPersistenceOptions): UseDraftPersistenceResult {
const [value, setValueState] = useState(initialValue ?? '');
const [isSaved, setIsSaved] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingValueRef = useRef<string | null>(null);
const keyRef = useRef(key);
keyRef.current = key;
// Load draft on mount
useEffect(() => {
if (!enabled) return;
let cancelled = false;
void (async () => {
const draft = await draftStorage.loadDraft(key);
if (cancelled) return;
if (draft != null && initialValue == null) {
setValueState(draft);
setIsSaved(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, [key, enabled]);
const flushPending = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingValueRef.current != null) {
const val = pendingValueRef.current;
pendingValueRef.current = null;
if (val.length === 0) {
void draftStorage.deleteDraft(keyRef.current);
} else {
void draftStorage.saveDraft(keyRef.current, val);
}
}
}, []);
// Flush on unmount
useEffect(() => {
return () => {
flushPending();
};
}, [flushPending]);
const setValue = useCallback(
(v: string) => {
setValueState(v);
setIsSaved(false);
if (!enabled) return;
pendingValueRef.current = v;
if (timerRef.current != null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
const pending = pendingValueRef.current;
pendingValueRef.current = null;
if (pending == null) return;
if (pending.length === 0) {
void draftStorage.deleteDraft(keyRef.current);
} else {
void draftStorage.saveDraft(keyRef.current, pending).then(() => {
setIsSaved(true);
});
}
}, debounceMs);
},
[enabled, debounceMs]
);
const clearDraft = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
pendingValueRef.current = null;
setValueState('');
setIsSaved(false);
if (enabled) {
void draftStorage.deleteDraft(keyRef.current);
}
}, [enabled]);
return { value, setValue, isSaved, clearDraft };
}

View file

@ -0,0 +1,80 @@
import { del, get, keys, set } from 'idb-keyval';
const DRAFT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const DRAFT_KEY_PREFIX = 'draft:';
interface StoredDraft {
value: string;
timestamp: number;
}
async function saveDraft(key: string, value: string): Promise<void> {
try {
const stored: StoredDraft = {
value,
timestamp: Date.now(),
};
await set(`${DRAFT_KEY_PREFIX}${key}`, stored);
} catch (error) {
console.error(`[draftStorage] Failed to save draft for ${key}:`, error);
}
}
async function loadDraft(key: string): Promise<string | null> {
try {
const stored = await get<StoredDraft>(`${DRAFT_KEY_PREFIX}${key}`);
if (!stored) {
return null;
}
const age = Date.now() - stored.timestamp;
if (age > DRAFT_TTL_MS) {
void deleteDraft(key);
return null;
}
return stored.value;
} catch (error) {
console.error(`[draftStorage] Failed to load draft for ${key}:`, error);
return null;
}
}
async function deleteDraft(key: string): Promise<void> {
try {
await del(`${DRAFT_KEY_PREFIX}${key}`);
} catch (error) {
console.error(`[draftStorage] Failed to delete draft for ${key}:`, error);
}
}
async function cleanupExpired(): Promise<void> {
try {
const allKeys = await keys();
const draftKeys = allKeys.filter(
(k): k is IDBValidKey & string => typeof k === 'string' && k.startsWith(DRAFT_KEY_PREFIX)
);
const now = Date.now();
for (const fullKey of draftKeys) {
try {
const stored = await get<StoredDraft>(fullKey);
if (stored && now - stored.timestamp > DRAFT_TTL_MS) {
await del(fullKey);
}
} catch (error) {
console.error(`[draftStorage] Failed to check/delete key ${fullKey}:`, error);
}
}
} catch (error) {
console.error('[draftStorage] Failed to cleanup expired drafts:', error);
}
}
export const draftStorage = {
saveDraft,
loadDraft,
deleteDraft,
cleanupExpired,
};

View file

@ -7,6 +7,7 @@
import { api } from '@renderer/api';
import { contextStorage } from '@renderer/services/contextStorage';
import { draftStorage } from '@renderer/services/draftStorage';
import { getFullResetState } from '../utils/stateResetHelpers';
@ -240,6 +241,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
if (available) {
// Clean up expired snapshots
void contextStorage.cleanupExpired();
void draftStorage.cleanupExpired();
}
// Fetch active context from main process

View file

@ -245,6 +245,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
}
} catch (error) {
// If provisioning is in progress for this team, stay in loading state
// instead of showing an error — the file watcher / progress callback will
// trigger a refresh once config.json is written.
const isProvisioning = Object.values(get().provisioningRuns).some(
(run) =>
run.teamName === teamName &&
!['ready', 'disconnected', 'failed', 'cancelled'].includes(run.state)
);
if (isProvisioning) {
set({
selectedTeamLoading: true,
selectedTeamData: null,
selectedTeamError: null,
});
return;
}
set({
selectedTeamLoading: false,
selectedTeamData: null,
@ -433,6 +451,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (progress.state === 'ready' || progress.state === 'disconnected') {
void get().fetchTeams();
// If the user already opened the team tab, reload team data now that
// config.json is guaranteed to exist.
if (get().selectedTeamName === progress.teamName) {
void get().selectTeam(progress.teamName);
}
}
},

View file

@ -3,6 +3,7 @@
*/
export * from './cache';
export * from './memberColors';
export * from './trafficLights';
export * from './triggerColors';
export * from './window';

View file

@ -0,0 +1,10 @@
/**
* Default color palette for team members.
* Used during team creation and for preview in the UI.
* Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length].
*/
export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const;
export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
}

View file

@ -24,12 +24,19 @@ export interface TeamUpdateConfigRequest {
color?: string;
}
export interface TeamSummaryMember {
name: string;
role?: string;
color?: string;
}
export interface TeamSummary {
teamName: string;
displayName: string;
description: string;
color?: string;
memberCount: number;
members?: TeamSummaryMember[];
taskCount: number;
lastActivity: string | null;
projectPath?: string;

View file

@ -4,8 +4,11 @@ const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
const dirs = new Map<string, string[]>();
// Normalize path separators so tests pass on Windows (backslash → forward slash)
const norm = (p: string): string => p.replace(/\\/g, '/');
const readdir = vi.fn(async (dirPath: string) => {
const entries = dirs.get(dirPath);
const entries = dirs.get(norm(dirPath));
if (!entries) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
@ -15,7 +18,7 @@ const hoisted = vi.hoisted(() => {
});
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';

View file

@ -5,8 +5,11 @@ const hoisted = vi.hoisted(() => {
let idCounter = 0;
let dropWrites = 0;
// Normalize path separators so tests pass on Windows (backslash → forward slash)
const norm = (p: string): string => p.replace(/\\/g, '/');
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
@ -18,10 +21,10 @@ const hoisted = vi.hoisted(() => {
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
if (dropWrites > 0) {
dropWrites -= 1;
files.set(filePath, '[]');
files.set(norm(filePath), '[]');
return;
}
files.set(filePath, data);
files.set(norm(filePath), data);
});
return {

View file

@ -2,8 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const files = new Map<string, string>();
// Normalize path separators so tests pass on Windows (backslash → forward slash)
const norm = (p: string): string => p.replace(/\\/g, '/');
const readFile = vi.fn(async (filePath: string) => {
const data = files.get(filePath);
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
@ -12,7 +16,7 @@ const hoisted = vi.hoisted(() => {
return data;
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
files.set(filePath, data);
files.set(norm(filePath), data);
});
return { files, readFile, atomicWrite };
});

View file

@ -5,13 +5,16 @@ const hoisted = vi.hoisted(() => {
let overrideVerifyRead: string | null = null;
let readCount = 0;
// Normalize path separators so tests pass on Windows (backslash → forward slash)
const norm = (p: string): string => p.replace(/\\/g, '/');
const readFile = vi.fn(async (filePath: string) => {
readCount += 1;
if (overrideVerifyRead && readCount >= 2) {
return overrideVerifyRead;
}
const data = files.get(filePath);
const data = files.get(norm(filePath));
if (data === undefined) {
const error = new Error('ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
@ -21,7 +24,7 @@ const hoisted = vi.hoisted(() => {
});
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
files.set(filePath, data);
files.set(norm(filePath), data);
});
return {

View file

@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock idb-keyval before importing draftStorage
const store = new Map<string, unknown>();
vi.mock('idb-keyval', () => ({
get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)),
set: vi.fn((key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve();
}),
del: vi.fn((key: string) => {
store.delete(key);
return Promise.resolve();
}),
keys: vi.fn(() => Promise.resolve([...store.keys()])),
}));
import { draftStorage } from '@renderer/services/draftStorage';
describe('draftStorage', () => {
beforeEach(() => {
store.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('saveDraft / loadDraft', () => {
it('should save and load a draft', async () => {
await draftStorage.saveDraft('test:field', 'hello world');
const result = await draftStorage.loadDraft('test:field');
expect(result).toBe('hello world');
});
it('should return null for non-existent draft', async () => {
const result = await draftStorage.loadDraft('nonexistent');
expect(result).toBeNull();
});
it('should overwrite existing draft', async () => {
await draftStorage.saveDraft('test:field', 'first');
await draftStorage.saveDraft('test:field', 'second');
const result = await draftStorage.loadDraft('test:field');
expect(result).toBe('second');
});
});
describe('deleteDraft', () => {
it('should delete a draft', async () => {
await draftStorage.saveDraft('test:field', 'to delete');
await draftStorage.deleteDraft('test:field');
const result = await draftStorage.loadDraft('test:field');
expect(result).toBeNull();
});
it('should not throw when deleting non-existent draft', async () => {
await expect(draftStorage.deleteDraft('nonexistent')).resolves.toBeUndefined();
});
});
describe('TTL expiry', () => {
it('should return null for expired drafts', async () => {
// Save a draft, then manually set old timestamp
await draftStorage.saveDraft('test:field', 'old data');
// Modify stored data to have old timestamp (>24h ago)
const key = 'draft:test:field';
const stored = store.get(key) as { value: string; timestamp: number };
store.set(key, { ...stored, timestamp: Date.now() - 25 * 60 * 60 * 1000 });
const result = await draftStorage.loadDraft('test:field');
expect(result).toBeNull();
});
it('should return value for non-expired drafts', async () => {
await draftStorage.saveDraft('test:field', 'fresh data');
// Modify timestamp to be 23h ago (within TTL)
const key = 'draft:test:field';
const stored = store.get(key) as { value: string; timestamp: number };
store.set(key, { ...stored, timestamp: Date.now() - 23 * 60 * 60 * 1000 });
const result = await draftStorage.loadDraft('test:field');
expect(result).toBe('fresh data');
});
});
describe('cleanupExpired', () => {
it('should remove expired drafts', async () => {
await draftStorage.saveDraft('test:a', 'value a');
await draftStorage.saveDraft('test:b', 'value b');
// Make 'a' expired
const keyA = 'draft:test:a';
const storedA = store.get(keyA) as { value: string; timestamp: number };
store.set(keyA, { ...storedA, timestamp: Date.now() - 25 * 60 * 60 * 1000 });
await draftStorage.cleanupExpired();
expect(await draftStorage.loadDraft('test:a')).toBeNull();
expect(await draftStorage.loadDraft('test:b')).toBe('value b');
});
it('should not affect non-draft keys', async () => {
store.set('other-key', { data: 'something' });
await draftStorage.saveDraft('test:field', 'draft value');
await draftStorage.cleanupExpired();
expect(store.has('other-key')).toBe(true);
expect(await draftStorage.loadDraft('test:field')).toBe('draft value');
});
});
});