feat: enhance team configuration and member detection

- Added validation for team configuration updates to ensure name, description, and color are strings.
- Improved member detection logic to avoid false positives by ensuring only one known member name is matched.
- Refactored post-launch configuration updates to combine session history and project path updates, preventing race conditions.
- Updated UI components to streamline state management for collapsible sections.

These changes aim to improve data integrity and user experience in team management functionalities.
This commit is contained in:
iliya 2026-02-22 18:41:08 +02:00 committed by Илия
parent bcda8b62cc
commit 704b9cbfe5
7 changed files with 77 additions and 100 deletions

View file

@ -202,6 +202,15 @@ async function handleUpdateConfig(
return { success: false, error: 'Invalid updates object' }; return { success: false, error: 'Invalid updates object' };
} }
const { name, description, color } = updates as TeamUpdateConfigRequest; const { name, description, color } = updates as TeamUpdateConfigRequest;
if (name !== undefined && typeof name !== 'string') {
return { success: false, error: 'name must be a string' };
}
if (description !== undefined && typeof description !== 'string') {
return { success: false, error: 'description must be a string' };
}
if (color !== undefined && typeof color !== 'string') {
return { success: false, error: 'color must be a string' };
}
return wrapTeamHandler('updateConfig', async () => { return wrapTeamHandler('updateConfig', async () => {
const result = await getTeamDataService().updateConfig(validated.value!, { const result = await getTeamDataService().updateConfig(validated.value!, {
name, name,

View file

@ -55,7 +55,8 @@ export class ClaudeBinaryResolver {
const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude'; const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude';
const fromPath = await resolveFromPathEnv(platformBinaryName); const fromPath = await resolveFromPathEnv(platformBinaryName);
if (fromPath) { if (fromPath) {
return fromPath; cachedPath = fromPath;
return cachedPath;
} }
const candidates: string[] = [ const candidates: string[] = [

View file

@ -427,52 +427,30 @@ export class TeamMemberLogsFinder {
} }
/** /**
* Detects the member name from a parsed JSONL message using multiple signals. * Last-resort member detection from message text.
* Returns a detection result with the name and a priority level: * Only called when all structured signals (teammate_id, process.team, routing) failed.
* 3 = routing sender (highest, handled outside this method) * Returns priority 1 (lowest) only if exactly one known member name appears.
* 2 = "You are {name}" spawn prompt
* 1 = text-based fallback (single member match or task assignment context)
*/ */
private detectMemberFromMessage( private detectMemberFromMessage(
msg: Record<string, unknown>, msg: Record<string, unknown>,
knownMembers: Set<string> knownMembers: Set<string>
): { name: string; priority: number } | null { ): { name: string; priority: number } | null {
if (this.extractRole(msg) !== 'user') return null;
const text = this.extractTextContent(msg); const text = this.extractTextContent(msg);
if (!text) return null; if (!text) return null;
// Signal 1 (priority 2): "You are {name}, a {role}" pattern (spawn prompt) // Only attribute if exactly one known member name appears (word-boundary match).
const youAreMatch = /\bYou are (\w[\w-]*),\s+a\s+/i.exec(text); // Avoids false positives when multiple members are mentioned.
if (youAreMatch) { const matches: string[] = [];
const name = youAreMatch[1].toLowerCase(); for (const name of knownMembers) {
if (knownMembers.has(name)) { const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i');
return { name: youAreMatch[1], priority: 2 }; if (regex.test(text)) {
matches.push(name);
} }
} }
if (matches.length === 1) {
// Signal 2 (priority 1): Task assignment — look for member name in the task content return { name: findOriginalCase(text, matches[0]), priority: 1 };
if (text.includes('New task assigned to you') || text.includes('task assigned')) {
for (const name of knownMembers) {
const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i');
if (regex.test(text)) {
return { name: findOriginalCase(text, name), priority: 1 };
}
}
}
// Signal 3 (priority 1): General fallback — check if exactly one known member
// name appears in the first user message content (word-boundary match)
if (msg.role === 'user') {
const matches: string[] = [];
for (const name of knownMembers) {
const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i');
if (regex.test(text)) {
matches.push(name);
}
}
// Only attribute if exactly one member matches (avoid ambiguity)
if (matches.length === 1) {
return { name: findOriginalCase(text, matches[0]), priority: 1 };
}
} }
return null; return null;

View file

@ -1091,8 +1091,7 @@ export class TeamProvisioningService {
this.stopFilesystemMonitor(run); this.stopFilesystemMonitor(run);
if (run.isLaunch) { if (run.isLaunch) {
await this.ensureProjectPathInConfig(run.teamName, run.request.cwd); await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
await this.appendSessionToHistory(run.teamName);
const readyMessage = 'Team launched — process alive and ready'; const readyMessage = 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, { const progress = updateProgress(run, 'ready', readyMessage, {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
@ -1129,8 +1128,7 @@ export class TeamProvisioningService {
// Persist teammates metadata separately from config.json. // Persist teammates metadata separately from config.json.
await this.persistMembersMeta(run.teamName, run.request); await this.persistMembersMeta(run.teamName, run.request);
await this.ensureProjectPathInConfig(run.teamName, run.request.cwd); await this.updateConfigPostLaunch(run.teamName, run.request.cwd);
await this.appendSessionToHistory(run.teamName);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
@ -1669,60 +1667,44 @@ export class TeamProvisioningService {
} }
/** /**
* Append current leadSessionId to sessionHistory array in config.json. * Single atomic read-mutate-write for post-launch config updates.
* Called after launch/create to track which sessions belong to this team. * Combines session history append and projectPath update to avoid
* race conditions with the CLI writing to the same file.
*/ */
private async appendSessionToHistory(teamName: string): Promise<void> { private async updateConfigPostLaunch(teamName: string, projectPath: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try { try {
const raw = await fs.promises.readFile(configPath, 'utf8'); const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>; const config = JSON.parse(raw) as Record<string, unknown>;
// Append session to history
const leadSessionId = config.leadSessionId; const leadSessionId = config.leadSessionId;
if (typeof leadSessionId !== 'string' || leadSessionId.trim().length === 0) { if (typeof leadSessionId === 'string' && leadSessionId.trim().length > 0) {
return; const sessionHistory = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[])
: [];
if (!sessionHistory.includes(leadSessionId)) {
sessionHistory.push(leadSessionId);
config.sessionHistory = sessionHistory;
}
} }
const history = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[]) // Ensure projectPath
: []; if (projectPath.trim()) {
if (history.includes(leadSessionId)) { config.projectPath = projectPath;
return; const pathHistory = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter(
(p) => typeof p === 'string' && p !== projectPath
)
: [];
pathHistory.push(projectPath);
config.projectPathHistory = pathHistory;
} }
history.push(leadSessionId);
config.sessionHistory = history; await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
logger.info(`[${teamName}] Appended session ${leadSessionId} to sessionHistory`);
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`[${teamName}] Failed to append session to history: ${error instanceof Error ? error.message : String(error)}` `[${teamName}] Failed to update config post-launch: ${
);
}
}
private async ensureProjectPathInConfig(teamName: string, projectPath: string): Promise<void> {
if (!projectPath.trim()) {
return;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
// Always update projectPath to current cwd
config.projectPath = projectPath;
// Maintain ordered history (no duplicates, most recent last)
const history = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter(
(p) => typeof p === 'string' && p !== projectPath
)
: [];
history.push(projectPath);
config.projectPathHistory = history;
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
} catch (error) {
logger.warn(
`[${teamName}] Failed to ensure projectPath in config.json: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}` }`
); );

View file

@ -21,10 +21,7 @@ export const CollapsibleTeamSection = ({
children, children,
}: CollapsibleTeamSectionProps): React.JSX.Element => { }: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const isOpen = forceOpen ? true : open;
if (forceOpen && !open) {
setOpen(true);
}
return ( return (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0"> <section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
@ -36,7 +33,7 @@ export const CollapsibleTeamSection = ({
> >
<ChevronRight <ChevronRight
size={14} size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${open ? 'rotate-90' : ''}`} className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/> />
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span> <span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
{badge != null && ( {badge != null && (
@ -50,7 +47,7 @@ export const CollapsibleTeamSection = ({
</button> </button>
{action && <div className="shrink-0">{action}</div>} {action && <div className="shrink-0">{action}</div>}
</div> </div>
{open && <div className="mt-2">{children}</div>} {isOpen && <div className="mt-2">{children}</div>}
</section> </section>
); );
}; };

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
@ -65,15 +65,24 @@ export const SendMessageDialog = ({
setPrevOpen(open); setPrevOpen(open);
} }
// Auto-close on successful send (lastResult changed while dialog is open) // Track whether auto-close is needed (setState in render phase is fine)
const [pendingAutoClose, setPendingAutoClose] = useState(false);
if (open && lastResult && lastResult !== prevResult) { if (open && lastResult && lastResult !== prevResult) {
setMember('');
textDraft.clearDraft();
setSummary('');
setPrevResult(lastResult); setPrevResult(lastResult);
onClose(); setPendingAutoClose(true);
} }
// Side effects (onClose mutates parent state) must run in useEffect, not render phase
useEffect(() => {
if (pendingAutoClose) {
setMember('');
textDraft.clearDraft();
setSummary('');
setPendingAutoClose(false);
onClose();
}
}, [pendingAutoClose]); // eslint-disable-line react-hooks/exhaustive-deps
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
() => () =>
members.map((m) => ({ members.map((m) => ({

View file

@ -1,3 +1,4 @@
import * as os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@preload/constants/ipcChannels', () => ({ vi.mock('@preload/constants/ipcChannels', () => ({
@ -148,7 +149,7 @@ describe('ipc teams handlers', () => {
const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, { const createResult = (await handlers.get(TEAM_CREATE)!({ sender: { send: vi.fn() } } as never, {
teamName: 'my-team', teamName: 'my-team',
members: [{ name: 'alice' }], members: [{ name: 'alice' }],
cwd: '/', cwd: os.tmpdir(),
})) as { success: boolean }; })) as { success: boolean };
expect(createResult.success).toBe(true); expect(createResult.success).toBe(true);
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1); expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
@ -247,7 +248,7 @@ describe('ipc teams handlers', () => {
const result = (await handler({ sender: { send: vi.fn() } } as never, { const result = (await handler({ sender: { send: vi.fn() } } as never, {
teamName: 'test-team', teamName: 'test-team',
members: [{ name: 'alice' }], members: [{ name: 'alice' }],
cwd: '/', cwd: os.tmpdir(),
prompt: 'Build a web app', prompt: 'Build a web app',
})) as { success: boolean }; })) as { success: boolean };
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -260,7 +261,7 @@ describe('ipc teams handlers', () => {
const result = (await handler({ sender: { send: vi.fn() } } as never, { const result = (await handler({ sender: { send: vi.fn() } } as never, {
teamName: 'test-team', teamName: 'test-team',
members: [{ name: 'alice' }], members: [{ name: 'alice' }],
cwd: '/', cwd: os.tmpdir(),
prompt: 123, prompt: 123,
})) as { success: boolean; error: string }; })) as { success: boolean; error: string };
expect(result.success).toBe(false); expect(result.success).toBe(false);