feat: add agent language update handling and validation

- Introduced support for updating the agent language in the configuration, including a new callback in `initializeConfigHandlers`.
- Enhanced `handleUpdateConfig` to trigger the new language update callback when the agent language changes.
- Updated validation logic to ensure the agent language is a non-empty string.
- Modified the `TeamConfigReader` and `TeamProvisioningService` to handle the new language setting, ensuring teams are notified of language changes.
- Adjusted various components to accommodate the new task start and cancellation features, improving task management in the Kanban board.
This commit is contained in:
iliya 2026-02-24 17:11:54 +02:00
parent 97f09520f0
commit 27eacfa77c
26 changed files with 559 additions and 193 deletions

View file

@ -54,6 +54,7 @@ const execFileAsync = promisify(execFile);
const configManager = ConfigManager.getInstance();
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
null;
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
/**
* Initializes config handlers with callbacks that require app-level services.
@ -61,9 +62,11 @@ let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> |
export function initializeConfigHandlers(
options: {
onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise<void> | void;
onAgentLanguageUpdated?: (newLangCode: string) => Promise<void> | void;
} = {}
): void {
onClaudeRootPathUpdated = options.onClaudeRootPathUpdated ?? null;
onAgentLanguageUpdated = options.onAgentLanguageUpdated ?? null;
}
/**
@ -155,6 +158,13 @@ async function handleUpdateConfig(
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
// Capture previous language BEFORE applying the update so we can detect real changes
const prevAgentLanguage =
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'agentLanguage')
? configManager.getConfig().general.agentLanguage
: undefined;
configManager.updateConfig(validation.section, validation.data);
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
@ -167,6 +177,17 @@ async function handleUpdateConfig(
}
}
if (prevAgentLanguage !== undefined && onAgentLanguageUpdated) {
const newLangCode = (validation.data as { agentLanguage?: string }).agentLanguage;
if (newLangCode && newLangCode !== prevAgentLanguage) {
try {
await onAgentLanguageUpdated(newLangCode);
} catch (callbackError) {
logger.error('Failed to notify teams about language change:', callbackError);
}
}
}
const updatedConfig = configManager.getConfig();
return { success: true, data: updatedConfig };
} catch (error) {

View file

@ -203,6 +203,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
'theme',
'defaultTab',
'claudeRootPath',
'agentLanguage',
];
const result: Partial<GeneralConfig> = {};
@ -267,6 +268,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
result.claudeRootPath = path.resolve(normalized);
}
break;
case 'agentLanguage':
if (typeof value !== 'string' || value.trim().length === 0) {
return { valid: false, error: 'general.agentLanguage must be a non-empty string' };
}
result.agentLanguage = value.trim();
break;
default:
return { valid: false, error: `Unsupported general key: ${key}` };
}

View file

@ -107,6 +107,9 @@ export function initializeIpcHandlers(
);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
onAgentLanguageUpdated: (newLangCode) => {
void teamProvisioningService.notifyLanguageChange(newLangCode);
},
});
if (httpServerDeps) {
initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer);

View file

@ -1278,7 +1278,7 @@ async function handleStartTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<void>> {
): Promise<IpcResult<{ notifiedOwner: boolean }>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };

View file

@ -146,7 +146,7 @@ export class TeamConfigReader {
async updateConfig(
teamName: string,
updates: { name?: string; description?: string; color?: string }
updates: { name?: string; description?: string; color?: string; language?: string }
): Promise<TeamConfig | null> {
const config = await this.getConfig(teamName);
if (!config) {
@ -161,6 +161,9 @@ export class TeamConfigReader {
if (updates.color !== undefined) {
config.color = updates.color.trim() || undefined;
}
if (updates.language !== undefined) {
config.language = updates.language.trim() || undefined;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
return config;

View file

@ -424,7 +424,7 @@ export class TeamDataService {
return task;
}
async startTask(teamName: string, taskId: string): Promise<void> {
async startTask(teamName: string, taskId: string): Promise<{ notifiedOwner: boolean }> {
const tasks = await this.taskReader.getTasks(teamName);
const task = tasks.find((t) => t.id === taskId);
if (!task) {
@ -458,6 +458,8 @@ export class TeamDataService {
// Best-effort notification
}
}
return { notifiedOwner: !!task.owner };
}
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {

View file

@ -450,7 +450,7 @@ function updateProgress(
): TeamProvisioningProgress {
const assistantOutput =
run.provisioningOutputParts.length > 0
? run.provisioningOutputParts.join('')
? run.provisioningOutputParts.join('\n\n')
: run.progress.assistantOutput;
run.progress = {
...run.progress,
@ -493,7 +493,7 @@ function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | u
function emitLogsProgress(run: ProvisioningRun): void {
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
const assistantOutput =
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('') : undefined;
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined;
if (!logsTail && !assistantOutput) {
return;
@ -1447,6 +1447,56 @@ export class TeamProvisioningService {
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
}
private languageChangeInFlight: Promise<void> = Promise.resolve();
/**
* Notify alive teams when the agent language setting changes.
* Compares each team's stored `config.language` with the new code and sends
* a message to the team lead if they differ.
*
* Serialised: rapid language switches (e.g. ru en ru) are queued so that
* only the latest value is applied to each team.
*/
async notifyLanguageChange(newLangCode: string): Promise<void> {
this.languageChangeInFlight = this.languageChangeInFlight.then(() =>
this.doNotifyLanguageChange(newLangCode)
);
return this.languageChangeInFlight;
}
private async doNotifyLanguageChange(newLangCode: string): Promise<void> {
const aliveTeams = this.getAliveTeams();
if (aliveTeams.length === 0) return;
const newResolved = resolveLanguageName(newLangCode);
for (const teamName of aliveTeams) {
try {
const config = await this.configReader.getConfig(teamName);
if (!config) continue;
const oldCode = config.language || 'system';
if (oldCode === newLangCode) continue;
const oldResolved = resolveLanguageName(oldCode);
const message =
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
`so they also switch to ${newResolved}.`;
await this.sendMessageToTeam(teamName, message);
await this.configReader.updateConfig(teamName, { language: newLangCode });
logger.info(`[${teamName}] Notified about language change: ${oldCode}${newLangCode}`);
} catch (error) {
logger.warn(
`[${teamName}] Failed to notify language change: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
}
private async markInboxMessagesRead(
teamName: string,
member: string,
@ -2300,6 +2350,10 @@ export class TeamProvisioningService {
}
}
// Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
config.language = langCode;
// Ensure projectPath
if (projectPath.trim()) {
config.projectPath = projectPath;

View file

@ -581,7 +581,7 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
},
startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId);
},
processSend: async (teamName: string, message: string) => {
return invokeIpcWithResult<void>(TEAM_PROCESS_SEND, teamName, message);

View file

@ -689,7 +689,7 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
throw new Error('Team task owner update is not available in browser mode');
},
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => {
throw new Error('Team start task is not available in browser mode');
},
processSend: async (_teamName: string, _message: string): Promise<void> => {

View file

@ -10,6 +10,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react';
import { MarkdownViewer } from '../viewers/MarkdownViewer';
@ -85,6 +86,18 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
// Detect resent/duplicate messages
const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]);
const plainSummary = useMemo(
() => extractMarkdownPlainText(teammateMessage.summary),
[teammateMessage.summary]
);
const plainReplyToSummary = useMemo(
() =>
teammateMessage.replyToSummary
? extractMarkdownPlainText(teammateMessage.replyToSummary)
: undefined,
[teammateMessage.replyToSummary]
);
// Noise: minimal inline row (no card, no expand)
if (noiseLabel) {
return (
@ -100,11 +113,8 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
);
}
// Real message: full card with visual distinction
const truncatedSummary =
teammateMessage.summary.length > 80
? teammateMessage.summary.slice(0, 80) + '...'
: teammateMessage.summary;
plainSummary.length > 80 ? plainSummary.slice(0, 80) + '...' : plainSummary;
return (
<div
@ -160,7 +170,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
</span>
{/* Reply indicator — shows which SendMessage triggered this response */}
{teammateMessage.replyToSummary && (
{plainReplyToSummary && (
<span
role="presentation"
className="flex cursor-default items-center gap-1 text-[10px]"
@ -170,7 +180,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
>
<CornerDownLeft className="size-2.5" />
<span className="truncate" style={{ maxWidth: '180px' }}>
{teammateMessage.replyToSummary}
{plainReplyToSummary}
</span>
</span>
)}

View file

@ -314,7 +314,9 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && <CopyButton text={content} />}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (

View file

@ -56,15 +56,22 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
);
}
const isTransparent = bgColor === 'transparent';
return (
<div className="pointer-events-none absolute right-0 top-0 z-10 flex opacity-0 transition-opacity group-hover:opacity-100">
{/* Gradient fade from transparent to bgColor so text isn't obscured */}
<div
className="w-8 self-stretch"
style={{ background: `linear-gradient(to right, transparent, ${bgColor})` }}
/>
{!isTransparent && (
<div
className="w-8 self-stretch"
style={{ background: `linear-gradient(to right, transparent, ${bgColor})` }}
/>
)}
{/* Solid background holding the button */}
<div className="rounded-bl-lg p-1.5" style={{ backgroundColor: bgColor }}>
<div
className="rounded-bl-lg p-1.5"
style={isTransparent ? undefined : { backgroundColor: bgColor }}
>
<button
onClick={handleCopy}
className="pointer-events-auto rounded p-1.5"

View file

@ -12,6 +12,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import {
buildTaskCountsByProject,
normalizePath,
@ -106,43 +107,6 @@ interface RepositoryCardProps {
taskCounts?: TaskStatusCounts;
}
/**
* Truncate path to show ~/relative/path format
*/
function formatProjectPath(path: string): string {
const p = path.replace(/\\/g, '/');
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
const parts = p.split('/').filter(Boolean);
if (parts.length >= 2) {
const rest = parts.slice(2).join('/');
return rest ? `~/${rest}` : '~';
}
}
if (isWindowsUserPath(path)) {
const parts = p.split('/').filter(Boolean);
if (parts.length >= 3) {
const rest = parts.slice(3).join('/');
return rest ? `~/${rest}` : '~';
}
}
return p;
}
function isWindowsUserPath(input: string): boolean {
if (input.length < 10) {
return false;
}
const drive = input.charCodeAt(0);
const hasDriveLetter =
((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
return hasDriveLetter && input.startsWith('\\Users\\', 2);
}
const RepositoryCard = ({
repo,
onClick,
@ -318,7 +282,7 @@ const NewProjectCard = (): React.JSX.Element => {
// Still no match — create a synthetic group for this new folder and navigate to it.
// This allows launching teams in projects that don't have Claude sessions yet.
const encodedId = selectedPath.replace(/[/\\]/g, '-');
const folderName = selectedPath.split('/').filter(Boolean).pop() ?? selectedPath;
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
const now = Date.now();
const syntheticGroup: RepositoryGroup = {

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@ -11,6 +11,55 @@ import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
import type { ProvisioningStep } from './provisioningSteps';
// ---------------------------------------------------------------------------
// JSON syntax-highlighted CLI logs
// ---------------------------------------------------------------------------
const JSON_KEY_COLOR = 'var(--syntax-property, #7dd3fc)';
const JSON_STRING_COLOR = 'var(--syntax-string, #86efac)';
const JSON_NUMBER_COLOR = 'var(--syntax-number, #fde68a)';
const JSON_BOOL_NULL_COLOR = 'var(--syntax-keyword, #c4b5fd)';
const JSON_BRACKET_COLOR = 'var(--color-text-muted)';
function syntaxHighlightJson(json: string): string {
return (
json
.replace(/("(?:\\.|[^"\\])*")\s*:/g, `<span style="color:${JSON_KEY_COLOR}">$1</span>:`)
.replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) =>
match.replace(str, `<span style="color:${JSON_STRING_COLOR}">${str}</span>`)
)
// eslint-disable-next-line security/detect-unsafe-regex -- number format is bounded, input is our JSON
.replace(/:\s*(-?\d+(?:\.\d{1,20})?(?:[eE][+-]?\d{1,5})?)/g, (match, num: string) =>
match.replace(num, `<span style="color:${JSON_NUMBER_COLOR}">${num}</span>`)
)
.replace(/:\s*(true|false|null)/g, (match, kw: string) =>
match.replace(kw, `<span style="color:${JSON_BOOL_NULL_COLOR}">${kw}</span>`)
)
.replace(/([{}[\]])/g, `<span style="color:${JSON_BRACKET_COLOR}">$1</span>`)
);
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function prettyFormatLogs(raw: string): string {
return raw
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as unknown;
const pretty = escapeHtml(JSON.stringify(parsed, null, 2));
return syntaxHighlightJson(pretty);
} catch {
return escapeHtml(trimmed);
}
})
.join('\n');
}
export interface ProvisioningProgressBlockProps {
/** Title above the steps, e.g. "Launching team" */
title: string;
@ -78,6 +127,10 @@ export const ProvisioningProgressBlock = ({
const [logsOpen, setLogsOpen] = useState(false);
const logsRef = useRef<HTMLPreElement>(null);
const outputScrollRef = useRef<HTMLDivElement>(null);
const prettyLogs = useMemo(
() => (cliLogsTail ? prettyFormatLogs(cliLogsTail) : ''),
[cliLogsTail]
);
// Auto-scroll CLI logs
useEffect(() => {
@ -182,9 +235,9 @@ export const ProvisioningProgressBlock = ({
<pre
ref={logsRef}
className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
>
{cliLogsTail}
</pre>
// Content is HTML-escaped via escapeHtml() before syntax highlighting spans are added
dangerouslySetInnerHTML={{ __html: prettyLogs }}
/>
) : null}
</div>
) : null}

View file

@ -15,12 +15,15 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import {
Bell,
CheckCheck,
FolderOpen,
GitBranch,
History,
Pencil,
Play,
Plus,
@ -611,27 +614,75 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</Tooltip>
</div>
</div>
{(data.config.description || leadBranch) && (
<div
{data.config.description && (
<p
className={cn(
'flex items-center justify-between gap-2',
'min-w-0 truncate text-xs text-[var(--color-text-muted)]',
headerColorSet && 'relative z-10'
)}
>
<p className="min-w-0 truncate text-xs text-[var(--color-text-muted)]">
{data.config.description || ''}
</p>
{leadBranch ? (
{data.config.description}
</p>
)}
{(data.config.projectPath || leadBranch) && (
<div
className={cn(
'mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5',
headerColorSet && 'relative z-10'
)}
>
{data.config.projectPath && (
<span
className="flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-text-muted)]"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
title={data.config.projectPath}
>
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="max-w-60 truncate font-mono">
{formatProjectPath(data.config.projectPath)}
</span>
</span>
)}
{leadBranch && (
<span
className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"
title={leadBranch}
>
<GitBranch size={10} />
<GitBranch size={11} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="max-w-32 truncate">{leadBranch}</span>
</span>
) : null}
)}
{data.isAlive && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-400" />
Running
</span>
)}
{!data.isAlive && isTeamProvisioning && (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
Launching...
</span>
)}
</div>
)}
{(() => {
const currentPath = data.config.projectPath;
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
if (!history || history.length === 0) return null;
return (
<div
className={cn(
'mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]',
headerColorSet && 'relative z-10'
)}
>
<History size={10} className="shrink-0" />
<span className="truncate">
Previous: {history.map((p) => formatProjectPath(p)).join(', ')}
</span>
</div>
);
})()}
</div>
{!data.isAlive && !isTeamProvisioning ? (
@ -789,18 +840,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onStartTask={(taskId) => {
void (async () => {
try {
await startTask(teamName, taskId);
const result = await startTask(teamName, taskId);
if (data?.isAlive) {
const task = data.tasks.find((t) => t.id === taskId);
if (task?.owner) {
try {
try {
if (result.notifiedOwner && task?.owner) {
await api.teams.processSend(
teamName,
`Task #${taskId} "${task.subject}" has started. Please begin working on it.`
);
} catch {
// best-effort
} else if (!result.notifiedOwner) {
const desc = task?.description?.trim()
? `\nDescription: ${task.description.trim()}`
: '';
await api.teams.processSend(
teamName,
`Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.`
);
}
} catch {
// best-effort
}
}
} catch {
@ -811,6 +870,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed');
}}
onCancelTask={(taskId) => {
void (async () => {
try {
const task = data?.tasks.find((t) => t.id === taskId);
await updateTaskStatus(teamName, taskId, 'pending');
// Notify assignee directly via inbox — they'll see it immediately
if (task?.owner) {
try {
await api.teams.sendMessage(teamName, {
member: task.owner,
text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`,
summary: `Task #${taskId} cancelled`,
});
} catch {
// best-effort
}
}
// Also notify team lead so they can reassign/coordinate
if (data?.isAlive) {
try {
const ownerSuffix = task?.owner
? ` ${task.owner} has been notified to stop.`
: '';
await api.teams.processSend(
teamName,
`Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}`
);
} catch {
// best-effort
}
}
} catch {
// error via store
}
})();
}}
onColumnOrderChange={(columnId, orderedTaskIds) => {
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
}}

View file

@ -278,10 +278,15 @@ export const TeamListView = (): React.JSX.Element => {
const data = await api.teams.getData(teamName);
const existingNames = teams.map((t) => t.teamName);
const uniqueName = generateUniqueName(teamName, existingNames);
const members = (data.config.members ?? []).map((m) => ({
name: m.name,
role: m.role,
}));
const members = (data.members ?? [])
.filter((m) => !m.removedAt)
.map((m) => {
let role = m.role;
if (!role && m.agentType && m.agentType !== 'general-purpose') {
role = m.agentType === 'team-lead' ? 'lead' : m.agentType;
}
return { name: m.name, role };
});
setCopyData({
teamName: uniqueName,
description: data.config.description,

View file

@ -19,6 +19,7 @@ import {
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
@ -169,6 +170,10 @@ export const ActivityItem = ({
[displayText]
);
const rawSummary =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Noise messages: minimal inline row
if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
@ -185,7 +190,6 @@ export const ActivityItem = ({
onCreateTask?.(subject, description);
};
const summaryText = message.summary || autoSummary || '';
const isHeaderClickable = Boolean(systemLabel);
return (

View file

@ -108,7 +108,8 @@ export const CreateTaskDialog = ({
[members]
);
const canSubmit = subject.trim().length > 0 && !submitting;
const requiresOwner = defaultStartImmediately === true;
const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner);
// Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter((t) => t.status !== 'deleted');
@ -146,6 +147,43 @@ export const CreateTaskDialog = ({
}
};
const assigneeField = (
<div className="grid gap-2">
<Label>{requiresOwner ? 'Assignee' : 'Assignee (optional)'}</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder={requiresOwner ? 'Select a member' : 'Unassigned'} />
</SelectTrigger>
<SelectContent>
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? <span className="text-[var(--color-text-muted)]">({role})</span> : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[480px]">
@ -182,6 +220,8 @@ export const CreateTaskDialog = ({
/>
</div>
{assigneeField}
<div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label>
<MentionableTextarea
@ -218,43 +258,6 @@ export const CreateTaskDialog = ({
/>
</div>
<div className="grid gap-2">
<Label>Assignee (optional)</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? (
<span className="text-[var(--color-text-muted)]">({role})</span>
) : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{owner ? (
<div className="grid gap-1">
<div className="flex items-center gap-2">

View file

@ -23,6 +23,7 @@ import {
} from '@renderer/components/ui/select';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
@ -158,7 +159,9 @@ export const LaunchTeamDialog = ({
};
}, [open]);
// Fetch projects on open
const repositoryGroups = useStore((s) => s.repositoryGroups);
// Fetch projects on open, merging with repositoryGroups from store
useEffect(() => {
if (!open) {
return;
@ -170,11 +173,30 @@ export const LaunchTeamDialog = ({
let cancelled = false;
void (async () => {
try {
const nextProjects = await api.getProjects();
const apiProjects = await api.getProjects();
if (cancelled) {
return;
}
setProjects(nextProjects);
// Merge repositoryGroups (may include synthetic folders without sessions)
const pathSet = new Set(apiProjects.map((p) => p.path));
const extras: Project[] = [];
for (const repo of repositoryGroups) {
for (const wt of repo.worktrees) {
if (!pathSet.has(wt.path)) {
pathSet.add(wt.path);
extras.push({
id: wt.id,
path: wt.path,
name: wt.name,
sessions: [],
createdAt: wt.createdAt ?? Date.now(),
});
}
}
}
setProjects([...apiProjects, ...extras]);
} catch (error) {
if (cancelled) {
return;
@ -191,7 +213,7 @@ export const LaunchTeamDialog = ({
return () => {
cancelled = true;
};
}, [open]);
}, [open, repositoryGroups]);
// Pre-select defaultProjectPath when projects loaded
useEffect(() => {

View file

@ -73,6 +73,7 @@ interface KanbanBoardProps {
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
@ -147,6 +148,7 @@ interface SortableKanbanTaskCardProps {
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
}
@ -164,6 +166,7 @@ const SortableKanbanTaskCard = ({
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
}: SortableKanbanTaskCardProps): React.JSX.Element => {
@ -195,6 +198,7 @@ const SortableKanbanTaskCard = ({
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
@ -217,6 +221,7 @@ export const KanbanBoard = ({
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
onColumnOrderChange,
@ -280,52 +285,61 @@ export const KanbanBoard = ({
);
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
const addButton = addHandler ? (
<button
type="button"
onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<Plus size={13} />
Add task
</button>
) : null;
if (columnTasks.length === 0) {
const addHandler =
onAddTask && columnId === 'todo'
? () => onAddTask(false)
: onAddTask && columnId === 'in_progress'
? () => onAddTask(true)
: undefined;
return addHandler ? (
<button
type="button"
onClick={addHandler}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<Plus size={13} />
Add task
</button>
) : (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
return (
addButton ?? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
)
);
}
if (onColumnOrderChange) {
const itemIds = columnTasks.map((t) => t.id);
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => (
<SortableKanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
))}
</SortableContext>
<>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => (
<SortableKanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
))}
</SortableContext>
{addButton}
</>
);
}
return (
@ -346,10 +360,12 @@ export const KanbanBoard = ({
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
))}
{addButton}
</>
);
};

View file

@ -1,9 +1,12 @@
import { useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play, XCircle } from 'lucide-react';
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
@ -21,6 +24,7 @@ interface KanbanTaskCardProps {
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onCancelTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
}
@ -58,6 +62,59 @@ const DependencyBadge = ({
);
};
const CancelTaskButton = ({
taskId,
onConfirm,
}: {
taskId: string;
onConfirm: (taskId: string) => void;
}): React.JSX.Element => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="destructive"
size="sm"
className="gap-1"
aria-label={`Cancel task ${taskId}`}
onClick={(e) => e.stopPropagation()}
>
<XCircle size={12} />
Cancel
</Button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-3"
side="top"
align="start"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-3 text-xs text-[var(--color-text-secondary)]">
Move this task back to TODO and notify the team?
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
className="flex-1"
onClick={() => {
setOpen(false);
onConfirm(taskId);
}}
>
Confirm
</Button>
<Button variant="outline" size="sm" className="flex-1" onClick={() => setOpen(false)}>
Keep
</Button>
</div>
</PopoverContent>
</Popover>
);
};
export const KanbanTaskCard = ({
task,
teamName,
@ -72,6 +129,7 @@ export const KanbanTaskCard = ({
onMoveBackToDone,
onStartTask,
onCompleteTask,
onCancelTask,
onScrollToTask,
onTaskClick,
}: KanbanTaskCardProps): React.JSX.Element => {
@ -148,7 +206,7 @@ export const KanbanTaskCard = ({
</div>
) : null}
<div className="flex items-center gap-2">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-wrap gap-2">
{columnId === 'todo' ? (
<>
@ -182,19 +240,22 @@ export const KanbanTaskCard = ({
) : null}
{columnId === 'in_progress' ? (
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
<>
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
<CancelTaskButton taskId={task.id} onConfirm={onCancelTask} />
</>
) : null}
{columnId === 'done' ? (

View file

@ -90,8 +90,7 @@ export const Combobox = ({
</div>
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain py-1 pl-0 pr-2"
style={{ paddingLeft: 0 }}
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
onWheel={(e) => e.stopPropagation()}
>
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
@ -105,8 +104,7 @@ export const Combobox = ({
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
style={{ paddingLeft: 0 }}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
<X className="mr-2 size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="text-[var(--color-text-muted)]">
@ -135,19 +133,13 @@ export const Combobox = ({
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
style={{ paddingLeft: 0 }}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
{renderOption ? (
renderOption(option, isSelected, search)
) : (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
{isSelected ? <Check className="mr-2 size-3.5 shrink-0" /> : null}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{option.label}

View file

@ -81,7 +81,7 @@ export interface TeamSlice {
orderedTaskIds: string[]
) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
startTask: (teamName: string, taskId: string) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
addingComment: boolean;
@ -222,8 +222,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
selectTeam: async (teamName: string) => {
// Clear stale data immediately to prevent flash of previous team's content
const prev = get().selectedTeamName;
set({
selectedTeamName: teamName,
selectedTeamData: prev !== teamName ? null : get().selectedTeamData,
selectedTeamLoading: true,
selectedTeamError: null,
reviewActionError: null,
@ -410,8 +413,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
},
startTask: async (teamName: string, taskId: string) => {
await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId));
await get().refreshTeamData(teamName);
return result;
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {

View file

@ -74,6 +74,40 @@ function inferHomeDir(projectRoot: string): string | null {
* - `src/foo/bar` `{projectRoot}/src/foo/bar`
* - Already absolute returned as-is
*/
/**
* Truncate a project path to ~/relative/path format.
* Works for macOS (/Users/...), Linux (/home/...) and Windows (C:\Users\...).
*/
export function formatProjectPath(path: string): string {
const p = path.replace(/\\/g, '/');
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
const parts = p.split('/').filter(Boolean);
if (parts.length >= 2) {
const rest = parts.slice(2).join('/');
return rest ? `~/${rest}` : '~';
}
}
if (isWindowsUserPath(path)) {
const parts = p.split('/').filter(Boolean);
if (parts.length >= 3) {
const rest = parts.slice(3).join('/');
return rest ? `~/${rest}` : '~';
}
}
return p;
}
function isWindowsUserPath(input: string): boolean {
if (input.length < 10) return false;
const drive = input.charCodeAt(0);
const hasDriveLetter =
((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':';
return hasDriveLetter && input.startsWith('\\Users\\', 2);
}
export function resolveAbsolutePath(filePath: string, projectRoot?: string): string {
let p = filePath;

View file

@ -364,7 +364,7 @@ export interface TeamsAPI {
) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>;
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;
aliveList: () => Promise<string[]>;

View file

@ -13,6 +13,7 @@ export interface TeamConfig {
name: string;
description?: string;
color?: string;
language?: string;
members?: TeamMember[];
projectPath?: string;
projectPathHistory?: string[];
@ -24,6 +25,7 @@ export interface TeamUpdateConfigRequest {
name?: string;
description?: string;
color?: string;
language?: string;
}
export interface TeamSummaryMember {