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:
parent
bcda8b62cc
commit
704b9cbfe5
7 changed files with 77 additions and 100 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue