From 27eacfa77c310b4d6da4fa53860efc4854dd3d4a Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 24 Feb 2026 17:11:54 +0200 Subject: [PATCH] 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. --- src/main/ipc/config.ts | 21 +++ src/main/ipc/configValidation.ts | 7 + src/main/ipc/handlers.ts | 3 + src/main/ipc/teams.ts | 2 +- src/main/services/team/TeamConfigReader.ts | 5 +- src/main/services/team/TeamDataService.ts | 4 +- .../services/team/TeamProvisioningService.ts | 58 +++++++- src/preload/index.ts | 2 +- src/renderer/api/httpClient.ts | 2 +- .../chat/items/TeammateMessageItem.tsx | 22 ++- .../chat/viewers/MarkdownViewer.tsx | 4 +- src/renderer/components/common/CopyButton.tsx | 17 ++- .../components/dashboard/DashboardView.tsx | 40 +----- .../team/ProvisioningProgressBlock.tsx | 61 ++++++++- .../components/team/TeamDetailView.tsx | 127 +++++++++++++++--- src/renderer/components/team/TeamListView.tsx | 13 +- .../components/team/activity/ActivityItem.tsx | 6 +- .../team/dialogs/CreateTaskDialog.tsx | 79 +++++------ .../team/dialogs/LaunchTeamDialog.tsx | 30 ++++- .../components/team/kanban/KanbanBoard.tsx | 96 +++++++------ .../components/team/kanban/KanbanTaskCard.tsx | 91 ++++++++++--- src/renderer/components/ui/combobox.tsx | 16 +-- src/renderer/store/slices/teamSlice.ts | 8 +- src/renderer/utils/pathDisplay.ts | 34 +++++ src/shared/types/api.ts | 2 +- src/shared/types/team.ts | 2 + 26 files changed, 559 insertions(+), 193 deletions(-) diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index fa57d46d..0655fe3c 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -54,6 +54,7 @@ const execFileAsync = promisify(execFile); const configManager = ConfigManager.getInstance(); let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise | void) | null = null; +let onAgentLanguageUpdated: ((newLangCode: string) => Promise | void) | null = null; /** * Initializes config handlers with callbacks that require app-level services. @@ -61,9 +62,11 @@ let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise | export function initializeConfigHandlers( options: { onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise | void; + onAgentLanguageUpdated?: (newLangCode: string) => Promise | 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) { diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 469d7c33..2e99c45c 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -203,6 +203,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V 'theme', 'defaultTab', 'claudeRootPath', + 'agentLanguage', ]; const result: Partial = {}; @@ -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}` }; } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6e1c7e36..ba372729 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -107,6 +107,9 @@ export function initializeIpcHandlers( ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, + onAgentLanguageUpdated: (newLangCode) => { + void teamProvisioningService.notifyLanguageChange(newLangCode); + }, }); if (httpServerDeps) { initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b8b8d754..337a664d 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1278,7 +1278,7 @@ async function handleStartTask( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown -): Promise> { +): Promise> { const validatedTeamName = validateTeamName(teamName); if (!validatedTeamName.valid) { return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 0e80f48e..c503646b 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -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 { 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; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b2fa167e..2ee107cd 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -424,7 +424,7 @@ export class TeamDataService { return task; } - async startTask(teamName: string, taskId: string): Promise { + 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 { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8ef60ed3..9a0b742e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 = 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 { + this.languageChangeInFlight = this.languageChangeInFlight.then(() => + this.doNotifyLanguageChange(newLangCode) + ); + return this.languageChangeInFlight; + } + + private async doNotifyLanguageChange(newLangCode: string): Promise { + 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; diff --git a/src/preload/index.ts b/src/preload/index.ts index 40a88b30..3d3d3f03 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -581,7 +581,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner); }, startTask: async (teamName: string, taskId: string) => { - return invokeIpcWithResult(TEAM_START_TASK, teamName, taskId); + return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); }, processSend: async (teamName: string, message: string) => { return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 69f324a9..86ab617f 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -689,7 +689,7 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Team task owner update is not available in browser mode'); }, - startTask: async (_teamName: string, _taskId: string): Promise => { + 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 => { diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 905afd71..0f3b3b14 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -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 = ({ // 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 = ({ ); } - // 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 (
= ({ {/* Reply indicator — shows which SendMessage triggered this response */} - {teammateMessage.replyToSummary && ( + {plainReplyToSummary && ( = ({ > - {teammateMessage.replyToSummary} + {plainReplyToSummary} )} diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 3f1abe23..e102f1f9 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -314,7 +314,9 @@ export const MarkdownViewer: React.FC = ({ } > {/* Copy button overlay (when no label header) */} - {copyable && !label && } + {copyable && !label && ( + + )} {/* Optional header - matches CodeBlockViewer style */} {label && ( diff --git a/src/renderer/components/common/CopyButton.tsx b/src/renderer/components/common/CopyButton.tsx index 63b6917d..005a95ca 100644 --- a/src/renderer/components/common/CopyButton.tsx +++ b/src/renderer/components/common/CopyButton.tsx @@ -56,15 +56,22 @@ export const CopyButton: React.FC = ({ ); } + const isTransparent = bgColor === 'transparent'; + return (
{/* Gradient fade from transparent to bgColor so text isn't obscured */} -
+ {!isTransparent && ( +
+ )} {/* Solid background holding the button */} -
+
) : null} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fc1f7b05..28311c47 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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
- {(data.config.description || leadBranch) && ( -
-

- {data.config.description || ''} -

- {leadBranch ? ( + {data.config.description} +

+ )} + {(data.config.projectPath || leadBranch) && ( +
+ {data.config.projectPath && ( + + + {formatProjectPath(data.config.projectPath)} + + + )} + {leadBranch && ( + - + {leadBranch} - ) : null} + )} + {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )}
)} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()}
{!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); }} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 066b0487..a2074e60 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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, diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 55d25e61..7d8b04aa 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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 ; @@ -185,7 +190,6 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const summaryText = message.summary || autoSummary || ''; const isHeaderClickable = Boolean(systemLabel); return ( diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 6c6f35c0..56a36f28 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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 = ( +
+ + +
+ ); + return ( @@ -182,6 +220,8 @@ export const CreateTaskDialog = ({ />
+ {assigneeField} +
-
- - -
- {owner ? (
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 5f9d4198..3eeb7ba4 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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(() => { diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 15c8f59a..1cf6f2d3 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -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 ? ( + + ) : null; + if (columnTasks.length === 0) { - const addHandler = - onAddTask && columnId === 'todo' - ? () => onAddTask(false) - : onAddTask && columnId === 'in_progress' - ? () => onAddTask(true) - : undefined; - return addHandler ? ( - - ) : ( -
- No tasks -
+ return ( + addButton ?? ( +
+ No tasks +
+ ) ); } if (onColumnOrderChange) { const itemIds = columnTasks.map((t) => t.id); return ( - - {columnTasks.map((task) => ( - - ))} - + <> + + {columnTasks.map((task) => ( + + ))} + + {addButton} + ); } return ( @@ -346,10 +360,12 @@ export const KanbanBoard = ({ onMoveBackToDone={onMoveBackToDone} onStartTask={onStartTask} onCompleteTask={onCompleteTask} + onCancelTask={onCancelTask} onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} /> ))} + {addButton} ); }; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 6df814f8..4e953a8b 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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 ( + + + + + e.stopPropagation()} + > +

+ Move this task back to TODO and notify the team? +

+
+ + +
+
+
+ ); +}; + 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 = ({
) : null} -
+
{columnId === 'todo' ? ( <> @@ -182,19 +240,22 @@ export const KanbanTaskCard = ({ ) : null} {columnId === 'in_progress' ? ( - + <> + + + ) : null} {columnId === 'done' ? ( diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index b7abea58..616cae52 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -90,8 +90,7 @@ export const Combobox = ({
e.stopPropagation()} > @@ -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)]" > @@ -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) ) : ( <> - + {isSelected ? : null}

{option.label} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 0e32fcba..920ec507 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -81,7 +81,7 @@ export interface TeamSlice { orderedTaskIds: string[] ) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; - startTask: (teamName: string, taskId: string) => Promise; + startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; addingComment: boolean; @@ -222,8 +222,11 @@ export const createTeamSlice: StateCreator = (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 = (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) => { diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts index 7adf60e3..51e3001f 100644 --- a/src/renderer/utils/pathDisplay.ts +++ b/src/renderer/utils/pathDisplay.ts @@ -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; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 56873df9..5e617c9a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -364,7 +364,7 @@ export interface TeamsAPI { ) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; - startTask: (teamName: string, taskId: string) => Promise; + startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; aliveList: () => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e0905c82..24200b37 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 {