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:
parent
9b64576377
commit
0867966d17
28 changed files with 741 additions and 146 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
80
src/renderer/components/ui/auto-resize-textarea.tsx
Normal file
80
src/renderer/components/ui/auto-resize-textarea.tsx
Normal 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 };
|
||||
120
src/renderer/hooks/useDraftPersistence.ts
Normal file
120
src/renderer/hooks/useDraftPersistence.ts
Normal 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 };
|
||||
}
|
||||
80
src/renderer/services/draftStorage.ts
Normal file
80
src/renderer/services/draftStorage.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
export * from './cache';
|
||||
export * from './memberColors';
|
||||
export * from './trafficLights';
|
||||
export * from './triggerColors';
|
||||
export * from './window';
|
||||
|
|
|
|||
10
src/shared/constants/memberColors.ts
Normal file
10
src/shared/constants/memberColors.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
116
test/renderer/hooks/useDraftPersistence.test.ts
Normal file
116
test/renderer/hooks/useDraftPersistence.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue