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:
parent
33b41ef5d5
commit
70a3d8e34a
26 changed files with 559 additions and 193 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ export function initializeIpcHandlers(
|
|||
);
|
||||
initializeConfigHandlers({
|
||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||
onAgentLanguageUpdated: (newLangCode) => {
|
||||
void teamProvisioningService.notifyLanguageChange(newLangCode);
|
||||
},
|
||||
});
|
||||
if (httpServerDeps) {
|
||||
initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer);
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue