feat: enhance task change handling and improve plugin catalog integration

- Added a 'source' field to PluginCatalogItem to distinguish between official and third-party plugins.
- Refactored ChangeExtractorService to improve caching mechanisms and normalize file paths for better consistency.
- Updated TaskBoundaryParser to support task IDs with underscores, enhancing task identification.
- Enhanced TeamMcpConfigBuilder to merge user-defined MCP configurations with generated ones, improving configuration management.
- Improved UI components to display source information for plugins and MCP servers, enhancing user experience and clarity.
This commit is contained in:
iliya 2026-03-11 18:16:40 +02:00
parent 2317c948ff
commit 1ccc1432fc
30 changed files with 1163 additions and 182 deletions

View file

@ -289,6 +289,7 @@ export class PluginCatalogService {
marketplaceId: qualifiedName,
qualifiedName,
name: raw.name,
source: 'official',
description: raw.description ?? '',
category: raw.category ?? 'other',
author: raw.author,

View file

@ -132,19 +132,25 @@ export class ChangeExtractorService {
}
): Promise<TaskChangeSetV2> {
const includeDetails = options?.summaryOnly !== true;
const cacheKey = `task:${teamName}:${taskId}`;
const taskMeta = await this.readTaskMeta(teamName, taskId);
const effectiveOptions = {
owner: options?.owner ?? taskMeta?.owner,
status: options?.status ?? taskMeta?.status,
intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since,
};
const cacheKey = this.buildTaskChangeCacheKey(
teamName,
taskId,
effectiveOptions,
includeDetails
);
const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined;
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const taskMeta = await this.readTaskMeta(teamName, taskId);
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
owner: options?.owner ?? taskMeta?.owner,
status: options?.status ?? taskMeta?.status,
intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since,
});
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
const empty = this.emptyTaskChangeSet(teamName, taskId);
@ -171,7 +177,7 @@ export class ChangeExtractorService {
// Если scope не найден — try deterministic interval scoping, else fallback to whole file
if (allScopes.length === 0) {
const intervals = options?.intervals ?? taskMeta?.intervals;
const intervals = effectiveOptions.intervals;
if (Array.isArray(intervals) && intervals.length > 0) {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
@ -345,7 +351,8 @@ export class ChangeExtractorService {
status: typeof parsed.status === 'string' ? parsed.status : undefined,
intervals: derivedIntervals,
};
} catch {
} catch (error) {
logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`);
return null;
}
}
@ -532,7 +539,7 @@ export class ChangeExtractorService {
const replaceAll = input.replace_all === true;
if (targetPath) {
seenFiles.add(targetPath);
seenFiles.add(this.normalizeFilePathKey(targetPath));
snippets.push({
toolUseId,
filePath: targetPath,
@ -551,8 +558,9 @@ export class ChangeExtractorService {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (targetPath) {
const isNew = !seenFiles.has(targetPath);
seenFiles.add(targetPath);
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
const isNew = !seenFiles.has(normalizedTargetPath);
seenFiles.add(normalizedTargetPath);
snippets.push({
toolUseId,
filePath: targetPath,
@ -571,7 +579,7 @@ export class ChangeExtractorService {
const edits = Array.isArray(input.edits) ? input.edits : [];
if (targetPath) {
seenFiles.add(targetPath);
seenFiles.add(this.normalizeFilePathKey(targetPath));
for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>;
@ -668,24 +676,30 @@ export class ChangeExtractorService {
projectPath?: string,
includeDetails = true
): FileChangeSummary[] {
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
const fileMap = new Map<
string,
{ filePath: string; snippets: SnippetDiff[]; isNewFile: boolean }
>();
for (const snippet of snippets) {
// Пропускаем snippets с ошибками при агрегации
if (snippet.isError) continue;
const existing = fileMap.get(snippet.filePath);
const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath);
const existing = fileMap.get(normalizedFilePath);
if (existing) {
existing.snippets.push(snippet);
} else {
fileMap.set(snippet.filePath, {
fileMap.set(normalizedFilePath, {
filePath: snippet.filePath,
snippets: [snippet],
isNewFile: snippet.type === 'write-new',
});
}
}
return [...fileMap.entries()].map(([fp, data]) => {
return [...fileMap.values()].map((data) => {
const fp = data.filePath;
let totalAdded = 0;
let totalRemoved = 0;
for (const s of data.snippets) {
@ -854,16 +868,12 @@ export class ChangeExtractorService {
projectPath?: string,
includeDetails = true
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
const allSnippets: SnippetDiff[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(
ref.filePath,
ref.memberName,
projectPath,
includeDetails
);
allFiles.push(...files);
const snippets = await this.parseJSONLFile(ref.filePath);
allSnippets.push(...snippets);
}
const allFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
const fallbackScope: TaskChangeScope = {
taskId,
@ -916,4 +926,42 @@ export class ChangeExtractorService {
warnings: ['No log files found for this task.'],
};
}
private buildTaskChangeCacheKey(
teamName: string,
taskId: string,
options: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
},
includeDetails: boolean
): string {
const owner = typeof options.owner === 'string' ? options.owner.trim() : '';
const status = typeof options.status === 'string' ? options.status.trim() : '';
const since = typeof options.since === 'string' ? options.since : '';
const intervals = Array.isArray(options.intervals)
? options.intervals.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
}))
: [];
return JSON.stringify({
type: 'task',
teamName,
taskId,
includeDetails,
owner,
status,
since,
intervals,
});
}
private normalizeFilePathKey(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase());
}
}

View file

@ -35,6 +35,13 @@ const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_se
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
function extractTaskId(input: Record<string, unknown>): string {
const rawTaskId = input.taskId ?? input.task_id;
if (typeof rawTaskId === 'string') return rawTaskId;
if (typeof rawTaskId === 'number') return String(rawTaskId);
return '';
}
function pickDetectedMechanism(
current: DetectedMechanism,
next: Exclude<DetectedMechanism, 'none'>
@ -191,13 +198,7 @@ export class TaskBoundaryParser {
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
const rawTaskId = input.taskId;
const taskId =
typeof rawTaskId === 'string'
? rawTaskId
: typeof rawTaskId === 'number'
? String(rawTaskId)
: '';
const taskId = extractTaskId(input);
if (!taskId) continue;
const status = typeof input.status === 'string' ? input.status : '';
@ -243,13 +244,7 @@ export class TaskBoundaryParser {
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;
const rawTaskId = input.taskId;
const taskId =
typeof rawTaskId === 'string'
? rawTaskId
: typeof rawTaskId === 'number'
? String(rawTaskId)
: '';
const taskId = extractTaskId(input);
if (!taskId) continue;
let event: TaskBoundaryEvent = null;

View file

@ -4,6 +4,9 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { atomicWriteAsync } from './atomicWrite';
interface McpLaunchSpec {
@ -12,6 +15,14 @@ interface McpLaunchSpec {
}
const MCP_SERVER_NAME = 'agent-teams';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const USER_MCP_CONFIG_NAME = '.claude.json';
type McpServerConfig = Record<string, unknown>;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function getWorkspaceRoot(): string {
return process.cwd();
@ -94,22 +105,25 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
}
export class TeamMcpConfigBuilder {
async writeConfigFile(): Promise<string> {
async writeConfigFile(_projectPath?: string): Promise<string> {
const launchSpec = await resolveMcpLaunchSpec();
const configDir = path.join(os.tmpdir(), 'claude-team-mcp');
const configPath = path.join(configDir, `agent-teams-mcp-${randomUUID()}.json`);
const userServers = await this.readUserMcpServers();
const generatedServers: Record<string, McpServerConfig> = {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
},
};
const mergedServers = this.mergeServers(userServers, generatedServers);
await fs.promises.mkdir(configDir, { recursive: true });
await atomicWriteAsync(
configPath,
JSON.stringify(
{
mcpServers: {
[MCP_SERVER_NAME]: {
command: launchSpec.command,
args: launchSpec.args,
},
},
mcpServers: mergedServers,
},
null,
2
@ -118,4 +132,61 @@ export class TeamMcpConfigBuilder {
return configPath;
}
private async readUserMcpServers(): Promise<Record<string, McpServerConfig>> {
const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME);
return this.readMcpServersFromFile(configPath, 'user');
}
private async readMcpServersFromFile(
filePath: string,
scope: 'user'
): Promise<Record<string, McpServerConfig>> {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const mcpServers = parsed.mcpServers;
if (!isRecord(mcpServers)) {
return {};
}
return Object.fromEntries(
Object.entries(mcpServers).filter(([, config]) => isRecord(config))
) as Record<string, McpServerConfig>;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return {};
}
logger.warn(
`Failed to read ${scope} MCP config from ${filePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
return {};
}
}
private mergeServers(
userServers: Record<string, McpServerConfig>,
generatedServers: Record<string, McpServerConfig>
): Record<string, McpServerConfig> {
const duplicates = Object.keys(userServers).filter((name) =>
Object.hasOwn(generatedServers, name)
);
if (duplicates.length > 0) {
logger.info(`Merging MCP configs with overrides for: ${duplicates.join(', ')}`);
}
// We inline only top-level user MCP into --mcp-config.
// Project/local scopes are still loaded natively by Claude via
// --setting-sources user,project,local, which preserves documented precedence:
// local > project > user. Generated agent-teams must always win on name collision.
return {
...userServers,
...generatedServers,
};
}
}

View file

@ -2387,7 +2387,7 @@ export class TeamProvisioningService {
const { env: shellEnv } = await this.buildProvisioningEnv();
let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
@ -2734,7 +2734,7 @@ export class TeamProvisioningService {
const { env: shellEnv } = await this.buildProvisioningEnv();
let mcpConfigPath: string;
try {
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);

View file

@ -17,16 +17,11 @@ import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from
import { Github as GithubIcon } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
/** Ribbon colors by source */
const RIBBON_STYLES: Record<string, string> = {
official: 'bg-blue-500/90 text-white',
glama: 'bg-zinc-600/90 text-zinc-200',
};
interface McpServerCardProps {
server: McpCatalogItem;
isInstalled: boolean;
@ -81,17 +76,8 @@ export const McpServerCard = ({
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
}`}
>
{/* Source ribbon (top-left corner) */}
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
<div
className={`absolute left-[-18px] top-[8px] w-[80px] rotate-[-45deg] text-center text-[9px] font-semibold leading-[18px] shadow-sm ${RIBBON_STYLES[server.source] ?? RIBBON_STYLES.glama}`}
>
{server.source === 'official' ? 'Official' : 'Glama'}
</div>
</div>
{/* Header: icon + name */}
<div className={`flex items-start gap-2.5 ${hasIcon ? 'pl-5' : 'pl-7'}`}>
<div className="flex items-start gap-2.5">
{/* Server icon (only when available) */}
{hasIcon && (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
@ -105,7 +91,14 @@ export const McpServerCard = ({
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
{server.source !== 'official' && (
<div className="mt-1">
<SourceBadge source={server.source} />
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{isInstalled && (
<Badge

View file

@ -236,7 +236,7 @@ export const McpServerDetailDialog = ({
Installed
</Badge>
)}
<SourceBadge source={server.source} />
{server.source !== 'official' && <SourceBadge source={server.source} />}
</div>
</div>
</div>

View file

@ -18,16 +18,20 @@ import type { EnrichedPlugin } from '@shared/types/extensions';
interface PluginCardProps {
plugin: EnrichedPlugin;
index: number;
onClick: (pluginId: string) => void;
}
export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Element => {
export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => {
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
const installPlugin = useStore((s) => s.installPlugin);
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
const baseStriped = index % 2 === 0;
const smStriped = Math.floor(index / 2) % 2 === 0;
const xlStriped = Math.floor(index / 3) % 2 === 0;
return (
<div
@ -40,10 +44,22 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
onClick(plugin.pluginId);
}
}}
className={`hover:bg-surface-raised/45 flex w-full cursor-pointer flex-col gap-3 rounded-xl border bg-transparent p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
className={`hover:bg-surface-raised/45 relative flex w-full cursor-pointer flex-col gap-3 rounded-xl border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
baseStriped ? 'bg-surface-raised/10' : 'bg-transparent'
} ${smStriped ? 'sm:bg-surface-raised/10' : 'sm:bg-transparent'} ${
xlStriped ? 'xl:bg-surface-raised/10' : 'xl:bg-transparent'
} ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/35' : 'border-border'
}`}
>
{plugin.source === 'official' && (
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
<div className="absolute left-[-24px] top-[4px] w-[80px] rotate-[-45deg] bg-blue-500/90 text-center text-[9px] font-semibold leading-[18px] text-white shadow-sm">
Official
</div>
</div>
)}
{/* Header: name + status/meta */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">

View file

@ -32,6 +32,7 @@ import { ExternalLink, Loader2, Mail } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { InstallCountBadge } from '../common/InstallCountBadge';
import { SourceBadge } from '../common/SourceBadge';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
@ -87,14 +88,17 @@ export const PluginDetailDialog = ({
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
</div>
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<div className="flex shrink-0 items-center gap-1.5">
{plugin.isInstalled && (
<Badge
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
variant="outline"
>
Installed
</Badge>
)}
<SourceBadge source={plugin.source} />
</div>
</div>
</DialogHeader>
@ -108,6 +112,10 @@ export const PluginDetailDialog = ({
<span className="text-text-muted">Category</span>
<p className="capitalize text-text">{category}</p>
</div>
<div>
<span className="text-text-muted">Source</span>
<p className="capitalize text-text">{plugin.source}</p>
</div>
{plugin.version && (
<div>
<span className="text-text-muted">Version</span>

View file

@ -359,8 +359,13 @@ export const PluginsPanel = ({
{!loading && !error && filtered.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((plugin) => (
<PluginCard key={plugin.pluginId} plugin={plugin} onClick={setSelectedPluginId} />
{filtered.map((plugin, index) => (
<PluginCard
key={plugin.pluginId}
plugin={plugin}
index={index}
onClick={setSelectedPluginId}
/>
))}
</div>
)}

View file

@ -26,6 +26,10 @@ import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import {
buildTaskChangeRequestOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
@ -175,6 +179,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
memberName?: string;
taskId?: string;
initialFilePath?: string;
taskChangeRequestOptions?: TaskChangeRequestOptions;
}>({ open: false, mode: 'task' });
// Active teams for conflict warning in LaunchTeamDialog
@ -723,6 +728,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
mode: 'task',
taskId: pendingReviewRequest.taskId,
initialFilePath: pendingReviewRequest.filePath,
taskChangeRequestOptions: pendingReviewRequest.requestOptions,
});
if (pendingReviewRequest.filePath) {
selectReviewFile(pendingReviewRequest.filePath);
@ -763,18 +769,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
[teamName, softDeleteTask]
);
const handleViewChanges = useCallback((taskId: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
}, []);
const handleViewChanges = useCallback(
(taskId: string) => {
const task = taskMap.get(taskId);
setReviewDialogState({
open: true,
mode: 'task',
taskId,
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
});
},
[taskMap]
);
const handleViewChangesForFile = useCallback(
(taskId: string, filePath?: string) => {
setReviewDialogState({ open: true, mode: 'task', taskId });
const task = taskMap.get(taskId);
setReviewDialogState({
open: true,
mode: 'task',
taskId,
initialFilePath: filePath,
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
});
if (filePath) {
selectReviewFile(filePath);
}
},
[selectReviewFile]
[selectReviewFile, taskMap]
);
const handleDeleteTeam = useCallback((): void => {
@ -1873,7 +1895,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setReviewDialogState((prev) => ({
...prev,
open,
...(open ? {} : { initialFilePath: undefined }),
...(open
? {}
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
}))
}
teamName={teamName}
@ -1881,6 +1905,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
memberName={reviewDialogState.memberName}
taskId={reviewDialogState.taskId}
initialFilePath={reviewDialogState.initialFilePath}
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
projectPath={data.config.projectPath}
onEditorAction={handleEditorAction}
/>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -99,11 +100,17 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
const handleViewChanges = useCallback(
(viewTaskId: string, filePath?: string) => {
setPendingReviewRequest({ taskId: viewTaskId, filePath });
const targetTask = taskMap.get(viewTaskId);
if (!targetTask) return;
setPendingReviewRequest({
taskId: viewTaskId,
filePath,
requestOptions: buildTaskChangeRequestOptions(targetTask),
});
closeGlobalTaskDetail();
openTeamTab(teamName);
},
[closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, teamName]
[closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, taskMap, teamName]
);
if (!globalTaskDetail) return null;

View file

@ -34,6 +34,7 @@ import {
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { formatDistanceToNow } from 'date-fns';
@ -74,29 +75,6 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
function deriveTaskSince(task: TeamTaskWithKanban | null): string | undefined {
if (!task) return undefined;
const sources: string[] = [];
if (task.createdAt) sources.push(task.createdAt);
if (Array.isArray(task.workIntervals)) {
for (const i of task.workIntervals) {
if (i.startedAt) sources.push(i.startedAt);
}
}
if (Array.isArray(task.historyEvents)) {
for (const e of task.historyEvents) {
if (e.timestamp) sources.push(e.timestamp);
}
}
if (sources.length === 0) return undefined;
const earliest = sources.reduce((a, b) => (a < b ? a : b));
const d = new Date(earliest);
d.setTime(d.getTime() - TASK_SINCE_GRACE_MS);
return d.toISOString();
}
interface TaskDetailDialogProps {
open: boolean;
loading?: boolean;
@ -136,6 +114,7 @@ export const TaskDetailDialog = ({
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
@ -289,19 +268,39 @@ export const TaskDetailDialog = ({
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
const taskChangeRequestOptions = useMemo(
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
[currentTask]
);
const taskChangeSummaryOptions = useMemo(
() =>
currentTask
? buildTaskChangeRequestOptions(currentTask, {
since: taskSince,
summaryOnly: true,
})
: null,
[currentTask, taskSince]
);
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
const loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return null;
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
owner: currentTask.owner,
status: currentTask.status,
intervals: currentTask.workIntervals,
since: taskSince,
summaryOnly: true,
});
if (
!currentTask ||
!taskChangeSummaryOptions ||
variant !== 'team' ||
!isTaskCompleted ||
!onViewChanges
) {
return null;
}
const data = await api.review.getTaskChanges(
teamName,
currentTask.id,
taskChangeSummaryOptions
);
return data.files;
}, [currentTask, isTaskCompleted, onViewChanges, teamName, taskSince, variant]);
}, [currentTask, isTaskCompleted, onViewChanges, taskChangeSummaryOptions, teamName, variant]);
useEffect(() => {
if (variant !== 'team') return;
@ -312,7 +311,17 @@ export const TaskDetailDialog = ({
setTaskChangesError(null);
void loadTaskChangeSummary()
.then((files) => {
if (!cancelled) setTaskChangesFiles(files ?? null);
if (!cancelled) {
setTaskChangesFiles(files ?? null);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(
teamName,
currentTask.id,
taskChangeRequestOptions,
!!files?.length
);
}
}
})
.catch((error) => {
if (!cancelled) {
@ -346,7 +355,12 @@ export const TaskDetailDialog = ({
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary()
.then((files) => setTaskChangesFiles(files ?? null))
.then((files) => {
setTaskChangesFiles(files ?? null);
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(teamName, currentTask.id, taskChangeRequestOptions, !!files?.length);
}
})
.catch((error) => {
setTaskChangesFiles(null);
setTaskChangesError(
@ -354,7 +368,16 @@ export const TaskDetailDialog = ({
);
})
.finally(() => setTaskChangesLoading(false));
}, [currentTask, isTaskCompleted, onViewChanges, loadTaskChangeSummary, variant]);
}, [
currentTask,
isTaskCompleted,
onViewChanges,
loadTaskChangeSummary,
recordTaskHasChanges,
taskChangeRequestOptions,
teamName,
variant,
]);
const handleDependencyClick = (taskId: string): void => {
handleClose();

View file

@ -101,7 +101,7 @@ export const KanbanGridLayout = ({
}: KanbanGridLayoutProps): React.JSX.Element => {
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
const { visibleItems, applyVisibleItems } = usePersistedGridLayout({
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
scopeKey: `${GRID_SCOPE_PREFIX}:${teamName}`,
allItemIds: allColumnIds,
visibleItemIds: visibleColumnIds,
@ -115,8 +115,9 @@ export const KanbanGridLayout = ({
);
useEffect(() => {
if (!isLoaded) return;
setRenderLayout(visibleItems.map(toReactGridLayoutItem));
}, [visibleItems]);
}, [isLoaded, visibleItems]);
const applyReactGridLayout = useCallback(
(layout: Layout, options?: { persist?: boolean }) => {
@ -128,6 +129,10 @@ export const KanbanGridLayout = ({
[applyVisibleItems]
);
if (!isLoaded) {
return <div className="min-h-[640px] p-1.5" />;
}
return (
<div className="p-1.5">
<WidthAwareGridLayout

View file

@ -8,6 +8,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store';
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
ArrowLeftFromLine,
@ -200,15 +204,27 @@ export const KanbanTaskCard = ({
// Lazy-check if task has file changes (only for done/review/approved columns)
const showChangesColumn =
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
const cacheKey = `${teamName}:${task.id}`;
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const cacheKey = useMemo(
() => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions),
[teamName, task.id, taskChangeRequestOptions]
);
const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => {
if (showChangesColumn && task.status === 'completed' && taskHasChanges !== true) {
void checkTaskHasChanges(teamName, task.id);
void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions);
}
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
}, [
showChangesColumn,
task.status,
task.id,
teamName,
taskHasChanges,
checkTaskHasChanges,
taskChangeRequestOptions,
]);
const isReviewManual = columnId === 'review' && !hasReviewers;
const multiButton =

View file

@ -10,6 +10,7 @@ import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
@ -47,6 +48,7 @@ interface ChangeReviewDialogProps {
memberName?: string;
taskId?: string;
initialFilePath?: string;
taskChangeRequestOptions?: TaskChangeRequestOptions;
projectPath?: string;
onEditorAction?: (action: EditorSelectionAction) => void;
}
@ -63,6 +65,7 @@ export const ChangeReviewDialog = ({
memberName,
taskId,
initialFilePath,
taskChangeRequestOptions,
projectPath,
onEditorAction,
}: ChangeReviewDialogProps): React.ReactElement | null => {
@ -692,7 +695,7 @@ export const ChangeReviewDialog = ({
if (mode === 'agent' && memberName) {
void fetchAgentChanges(teamName, memberName);
} else if (mode === 'task' && taskId) {
void fetchTaskChanges(teamName, taskId);
void fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {});
}
// On close — clear only volatile cache, keep decisions in store
@ -703,6 +706,7 @@ export const ChangeReviewDialog = ({
teamName,
memberName,
taskId,
taskChangeRequestOptions,
decisionScopeKey,
fetchAgentChanges,
fetchTaskChanges,

View file

@ -318,6 +318,7 @@
width: 36px;
height: 6px;
transform: translateX(-50%);
cursor: ns-resize;
}
.kanban-grid-resize-handle-n {
@ -334,6 +335,7 @@
width: 6px;
height: 36px;
transform: translateY(-50%);
cursor: ew-resize;
}
.kanban-grid-resize-handle-e {
@ -355,21 +357,25 @@
.kanban-grid-resize-handle-ne {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
.kanban-grid-resize-handle-nw {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
.kanban-grid-resize-handle-se {
right: -4px;
bottom: -4px;
cursor: nwse-resize;
}
.kanban-grid-resize-handle-sw {
left: -4px;
bottom: -4px;
cursor: nesw-resize;
}
/* File icon glow — halo so dark icons stay visible on dark backgrounds */

View file

@ -37,26 +37,36 @@ function removeLocalStorage(key: string): void {
}
}
function pickNewestState(
...states: Array<PersistedGridLayoutState | null | undefined>
): PersistedGridLayoutState | null {
return states.reduce<PersistedGridLayoutState | null>((latest, current) => {
if (!current) return latest;
if (!latest) return current;
return current.updatedAt >= latest.updatedAt ? current : latest;
}, null);
}
export class BrowserGridLayoutRepository implements GridLayoutRepository<PersistedGridLayoutState> {
private idbUnavailable = false;
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
const key = storageKey(scopeKey);
const memoryState = this.fallbackStore.get(key) ?? null;
const localState = readLocalStorage(key);
let idbState: PersistedGridLayoutState | null = null;
if (!this.idbUnavailable) {
try {
const stored = await get<unknown>(key);
const sanitized = sanitizePersistedGridLayoutState(stored);
if (sanitized) {
return sanitized;
}
idbState = sanitizePersistedGridLayoutState(stored);
} catch {
this.idbUnavailable = true;
}
}
return this.fallbackStore.get(key) ?? readLocalStorage(key);
return pickNewestState(memoryState, localState, idbState);
}
async save(scopeKey: string, state: PersistedGridLayoutState): Promise<void> {

View file

@ -120,41 +120,18 @@ export function projectVisibleGridLayoutItems(
cols: number
): PersistedGridLayoutItem[] {
const visibleIdSet = new Set(visibleIds);
const visibleItems = allItems
return allItems
.filter((item) => visibleIdSet.has(item.id))
.map((item) => {
const width = Math.min(Math.max(1, item.w), cols);
const maxX = Math.max(0, cols - width);
return {
...item,
w: width,
x: Math.min(Math.max(0, item.x), maxX),
y: Math.max(0, item.y),
};
})
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
if (visibleItems.length === 0) {
return [];
}
const fillWidth =
visibleItems.length <= 3 ? Math.max(1, Math.floor(cols / visibleItems.length)) : 0;
const projected: PersistedGridLayoutItem[] = [];
let currentX = 0;
let currentY = 0;
let rowHeight = 0;
for (const item of visibleItems) {
const nextWidth =
visibleItems.length === 1 ? cols : Math.min(cols, Math.max(item.w, fillWidth || item.w));
if (currentX + nextWidth > cols) {
currentX = 0;
currentY += rowHeight;
rowHeight = 0;
}
projected.push({
...item,
x: currentX,
y: currentY,
w: nextWidth,
});
currentX += nextWidth;
rowHeight = Math.max(rowHeight, item.h);
}
return projected;
}

View file

@ -1,4 +1,8 @@
import { api } from '@renderer/api';
import {
buildTaskChangePresenceKey,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { structuredPatch } from 'diff';
@ -8,6 +12,7 @@ const taskChangesCheckInFlight = new Set<string>();
/** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
let latestTaskChangesRequestToken = 0;
/** Debounce timer for persisting decisions to disk */
let persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
@ -78,12 +83,22 @@ export interface ChangeReviewSlice {
// Editable diff state
editedContents: Record<string, string>;
/** Cache: "teamName:taskId" → true/false (has file changes) */
/** Cache: "teamName:taskId:signature" → true/false (has file changes) */
taskHasChanges: Record<string, boolean>;
// Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
fetchTaskChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => Promise<void>;
recordTaskHasChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
hasChanges: boolean
) => void;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
clearChangeReviewCache: () => void;
@ -145,7 +160,11 @@ export interface ChangeReviewSlice {
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
// Task change availability
checkTaskHasChanges: (teamName: string, taskId: string) => Promise<void>;
checkTaskHasChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => Promise<void>;
}
/**
@ -278,18 +297,43 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
}
},
fetchTaskChanges: async (teamName: string, taskId: string) => {
recordTaskHasChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
hasChanges: boolean
) => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges },
}));
if (hasChanges) {
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
},
fetchTaskChanges: async (teamName: string, taskId: string, options: TaskChangeRequestOptions) => {
const requestToken = ++latestTaskChangesRequestToken;
set({ changeSetLoading: true, changeSetError: null });
try {
const data = await api.review.getTaskChanges(teamName, taskId);
const cacheKey = `${teamName}:${taskId}`;
const data = await api.review.getTaskChanges(teamName, taskId, options);
if (requestToken !== latestTaskChangesRequestToken) return;
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
set((s) => ({
activeChangeSet: data,
changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null,
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
} catch (error) {
if (requestToken !== latestTaskChangesRequestToken) return;
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
logger.error('fetchTaskChanges error:', message);
set({ changeSetError: message, changeSetLoading: false });
@ -301,6 +345,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
clearChangeReview: () => {
latestTaskChangesRequestToken++;
set({
activeChangeSet: null,
changeSetLoading: false,
@ -320,6 +365,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
clearChangeReviewCache: () => {
latestTaskChangesRequestToken++;
set({
activeChangeSet: null,
changeSetLoading: false,
@ -337,6 +383,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
resetAllReviewState: () => {
latestTaskChangesRequestToken++;
set({
activeChangeSet: null,
changeSetLoading: false,
@ -978,8 +1025,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
}
},
checkTaskHasChanges: async (teamName: string, taskId: string) => {
const cacheKey = `${teamName}:${taskId}`;
checkTaskHasChanges: async (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
// Positive results are final — no need to recheck
if (get().taskHasChanges[cacheKey] === true) return;
// Prevent duplicate in-flight requests
@ -990,18 +1041,23 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(teamName, taskId);
const data = await api.review.getTaskChanges(teamName, taskId, {
...options,
summaryOnly: true,
});
if (data.files.length > 0) {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
}));
taskChangesNegativeCache.delete(cacheKey);
} else {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false },
}));
taskChangesNegativeCache.set(cacheKey, Date.now());
}
} catch {
// Don't cache errors in store — allow retry when session data appears later
taskChangesNegativeCache.set(cacheKey, Date.now());
// Allow immediate retry after transient failures (race, file lock, late logs).
} finally {
taskChangesCheckInFlight.delete(cacheKey);
}

View file

@ -1,4 +1,5 @@
import { api } from '@renderer/api';
import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger';
@ -277,8 +278,14 @@ export interface TeamSlice {
openMemberProfile: (memberName: string) => void;
closeMemberProfile: () => void;
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
pendingReviewRequest: { taskId: string; filePath?: string } | null;
setPendingReviewRequest: (req: { taskId: string; filePath?: string } | null) => void;
pendingReviewRequest: {
taskId: string;
filePath?: string;
requestOptions: TaskChangeRequestOptions;
} | null;
setPendingReviewRequest: (
req: { taskId: string; filePath?: string; requestOptions: TaskChangeRequestOptions } | null
) => void;
selectedTeamName: string | null;
selectedTeamData: TeamData | null;
selectedTeamLoading: boolean;

View file

@ -0,0 +1,97 @@
import type { ReviewAPI } from '@shared/types/api';
import type { TeamTaskWithKanban } from '@shared/types/team';
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
export type TaskChangeRequestOptions = NonNullable<Parameters<ReviewAPI['getTaskChanges']>[2]>;
export interface TaskChangeContext {
taskId: string;
requestOptions: TaskChangeRequestOptions;
initialFilePath?: string;
}
type TaskChangeTaskLike = Pick<
TeamTaskWithKanban,
'id' | 'owner' | 'status' | 'createdAt' | 'updatedAt' | 'workIntervals' | 'historyEvents'
>;
export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined {
if (!task) return undefined;
const sources: string[] = [];
if (task.createdAt) sources.push(task.createdAt);
if (Array.isArray(task.workIntervals)) {
for (const interval of task.workIntervals) {
if (interval.startedAt) sources.push(interval.startedAt);
}
}
if (Array.isArray(task.historyEvents)) {
for (const event of task.historyEvents) {
if (event.timestamp) sources.push(event.timestamp);
}
}
if (sources.length === 0) return undefined;
const earliest = sources.reduce((a, b) => (a < b ? a : b));
const date = new Date(earliest);
date.setTime(date.getTime() - TASK_SINCE_GRACE_MS);
return date.toISOString();
}
export function buildTaskChangeRequestOptions(
task: TaskChangeTaskLike,
overrides?: Partial<TaskChangeRequestOptions>
): TaskChangeRequestOptions {
const options: TaskChangeRequestOptions = {
owner: task.owner,
status: task.status,
intervals: task.workIntervals,
since: deriveTaskSince(task),
};
return {
...options,
...overrides,
};
}
export function buildTaskChangeContext(
task: TaskChangeTaskLike,
input?: { initialFilePath?: string; summaryOnly?: boolean }
): TaskChangeContext {
return {
taskId: task.id,
requestOptions: buildTaskChangeRequestOptions(task, {
summaryOnly: input?.summaryOnly,
}),
initialFilePath: input?.initialFilePath,
};
}
export function buildTaskChangeSignature(options: TaskChangeRequestOptions): string {
const owner = typeof options.owner === 'string' ? options.owner.trim() : '';
const status = typeof options.status === 'string' ? options.status.trim() : '';
const since = typeof options.since === 'string' ? options.since : '';
const intervals = Array.isArray(options.intervals)
? options.intervals.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
}))
: [];
return JSON.stringify({
owner,
status,
since,
intervals,
});
}
export function buildTaskChangePresenceKey(
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
): string {
return `${teamName}:${taskId}:${buildTaskChangeSignature(options)}`;
}

View file

@ -14,6 +14,7 @@ export interface PluginCatalogItem {
name: string; // display name only
// Metadata
source: 'official';
description: string;
category: string; // open-ended string, derived from marketplace.json
author?: { name: string; email?: string };

View file

@ -0,0 +1,148 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs/promises';
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
describe('ChangeExtractorService', () => {
let tmpDir: string | null = null;
afterEach(async () => {
setClaudeBasePathOverride(null);
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('does not reuse detailed task-change cache across different scope inputs', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
await fs.writeFile(
aliceLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'Write',
input: { file_path: '/repo/src/file.ts', content: 'export const value = 1;\n' },
},
],
},
}) + '\n',
'utf8'
);
const findLogsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
);
const parseBoundaries = vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
}));
const service = new ChangeExtractorService(
{
findLogsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{ parseBoundaries } as any,
{ getConfig: vi.fn(async () => ({ projectPath: '/repo' })) } as any
);
const empty = await service.getTaskChanges('team-a', '1', { owner: 'bob', status: 'completed' });
const populated = await service.getTaskChanges('team-a', '1', {
owner: 'alice',
status: 'completed',
});
expect(empty.files).toHaveLength(0);
expect(populated.files).toHaveLength(1);
expect(findLogsForTask).toHaveBeenCalledTimes(2);
});
it('merges fallback changes for the same Windows file across slash variants', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
const firstLogPath = path.join(tmpDir, 'first.jsonl');
const secondLogPath = path.join(tmpDir, 'second.jsonl');
await fs.writeFile(
firstLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'Write',
input: { file_path: 'C:\\repo\\src\\same.ts', content: 'first\n' },
},
],
},
}) + '\n',
'utf8'
);
await fs.writeFile(
secondLogPath,
JSON.stringify({
timestamp: '2026-03-01T10:01:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'Write',
input: { file_path: 'C:/repo/src/same.ts', content: 'second\n' },
},
],
},
}) + '\n',
'utf8'
);
const service = new ChangeExtractorService(
{
findLogsForTask: vi.fn(async () => [
{ filePath: firstLogPath, memberName: 'alice' },
{ filePath: secondLogPath, memberName: 'alice' },
]),
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => ({
boundaries: [],
scopes: [],
isSingleTaskSession: true,
detectedMechanism: 'none' as const,
})),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: 'C:\\repo' })) } as any
);
const result = await service.getTaskChanges('team-a', '1', {
owner: 'alice',
status: 'completed',
});
expect(result.files).toHaveLength(1);
expect(result.files[0]?.relativePath).toBe('src/same.ts');
expect(result.totalLinesAdded).toBe(2);
});
});

View file

@ -110,4 +110,51 @@ describe('TaskBoundaryParser', () => {
expect(result.boundaries).toHaveLength(1);
expect(result.boundaries[0]?.mechanism).toBe('mcp');
});
it('accepts task_id for TaskUpdate and MCP task markers', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'task-id-underscore.jsonl');
await fs.writeFile(
jsonlPath,
[
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'TaskUpdate',
input: { task_id: 'task-123', status: 'in_progress' },
},
],
},
}),
JSON.stringify({
timestamp: '2026-03-01T10:10:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'task_complete',
input: { task_id: 'task-123' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.boundaries).toHaveLength(2);
expect(result.boundaries.map((entry) => entry.taskId)).toEqual(['task-123', 'task-123']);
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
});
});

View file

@ -1,10 +1,23 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
let mockHomeDir = '';
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getHomeDir: () => mockHomeDir || actual.getHomeDir(),
};
});
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
describe('TeamMcpConfigBuilder', () => {
const createdPaths: string[] = [];
const createdDirs: string[] = [];
afterEach(() => {
for (const filePath of createdPaths.splice(0)) {
@ -14,6 +27,14 @@ describe('TeamMcpConfigBuilder', () => {
// ignore cleanup issues in temp dir
}
}
for (const dirPath of createdDirs.splice(0)) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// ignore cleanup issues in temp dir
}
}
mockHomeDir = '';
});
it('prefers the source MCP entry when workspace source is available', async () => {
@ -37,4 +58,156 @@ describe('TeamMcpConfigBuilder', () => {
`${process.cwd()}/mcp-server/src/index.ts`,
]);
});
it('merges top-level user MCP with generated agent-teams config', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
globalOnly: { type: 'http', url: 'https://global.example.com/mcp' },
duplicateServer: { type: 'http', url: 'https://global.example.com/duplicate' },
},
},
null,
2
)
);
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
projectOnly: { command: 'node', args: ['project-server.js'] },
duplicateServer: { command: 'node', args: ['project-override.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir);
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>;
};
expect(Object.keys(parsed.mcpServers).sort()).toEqual([
'agent-teams',
'duplicateServer',
'globalOnly',
]);
expect(parsed.mcpServers.globalOnly).toMatchObject({
type: 'http',
url: 'https://global.example.com/mcp',
});
expect(parsed.mcpServers.duplicateServer).toMatchObject({
type: 'http',
url: 'https://global.example.com/duplicate',
});
});
it('does not inline project MCP config to preserve native Claude precedence', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(path.join(homeDir, '.claude.json'), JSON.stringify({ mcpServers: {} }, null, 2));
fs.writeFileSync(
path.join(projectDir, '.mcp.json'),
JSON.stringify(
{
mcpServers: {
projectOnly: { command: 'node', args: ['project-server.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile(projectDir);
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(parsed.mcpServers.projectOnly).toBeUndefined();
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
});
it('generated agent-teams server overrides same-named user MCP entry', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
createdDirs.push(homeDir);
mockHomeDir = homeDir;
fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'agent-teams': { command: 'node', args: ['user-server.js'] },
},
},
null,
2
)
);
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(parsed.mcpServers['agent-teams']).toMatchObject({
command: 'pnpm',
args: [
'--dir',
`${process.cwd()}/mcp-server`,
'exec',
'tsx',
`${process.cwd()}/mcp-server/src/index.ts`,
],
});
});
it('ignores malformed user MCP file', async () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir;
fs.writeFileSync(path.join(homeDir, '.claude.json'), '{ invalid json');
const builder = new TeamMcpConfigBuilder();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let configPath = '';
try {
configPath = await builder.writeConfigFile(projectDir);
} finally {
warnSpy.mockRestore();
}
createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[] }>;
};
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
});
});

View file

@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand';
import { createChangeReviewSlice } from '../../../src/renderer/store/slices/changeReviewSlice';
const hoisted = vi.hoisted(() => ({
getTaskChanges: vi.fn(),
getAgentChanges: vi.fn(),
getChangeStats: vi.fn(),
getFileContent: vi.fn(),
applyDecisions: vi.fn(),
saveEditedFile: vi.fn(),
checkConflict: vi.fn(),
rejectHunks: vi.fn(),
rejectFile: vi.fn(),
previewReject: vi.fn(),
}));
vi.mock('@renderer/api', () => ({
api: {
review: {
getTaskChanges: hoisted.getTaskChanges,
getAgentChanges: hoisted.getAgentChanges,
getChangeStats: hoisted.getChangeStats,
getFileContent: hoisted.getFileContent,
applyDecisions: hoisted.applyDecisions,
saveEditedFile: hoisted.saveEditedFile,
checkConflict: hoisted.checkConflict,
rejectHunks: hoisted.rejectHunks,
rejectFile: hoisted.rejectFile,
previewReject: hoisted.previewReject,
},
},
}));
function createSliceStore() {
return create<any>()((set, get, store) => ({
...createChangeReviewSlice(set as never, get as never, store as never),
}));
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const OPTIONS_A = {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
since: '2026-03-01T09:58:00.000Z',
};
const OPTIONS_B = {
owner: 'bob',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T11:00:00.000Z' }],
since: '2026-03-01T10:58:00.000Z',
};
describe('changeReviewSlice task changes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not cache errors as negative task-change results', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockRejectedValue(new Error('transient'));
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A);
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('negative-caches confirmed empty results per request signature', async () => {
const store = createSliceStore();
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: '1',
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
},
warnings: [],
});
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A);
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_A);
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_B);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => {
const store = createSliceStore();
const first = deferred<any>();
const second = deferred<any>();
hoisted.getTaskChanges.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const firstFetch = store.getState().fetchTaskChanges('team-a', '1', OPTIONS_A);
const secondFetch = store.getState().fetchTaskChanges('team-a', '2', OPTIONS_B);
second.resolve({
teamName: 'team-a',
taskId: '2',
files: [{ filePath: '/repo/new.ts', relativePath: 'new.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true }],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '2',
memberName: 'bob',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/new.ts'],
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: [],
});
await secondFetch;
first.resolve({
teamName: 'team-a',
taskId: '1',
files: [{ filePath: '/repo/old.ts', relativePath: 'old.ts', snippets: [], linesAdded: 1, linesRemoved: 0, isNewFile: true }],
totalFiles: 1,
totalLinesAdded: 1,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: ['/repo/old.ts'],
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: [],
});
await firstFetch;
expect(store.getState().activeChangeSet?.taskId).toBe('2');
expect(store.getState().selectedReviewFilePath).toBe('/repo/new.ts');
});
});

View file

@ -36,6 +36,7 @@ const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
marketplaceId: 'test@marketplace',
qualifiedName: 'test@marketplace',
name: 'Test Plugin',
source: 'official',
description: 'A test plugin',
category: 'testing',
hasLspServers: false,

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
deriveTaskSince,
} from '@renderer/utils/taskChangeRequest';
describe('taskChangeRequest', () => {
it('derives since from the earliest known task timestamp with grace window', () => {
const since = deriveTaskSince({
id: 't1',
owner: 'alice',
status: 'completed',
createdAt: '2026-03-01T10:05:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
historyEvents: [
{
id: 'evt-1',
type: 'status_changed',
from: 'pending',
to: 'in_progress',
timestamp: '2026-03-01T10:00:00.000Z',
},
],
});
expect(since).toBe('2026-03-01T09:58:00.000Z');
});
it('builds canonical task change request options', () => {
const options = buildTaskChangeRequestOptions(
{
id: 't1',
owner: 'alice',
status: 'completed',
createdAt: '2026-03-01T10:05:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
historyEvents: [],
},
{ summaryOnly: true }
);
expect(options).toEqual({
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
since: '2026-03-01T10:03:00.000Z',
summaryOnly: true,
});
});
it('uses scope inputs for presence keys', () => {
const base = {
owner: 'alice',
status: 'completed',
intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }],
since: '2026-03-01T10:03:00.000Z',
};
expect(buildTaskChangePresenceKey('team-a', '1', base)).not.toBe(
buildTaskChangePresenceKey('team-a', '1', { ...base, owner: 'bob' })
);
});
});

View file

@ -39,6 +39,7 @@ describe('inferCapabilities', () => {
marketplaceId: 'test@marketplace',
qualifiedName: 'test@marketplace',
name: 'test',
source: 'official',
description: 'test',
category: 'development',
hasLspServers: false,