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:
parent
2317c948ff
commit
1ccc1432fc
30 changed files with 1163 additions and 182 deletions
|
|
@ -289,6 +289,7 @@ export class PluginCatalogService {
|
||||||
marketplaceId: qualifiedName,
|
marketplaceId: qualifiedName,
|
||||||
qualifiedName,
|
qualifiedName,
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
|
source: 'official',
|
||||||
description: raw.description ?? '',
|
description: raw.description ?? '',
|
||||||
category: raw.category ?? 'other',
|
category: raw.category ?? 'other',
|
||||||
author: raw.author,
|
author: raw.author,
|
||||||
|
|
|
||||||
|
|
@ -132,19 +132,25 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
const includeDetails = options?.summaryOnly !== true;
|
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;
|
const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined;
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
|
||||||
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 logRefs = await this.resolveLogFileRefs(teamName, logs);
|
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||||
if (logRefs.length === 0) {
|
if (logRefs.length === 0) {
|
||||||
const empty = this.emptyTaskChangeSet(teamName, taskId);
|
const empty = this.emptyTaskChangeSet(teamName, taskId);
|
||||||
|
|
@ -171,7 +177,7 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
// Если scope не найден — try deterministic interval scoping, else fallback to whole file
|
// Если scope не найден — try deterministic interval scoping, else fallback to whole file
|
||||||
if (allScopes.length === 0) {
|
if (allScopes.length === 0) {
|
||||||
const intervals = options?.intervals ?? taskMeta?.intervals;
|
const intervals = effectiveOptions.intervals;
|
||||||
if (Array.isArray(intervals) && intervals.length > 0) {
|
if (Array.isArray(intervals) && intervals.length > 0) {
|
||||||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
||||||
|
|
@ -345,7 +351,8 @@ export class ChangeExtractorService {
|
||||||
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
||||||
intervals: derivedIntervals,
|
intervals: derivedIntervals,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -532,7 +539,7 @@ export class ChangeExtractorService {
|
||||||
const replaceAll = input.replace_all === true;
|
const replaceAll = input.replace_all === true;
|
||||||
|
|
||||||
if (targetPath) {
|
if (targetPath) {
|
||||||
seenFiles.add(targetPath);
|
seenFiles.add(this.normalizeFilePathKey(targetPath));
|
||||||
snippets.push({
|
snippets.push({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: targetPath,
|
filePath: targetPath,
|
||||||
|
|
@ -551,8 +558,9 @@ export class ChangeExtractorService {
|
||||||
const writeContent = typeof input.content === 'string' ? input.content : '';
|
const writeContent = typeof input.content === 'string' ? input.content : '';
|
||||||
|
|
||||||
if (targetPath) {
|
if (targetPath) {
|
||||||
const isNew = !seenFiles.has(targetPath);
|
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
|
||||||
seenFiles.add(targetPath);
|
const isNew = !seenFiles.has(normalizedTargetPath);
|
||||||
|
seenFiles.add(normalizedTargetPath);
|
||||||
snippets.push({
|
snippets.push({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: targetPath,
|
filePath: targetPath,
|
||||||
|
|
@ -571,7 +579,7 @@ export class ChangeExtractorService {
|
||||||
const edits = Array.isArray(input.edits) ? input.edits : [];
|
const edits = Array.isArray(input.edits) ? input.edits : [];
|
||||||
|
|
||||||
if (targetPath) {
|
if (targetPath) {
|
||||||
seenFiles.add(targetPath);
|
seenFiles.add(this.normalizeFilePathKey(targetPath));
|
||||||
for (const edit of edits) {
|
for (const edit of edits) {
|
||||||
if (!edit || typeof edit !== 'object') continue;
|
if (!edit || typeof edit !== 'object') continue;
|
||||||
const editObj = edit as Record<string, unknown>;
|
const editObj = edit as Record<string, unknown>;
|
||||||
|
|
@ -668,24 +676,30 @@ export class ChangeExtractorService {
|
||||||
projectPath?: string,
|
projectPath?: string,
|
||||||
includeDetails = true
|
includeDetails = true
|
||||||
): FileChangeSummary[] {
|
): 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) {
|
for (const snippet of snippets) {
|
||||||
// Пропускаем snippets с ошибками при агрегации
|
// Пропускаем snippets с ошибками при агрегации
|
||||||
if (snippet.isError) continue;
|
if (snippet.isError) continue;
|
||||||
|
|
||||||
const existing = fileMap.get(snippet.filePath);
|
const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath);
|
||||||
|
const existing = fileMap.get(normalizedFilePath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.snippets.push(snippet);
|
existing.snippets.push(snippet);
|
||||||
} else {
|
} else {
|
||||||
fileMap.set(snippet.filePath, {
|
fileMap.set(normalizedFilePath, {
|
||||||
|
filePath: snippet.filePath,
|
||||||
snippets: [snippet],
|
snippets: [snippet],
|
||||||
isNewFile: snippet.type === 'write-new',
|
isNewFile: snippet.type === 'write-new',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...fileMap.entries()].map(([fp, data]) => {
|
return [...fileMap.values()].map((data) => {
|
||||||
|
const fp = data.filePath;
|
||||||
let totalAdded = 0;
|
let totalAdded = 0;
|
||||||
let totalRemoved = 0;
|
let totalRemoved = 0;
|
||||||
for (const s of data.snippets) {
|
for (const s of data.snippets) {
|
||||||
|
|
@ -854,16 +868,12 @@ export class ChangeExtractorService {
|
||||||
projectPath?: string,
|
projectPath?: string,
|
||||||
includeDetails = true
|
includeDetails = true
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
const allFiles: FileChangeSummary[] = [];
|
const allSnippets: SnippetDiff[] = [];
|
||||||
for (const ref of logRefs) {
|
for (const ref of logRefs) {
|
||||||
const files = await this.extractAllChanges(
|
const snippets = await this.parseJSONLFile(ref.filePath);
|
||||||
ref.filePath,
|
allSnippets.push(...snippets);
|
||||||
ref.memberName,
|
|
||||||
projectPath,
|
|
||||||
includeDetails
|
|
||||||
);
|
|
||||||
allFiles.push(...files);
|
|
||||||
}
|
}
|
||||||
|
const allFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||||
|
|
||||||
const fallbackScope: TaskChangeScope = {
|
const fallbackScope: TaskChangeScope = {
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -916,4 +926,42 @@ export class ChangeExtractorService {
|
||||||
warnings: ['No log files found for this task.'],
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_se
|
||||||
|
|
||||||
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
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(
|
function pickDetectedMechanism(
|
||||||
current: DetectedMechanism,
|
current: DetectedMechanism,
|
||||||
next: Exclude<DetectedMechanism, 'none'>
|
next: Exclude<DetectedMechanism, 'none'>
|
||||||
|
|
@ -191,13 +198,7 @@ export class TaskBoundaryParser {
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
||||||
const rawTaskId = input.taskId;
|
const taskId = extractTaskId(input);
|
||||||
const taskId =
|
|
||||||
typeof rawTaskId === 'string'
|
|
||||||
? rawTaskId
|
|
||||||
: typeof rawTaskId === 'number'
|
|
||||||
? String(rawTaskId)
|
|
||||||
: '';
|
|
||||||
if (!taskId) continue;
|
if (!taskId) continue;
|
||||||
|
|
||||||
const status = typeof input.status === 'string' ? input.status : '';
|
const status = typeof input.status === 'string' ? input.status : '';
|
||||||
|
|
@ -243,13 +244,7 @@ export class TaskBoundaryParser {
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
||||||
const rawTaskId = input.taskId;
|
const taskId = extractTaskId(input);
|
||||||
const taskId =
|
|
||||||
typeof rawTaskId === 'string'
|
|
||||||
? rawTaskId
|
|
||||||
: typeof rawTaskId === 'number'
|
|
||||||
? String(rawTaskId)
|
|
||||||
: '';
|
|
||||||
if (!taskId) continue;
|
if (!taskId) continue;
|
||||||
|
|
||||||
let event: TaskBoundaryEvent = null;
|
let event: TaskBoundaryEvent = null;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
interface McpLaunchSpec {
|
interface McpLaunchSpec {
|
||||||
|
|
@ -12,6 +15,14 @@ interface McpLaunchSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCP_SERVER_NAME = 'agent-teams';
|
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 {
|
function getWorkspaceRoot(): string {
|
||||||
return process.cwd();
|
return process.cwd();
|
||||||
|
|
@ -94,22 +105,25 @@ async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamMcpConfigBuilder {
|
export class TeamMcpConfigBuilder {
|
||||||
async writeConfigFile(): Promise<string> {
|
async writeConfigFile(_projectPath?: string): Promise<string> {
|
||||||
const launchSpec = await resolveMcpLaunchSpec();
|
const launchSpec = await resolveMcpLaunchSpec();
|
||||||
const configDir = path.join(os.tmpdir(), 'claude-team-mcp');
|
const configDir = path.join(os.tmpdir(), 'claude-team-mcp');
|
||||||
const configPath = path.join(configDir, `agent-teams-mcp-${randomUUID()}.json`);
|
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 fs.promises.mkdir(configDir, { recursive: true });
|
||||||
await atomicWriteAsync(
|
await atomicWriteAsync(
|
||||||
configPath,
|
configPath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
mcpServers: {
|
mcpServers: mergedServers,
|
||||||
[MCP_SERVER_NAME]: {
|
|
||||||
command: launchSpec.command,
|
|
||||||
args: launchSpec.args,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|
@ -118,4 +132,61 @@ export class TeamMcpConfigBuilder {
|
||||||
|
|
||||||
return configPath;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2387,7 +2387,7 @@ export class TeamProvisioningService {
|
||||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
try {
|
try {
|
||||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
|
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.activeByTeam.delete(request.teamName);
|
this.activeByTeam.delete(request.teamName);
|
||||||
|
|
@ -2734,7 +2734,7 @@ export class TeamProvisioningService {
|
||||||
const { env: shellEnv } = await this.buildProvisioningEnv();
|
const { env: shellEnv } = await this.buildProvisioningEnv();
|
||||||
let mcpConfigPath: string;
|
let mcpConfigPath: string;
|
||||||
try {
|
try {
|
||||||
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile();
|
mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.activeByTeam.delete(request.teamName);
|
this.activeByTeam.delete(request.teamName);
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,11 @@ import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from
|
||||||
import { Github as GithubIcon } from 'lucide-react';
|
import { Github as GithubIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
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 {
|
interface McpServerCardProps {
|
||||||
server: McpCatalogItem;
|
server: McpCatalogItem;
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
|
@ -81,17 +76,8 @@ export const McpServerCard = ({
|
||||||
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
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 */}
|
{/* 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) */}
|
{/* Server icon (only when available) */}
|
||||||
{hasIcon && (
|
{hasIcon && (
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<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">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{isInstalled && (
|
{isInstalled && (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ export const McpServerDetailDialog = ({
|
||||||
Installed
|
Installed
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<SourceBadge source={server.source} />
|
{server.source !== 'official' && <SourceBadge source={server.source} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,20 @@ import type { EnrichedPlugin } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface PluginCardProps {
|
interface PluginCardProps {
|
||||||
plugin: EnrichedPlugin;
|
plugin: EnrichedPlugin;
|
||||||
|
index: number;
|
||||||
onClick: (pluginId: string) => void;
|
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 capabilities = inferCapabilities(plugin);
|
||||||
const category = normalizeCategory(plugin.category);
|
const category = normalizeCategory(plugin.category);
|
||||||
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
|
const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle');
|
||||||
const installPlugin = useStore((s) => s.installPlugin);
|
const installPlugin = useStore((s) => s.installPlugin);
|
||||||
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
const uninstallPlugin = useStore((s) => s.uninstallPlugin);
|
||||||
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -40,10 +44,22 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
|
||||||
onClick(plugin.pluginId);
|
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.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 */}
|
{/* Header: name + status/meta */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { ExternalLink, Loader2, Mail } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||||
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
|
||||||
|
|
@ -87,14 +88,17 @@ export const PluginDetailDialog = ({
|
||||||
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
|
<DialogTitle className="truncate">{plugin.name}</DialogTitle>
|
||||||
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
|
<DialogDescription className="mt-1">{plugin.description}</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
{plugin.isInstalled && (
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
<Badge
|
{plugin.isInstalled && (
|
||||||
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
<Badge
|
||||||
variant="outline"
|
className="shrink-0 border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
||||||
>
|
variant="outline"
|
||||||
Installed
|
>
|
||||||
</Badge>
|
Installed
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
<SourceBadge source={plugin.source} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -108,6 +112,10 @@ export const PluginDetailDialog = ({
|
||||||
<span className="text-text-muted">Category</span>
|
<span className="text-text-muted">Category</span>
|
||||||
<p className="capitalize text-text">{category}</p>
|
<p className="capitalize text-text">{category}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-text-muted">Source</span>
|
||||||
|
<p className="capitalize text-text">{plugin.source}</p>
|
||||||
|
</div>
|
||||||
{plugin.version && (
|
{plugin.version && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-text-muted">Version</span>
|
<span className="text-text-muted">Version</span>
|
||||||
|
|
|
||||||
|
|
@ -359,8 +359,13 @@ export const PluginsPanel = ({
|
||||||
|
|
||||||
{!loading && !error && filtered.length > 0 && (
|
{!loading && !error && filtered.length > 0 && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{filtered.map((plugin) => (
|
{filtered.map((plugin, index) => (
|
||||||
<PluginCard key={plugin.pluginId} plugin={plugin} onClick={setSelectedPluginId} />
|
<PluginCard
|
||||||
|
key={plugin.pluginId}
|
||||||
|
plugin={plugin}
|
||||||
|
index={index}
|
||||||
|
onClick={setSelectedPluginId}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
|
import {
|
||||||
|
buildTaskChangeRequestOptions,
|
||||||
|
type TaskChangeRequestOptions,
|
||||||
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
|
|
@ -175,6 +179,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
|
taskChangeRequestOptions?: TaskChangeRequestOptions;
|
||||||
}>({ open: false, mode: 'task' });
|
}>({ open: false, mode: 'task' });
|
||||||
|
|
||||||
// Active teams for conflict warning in LaunchTeamDialog
|
// Active teams for conflict warning in LaunchTeamDialog
|
||||||
|
|
@ -723,6 +728,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
mode: 'task',
|
mode: 'task',
|
||||||
taskId: pendingReviewRequest.taskId,
|
taskId: pendingReviewRequest.taskId,
|
||||||
initialFilePath: pendingReviewRequest.filePath,
|
initialFilePath: pendingReviewRequest.filePath,
|
||||||
|
taskChangeRequestOptions: pendingReviewRequest.requestOptions,
|
||||||
});
|
});
|
||||||
if (pendingReviewRequest.filePath) {
|
if (pendingReviewRequest.filePath) {
|
||||||
selectReviewFile(pendingReviewRequest.filePath);
|
selectReviewFile(pendingReviewRequest.filePath);
|
||||||
|
|
@ -763,18 +769,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
[teamName, softDeleteTask]
|
[teamName, softDeleteTask]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleViewChanges = useCallback((taskId: string) => {
|
const handleViewChanges = useCallback(
|
||||||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
(taskId: string) => {
|
||||||
}, []);
|
const task = taskMap.get(taskId);
|
||||||
|
setReviewDialogState({
|
||||||
|
open: true,
|
||||||
|
mode: 'task',
|
||||||
|
taskId,
|
||||||
|
taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[taskMap]
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewChangesForFile = useCallback(
|
const handleViewChangesForFile = useCallback(
|
||||||
(taskId: string, filePath?: string) => {
|
(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) {
|
if (filePath) {
|
||||||
selectReviewFile(filePath);
|
selectReviewFile(filePath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectReviewFile]
|
[selectReviewFile, taskMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteTeam = useCallback((): void => {
|
const handleDeleteTeam = useCallback((): void => {
|
||||||
|
|
@ -1873,7 +1895,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
setReviewDialogState((prev) => ({
|
setReviewDialogState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
open,
|
open,
|
||||||
...(open ? {} : { initialFilePath: undefined }),
|
...(open
|
||||||
|
? {}
|
||||||
|
: { initialFilePath: undefined, taskChangeRequestOptions: undefined }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -1881,6 +1905,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
memberName={reviewDialogState.memberName}
|
memberName={reviewDialogState.memberName}
|
||||||
taskId={reviewDialogState.taskId}
|
taskId={reviewDialogState.taskId}
|
||||||
initialFilePath={reviewDialogState.initialFilePath}
|
initialFilePath={reviewDialogState.initialFilePath}
|
||||||
|
taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions}
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onEditorAction={handleEditorAction}
|
onEditorAction={handleEditorAction}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -99,11 +100,17 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const handleViewChanges = useCallback(
|
const handleViewChanges = useCallback(
|
||||||
(viewTaskId: string, filePath?: string) => {
|
(viewTaskId: string, filePath?: string) => {
|
||||||
setPendingReviewRequest({ taskId: viewTaskId, filePath });
|
const targetTask = taskMap.get(viewTaskId);
|
||||||
|
if (!targetTask) return;
|
||||||
|
setPendingReviewRequest({
|
||||||
|
taskId: viewTaskId,
|
||||||
|
filePath,
|
||||||
|
requestOptions: buildTaskChangeRequestOptions(targetTask),
|
||||||
|
});
|
||||||
closeGlobalTaskDetail();
|
closeGlobalTaskDetail();
|
||||||
openTeamTab(teamName);
|
openTeamTab(teamName);
|
||||||
},
|
},
|
||||||
[closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, teamName]
|
[closeGlobalTaskDetail, openTeamTab, setPendingReviewRequest, taskMap, teamName]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!globalTaskDetail) return null;
|
if (!globalTaskDetail) return null;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
@ -74,29 +75,6 @@ import type {
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} 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 {
|
interface TaskDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
@ -136,6 +114,7 @@ export const TaskDetailDialog = ({
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||||
|
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
|
||||||
|
|
||||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||||
const [executionPreviewOnline, setExecutionPreviewOnline] = 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
|
// Lazy-load task changes when dialog is open and task is completed
|
||||||
const isTaskCompleted = currentTask?.status === 'completed';
|
const isTaskCompleted = currentTask?.status === 'completed';
|
||||||
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
|
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 setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
||||||
|
|
||||||
const loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
|
const loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
|
||||||
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return null;
|
if (
|
||||||
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
|
!currentTask ||
|
||||||
owner: currentTask.owner,
|
!taskChangeSummaryOptions ||
|
||||||
status: currentTask.status,
|
variant !== 'team' ||
|
||||||
intervals: currentTask.workIntervals,
|
!isTaskCompleted ||
|
||||||
since: taskSince,
|
!onViewChanges
|
||||||
summaryOnly: true,
|
) {
|
||||||
});
|
return null;
|
||||||
|
}
|
||||||
|
const data = await api.review.getTaskChanges(
|
||||||
|
teamName,
|
||||||
|
currentTask.id,
|
||||||
|
taskChangeSummaryOptions
|
||||||
|
);
|
||||||
return data.files;
|
return data.files;
|
||||||
}, [currentTask, isTaskCompleted, onViewChanges, teamName, taskSince, variant]);
|
}, [currentTask, isTaskCompleted, onViewChanges, taskChangeSummaryOptions, teamName, variant]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'team') return;
|
if (variant !== 'team') return;
|
||||||
|
|
@ -312,7 +311,17 @@ export const TaskDetailDialog = ({
|
||||||
setTaskChangesError(null);
|
setTaskChangesError(null);
|
||||||
void loadTaskChangeSummary()
|
void loadTaskChangeSummary()
|
||||||
.then((files) => {
|
.then((files) => {
|
||||||
if (!cancelled) setTaskChangesFiles(files ?? null);
|
if (!cancelled) {
|
||||||
|
setTaskChangesFiles(files ?? null);
|
||||||
|
if (currentTask && taskChangeRequestOptions) {
|
||||||
|
recordTaskHasChanges(
|
||||||
|
teamName,
|
||||||
|
currentTask.id,
|
||||||
|
taskChangeRequestOptions,
|
||||||
|
!!files?.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -346,7 +355,12 @@ export const TaskDetailDialog = ({
|
||||||
setTaskChangesLoading(true);
|
setTaskChangesLoading(true);
|
||||||
setTaskChangesError(null);
|
setTaskChangesError(null);
|
||||||
void loadTaskChangeSummary()
|
void loadTaskChangeSummary()
|
||||||
.then((files) => setTaskChangesFiles(files ?? null))
|
.then((files) => {
|
||||||
|
setTaskChangesFiles(files ?? null);
|
||||||
|
if (currentTask && taskChangeRequestOptions) {
|
||||||
|
recordTaskHasChanges(teamName, currentTask.id, taskChangeRequestOptions, !!files?.length);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setTaskChangesFiles(null);
|
setTaskChangesFiles(null);
|
||||||
setTaskChangesError(
|
setTaskChangesError(
|
||||||
|
|
@ -354,7 +368,16 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setTaskChangesLoading(false));
|
.finally(() => setTaskChangesLoading(false));
|
||||||
}, [currentTask, isTaskCompleted, onViewChanges, loadTaskChangeSummary, variant]);
|
}, [
|
||||||
|
currentTask,
|
||||||
|
isTaskCompleted,
|
||||||
|
onViewChanges,
|
||||||
|
loadTaskChangeSummary,
|
||||||
|
recordTaskHasChanges,
|
||||||
|
taskChangeRequestOptions,
|
||||||
|
teamName,
|
||||||
|
variant,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDependencyClick = (taskId: string): void => {
|
const handleDependencyClick = (taskId: string): void => {
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ export const KanbanGridLayout = ({
|
||||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||||
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
|
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
|
||||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||||
const { visibleItems, applyVisibleItems } = usePersistedGridLayout({
|
const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({
|
||||||
scopeKey: `${GRID_SCOPE_PREFIX}:${teamName}`,
|
scopeKey: `${GRID_SCOPE_PREFIX}:${teamName}`,
|
||||||
allItemIds: allColumnIds,
|
allItemIds: allColumnIds,
|
||||||
visibleItemIds: visibleColumnIds,
|
visibleItemIds: visibleColumnIds,
|
||||||
|
|
@ -115,8 +115,9 @@ export const KanbanGridLayout = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isLoaded) return;
|
||||||
setRenderLayout(visibleItems.map(toReactGridLayoutItem));
|
setRenderLayout(visibleItems.map(toReactGridLayoutItem));
|
||||||
}, [visibleItems]);
|
}, [isLoaded, visibleItems]);
|
||||||
|
|
||||||
const applyReactGridLayout = useCallback(
|
const applyReactGridLayout = useCallback(
|
||||||
(layout: Layout, options?: { persist?: boolean }) => {
|
(layout: Layout, options?: { persist?: boolean }) => {
|
||||||
|
|
@ -128,6 +129,10 @@ export const KanbanGridLayout = ({
|
||||||
[applyVisibleItems]
|
[applyVisibleItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <div className="min-h-[640px] p-1.5" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
<WidthAwareGridLayout
|
<WidthAwareGridLayout
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
buildTaskChangePresenceKey,
|
||||||
|
buildTaskChangeRequestOptions,
|
||||||
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
|
|
@ -200,15 +204,27 @@ export const KanbanTaskCard = ({
|
||||||
// Lazy-check if task has file changes (only for done/review/approved columns)
|
// Lazy-check if task has file changes (only for done/review/approved columns)
|
||||||
const showChangesColumn =
|
const showChangesColumn =
|
||||||
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
|
(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 taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]);
|
||||||
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
|
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showChangesColumn && task.status === 'completed' && taskHasChanges !== true) {
|
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 isReviewManual = columnId === 'review' && !hasReviewers;
|
||||||
const multiButton =
|
const multiButton =
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
import { getFileHunkCount, REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
||||||
|
import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||||
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||||
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
|
||||||
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
|
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
|
||||||
|
|
@ -47,6 +48,7 @@ interface ChangeReviewDialogProps {
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
|
taskChangeRequestOptions?: TaskChangeRequestOptions;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
onEditorAction?: (action: EditorSelectionAction) => void;
|
onEditorAction?: (action: EditorSelectionAction) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +65,7 @@ export const ChangeReviewDialog = ({
|
||||||
memberName,
|
memberName,
|
||||||
taskId,
|
taskId,
|
||||||
initialFilePath,
|
initialFilePath,
|
||||||
|
taskChangeRequestOptions,
|
||||||
projectPath,
|
projectPath,
|
||||||
onEditorAction,
|
onEditorAction,
|
||||||
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
}: ChangeReviewDialogProps): React.ReactElement | null => {
|
||||||
|
|
@ -692,7 +695,7 @@ export const ChangeReviewDialog = ({
|
||||||
if (mode === 'agent' && memberName) {
|
if (mode === 'agent' && memberName) {
|
||||||
void fetchAgentChanges(teamName, memberName);
|
void fetchAgentChanges(teamName, memberName);
|
||||||
} else if (mode === 'task' && taskId) {
|
} else if (mode === 'task' && taskId) {
|
||||||
void fetchTaskChanges(teamName, taskId);
|
void fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// On close — clear only volatile cache, keep decisions in store
|
// On close — clear only volatile cache, keep decisions in store
|
||||||
|
|
@ -703,6 +706,7 @@ export const ChangeReviewDialog = ({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
taskId,
|
taskId,
|
||||||
|
taskChangeRequestOptions,
|
||||||
decisionScopeKey,
|
decisionScopeKey,
|
||||||
fetchAgentChanges,
|
fetchAgentChanges,
|
||||||
fetchTaskChanges,
|
fetchTaskChanges,
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-n {
|
.kanban-grid-resize-handle-n {
|
||||||
|
|
@ -334,6 +335,7 @@
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
cursor: ew-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-e {
|
.kanban-grid-resize-handle-e {
|
||||||
|
|
@ -355,21 +357,25 @@
|
||||||
.kanban-grid-resize-handle-ne {
|
.kanban-grid-resize-handle-ne {
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -4px;
|
right: -4px;
|
||||||
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-nw {
|
.kanban-grid-resize-handle-nw {
|
||||||
top: -4px;
|
top: -4px;
|
||||||
left: -4px;
|
left: -4px;
|
||||||
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-se {
|
.kanban-grid-resize-handle-se {
|
||||||
right: -4px;
|
right: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-sw {
|
.kanban-grid-resize-handle-sw {
|
||||||
left: -4px;
|
left: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
|
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
export class BrowserGridLayoutRepository implements GridLayoutRepository<PersistedGridLayoutState> {
|
||||||
private idbUnavailable = false;
|
private idbUnavailable = false;
|
||||||
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
|
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
|
||||||
|
|
||||||
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
|
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
|
||||||
const key = storageKey(scopeKey);
|
const key = storageKey(scopeKey);
|
||||||
|
const memoryState = this.fallbackStore.get(key) ?? null;
|
||||||
|
const localState = readLocalStorage(key);
|
||||||
|
let idbState: PersistedGridLayoutState | null = null;
|
||||||
|
|
||||||
if (!this.idbUnavailable) {
|
if (!this.idbUnavailable) {
|
||||||
try {
|
try {
|
||||||
const stored = await get<unknown>(key);
|
const stored = await get<unknown>(key);
|
||||||
const sanitized = sanitizePersistedGridLayoutState(stored);
|
idbState = sanitizePersistedGridLayoutState(stored);
|
||||||
if (sanitized) {
|
|
||||||
return sanitized;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.idbUnavailable = true;
|
this.idbUnavailable = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fallbackStore.get(key) ?? readLocalStorage(key);
|
return pickNewestState(memoryState, localState, idbState);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(scopeKey: string, state: PersistedGridLayoutState): Promise<void> {
|
async save(scopeKey: string, state: PersistedGridLayoutState): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -120,41 +120,18 @@ export function projectVisibleGridLayoutItems(
|
||||||
cols: number
|
cols: number
|
||||||
): PersistedGridLayoutItem[] {
|
): PersistedGridLayoutItem[] {
|
||||||
const visibleIdSet = new Set(visibleIds);
|
const visibleIdSet = new Set(visibleIds);
|
||||||
const visibleItems = allItems
|
return allItems
|
||||||
.filter((item) => visibleIdSet.has(item.id))
|
.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));
|
.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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import {
|
||||||
|
buildTaskChangePresenceKey,
|
||||||
|
type TaskChangeRequestOptions,
|
||||||
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { structuredPatch } from 'diff';
|
import { structuredPatch } from 'diff';
|
||||||
|
|
@ -8,6 +12,7 @@ const taskChangesCheckInFlight = new Set<string>();
|
||||||
/** Negative results cached with timestamp — recheck after 30s */
|
/** Negative results cached with timestamp — recheck after 30s */
|
||||||
const taskChangesNegativeCache = new Map<string, number>();
|
const taskChangesNegativeCache = new Map<string, number>();
|
||||||
const NEGATIVE_CACHE_TTL = 30_000;
|
const NEGATIVE_CACHE_TTL = 30_000;
|
||||||
|
let latestTaskChangesRequestToken = 0;
|
||||||
|
|
||||||
/** Debounce timer for persisting decisions to disk */
|
/** Debounce timer for persisting decisions to disk */
|
||||||
let persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
@ -78,12 +83,22 @@ export interface ChangeReviewSlice {
|
||||||
// Editable diff state
|
// Editable diff state
|
||||||
editedContents: Record<string, string>;
|
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>;
|
taskHasChanges: Record<string, boolean>;
|
||||||
|
|
||||||
// Phase 1 actions
|
// Phase 1 actions
|
||||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
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;
|
selectReviewFile: (filePath: string | null) => void;
|
||||||
clearChangeReview: () => void;
|
clearChangeReview: () => void;
|
||||||
clearChangeReviewCache: () => void;
|
clearChangeReviewCache: () => void;
|
||||||
|
|
@ -145,7 +160,11 @@ export interface ChangeReviewSlice {
|
||||||
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
|
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
|
||||||
|
|
||||||
// Task change availability
|
// 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 });
|
set({ changeSetLoading: true, changeSetError: null });
|
||||||
try {
|
try {
|
||||||
const data = await api.review.getTaskChanges(teamName, taskId);
|
const data = await api.review.getTaskChanges(teamName, taskId, options);
|
||||||
const cacheKey = `${teamName}:${taskId}`;
|
if (requestToken !== latestTaskChangesRequestToken) return;
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
activeChangeSet: data,
|
activeChangeSet: data,
|
||||||
changeSetLoading: false,
|
changeSetLoading: false,
|
||||||
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
selectedReviewFilePath: data.files[0]?.filePath ?? null,
|
||||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
|
||||||
}));
|
}));
|
||||||
|
if (data.files.length > 0) {
|
||||||
|
taskChangesNegativeCache.delete(cacheKey);
|
||||||
|
} else {
|
||||||
|
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestToken !== latestTaskChangesRequestToken) return;
|
||||||
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
|
||||||
logger.error('fetchTaskChanges error:', message);
|
logger.error('fetchTaskChanges error:', message);
|
||||||
set({ changeSetError: message, changeSetLoading: false });
|
set({ changeSetError: message, changeSetLoading: false });
|
||||||
|
|
@ -301,6 +345,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
},
|
},
|
||||||
|
|
||||||
clearChangeReview: () => {
|
clearChangeReview: () => {
|
||||||
|
latestTaskChangesRequestToken++;
|
||||||
set({
|
set({
|
||||||
activeChangeSet: null,
|
activeChangeSet: null,
|
||||||
changeSetLoading: false,
|
changeSetLoading: false,
|
||||||
|
|
@ -320,6 +365,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
},
|
},
|
||||||
|
|
||||||
clearChangeReviewCache: () => {
|
clearChangeReviewCache: () => {
|
||||||
|
latestTaskChangesRequestToken++;
|
||||||
set({
|
set({
|
||||||
activeChangeSet: null,
|
activeChangeSet: null,
|
||||||
changeSetLoading: false,
|
changeSetLoading: false,
|
||||||
|
|
@ -337,6 +383,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
},
|
},
|
||||||
|
|
||||||
resetAllReviewState: () => {
|
resetAllReviewState: () => {
|
||||||
|
latestTaskChangesRequestToken++;
|
||||||
set({
|
set({
|
||||||
activeChangeSet: null,
|
activeChangeSet: null,
|
||||||
changeSetLoading: false,
|
changeSetLoading: false,
|
||||||
|
|
@ -978,8 +1025,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
checkTaskHasChanges: async (teamName: string, taskId: string) => {
|
checkTaskHasChanges: async (
|
||||||
const cacheKey = `${teamName}:${taskId}`;
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
options: TaskChangeRequestOptions
|
||||||
|
) => {
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||||
// Positive results are final — no need to recheck
|
// Positive results are final — no need to recheck
|
||||||
if (get().taskHasChanges[cacheKey] === true) return;
|
if (get().taskHasChanges[cacheKey] === true) return;
|
||||||
// Prevent duplicate in-flight requests
|
// Prevent duplicate in-flight requests
|
||||||
|
|
@ -990,18 +1041,23 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
|
|
||||||
taskChangesCheckInFlight.add(cacheKey);
|
taskChangesCheckInFlight.add(cacheKey);
|
||||||
try {
|
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) {
|
if (data.files.length > 0) {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
|
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
|
||||||
}));
|
}));
|
||||||
taskChangesNegativeCache.delete(cacheKey);
|
taskChangesNegativeCache.delete(cacheKey);
|
||||||
} else {
|
} else {
|
||||||
|
set((s) => ({
|
||||||
|
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false },
|
||||||
|
}));
|
||||||
taskChangesNegativeCache.set(cacheKey, Date.now());
|
taskChangesNegativeCache.set(cacheKey, Date.now());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Don't cache errors in store — allow retry when session data appears later
|
// Allow immediate retry after transient failures (race, file lock, late logs).
|
||||||
taskChangesNegativeCache.set(cacheKey, Date.now());
|
|
||||||
} finally {
|
} finally {
|
||||||
taskChangesCheckInFlight.delete(cacheKey);
|
taskChangesCheckInFlight.delete(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -277,8 +278,14 @@ export interface TeamSlice {
|
||||||
openMemberProfile: (memberName: string) => void;
|
openMemberProfile: (memberName: string) => void;
|
||||||
closeMemberProfile: () => void;
|
closeMemberProfile: () => void;
|
||||||
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
|
/** Set by GlobalTaskDetailDialog to signal TeamDetailView to open ChangeReviewDialog */
|
||||||
pendingReviewRequest: { taskId: string; filePath?: string } | null;
|
pendingReviewRequest: {
|
||||||
setPendingReviewRequest: (req: { taskId: string; filePath?: string } | null) => void;
|
taskId: string;
|
||||||
|
filePath?: string;
|
||||||
|
requestOptions: TaskChangeRequestOptions;
|
||||||
|
} | null;
|
||||||
|
setPendingReviewRequest: (
|
||||||
|
req: { taskId: string; filePath?: string; requestOptions: TaskChangeRequestOptions } | null
|
||||||
|
) => void;
|
||||||
selectedTeamName: string | null;
|
selectedTeamName: string | null;
|
||||||
selectedTeamData: TeamData | null;
|
selectedTeamData: TeamData | null;
|
||||||
selectedTeamLoading: boolean;
|
selectedTeamLoading: boolean;
|
||||||
|
|
|
||||||
97
src/renderer/utils/taskChangeRequest.ts
Normal file
97
src/renderer/utils/taskChangeRequest.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ export interface PluginCatalogItem {
|
||||||
name: string; // display name only
|
name: string; // display name only
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
|
source: 'official';
|
||||||
description: string;
|
description: string;
|
||||||
category: string; // open-ended string, derived from marketplace.json
|
category: string; // open-ended string, derived from marketplace.json
|
||||||
author?: { name: string; email?: string };
|
author?: { name: string; email?: string };
|
||||||
|
|
|
||||||
148
test/main/services/team/ChangeExtractorService.test.ts
Normal file
148
test/main/services/team/ChangeExtractorService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -110,4 +110,51 @@ describe('TaskBoundaryParser', () => {
|
||||||
expect(result.boundaries).toHaveLength(1);
|
expect(result.boundaries).toHaveLength(1);
|
||||||
expect(result.boundaries[0]?.mechanism).toBe('mcp');
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 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';
|
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
|
||||||
|
|
||||||
describe('TeamMcpConfigBuilder', () => {
|
describe('TeamMcpConfigBuilder', () => {
|
||||||
const createdPaths: string[] = [];
|
const createdPaths: string[] = [];
|
||||||
|
const createdDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const filePath of createdPaths.splice(0)) {
|
for (const filePath of createdPaths.splice(0)) {
|
||||||
|
|
@ -14,6 +27,14 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
// ignore cleanup issues in temp dir
|
// 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 () => {
|
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`,
|
`${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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
173
test/renderer/store/changeReviewSlice.test.ts
Normal file
173
test/renderer/store/changeReviewSlice.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,6 +36,7 @@ const makePlugin = (overrides: Partial<EnrichedPlugin>): EnrichedPlugin => ({
|
||||||
marketplaceId: 'test@marketplace',
|
marketplaceId: 'test@marketplace',
|
||||||
qualifiedName: 'test@marketplace',
|
qualifiedName: 'test@marketplace',
|
||||||
name: 'Test Plugin',
|
name: 'Test Plugin',
|
||||||
|
source: 'official',
|
||||||
description: 'A test plugin',
|
description: 'A test plugin',
|
||||||
category: 'testing',
|
category: 'testing',
|
||||||
hasLspServers: false,
|
hasLspServers: false,
|
||||||
|
|
|
||||||
67
test/renderer/utils/taskChangeRequest.test.ts
Normal file
67
test/renderer/utils/taskChangeRequest.test.ts
Normal 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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -39,6 +39,7 @@ describe('inferCapabilities', () => {
|
||||||
marketplaceId: 'test@marketplace',
|
marketplaceId: 'test@marketplace',
|
||||||
qualifiedName: 'test@marketplace',
|
qualifiedName: 'test@marketplace',
|
||||||
name: 'test',
|
name: 'test',
|
||||||
|
source: 'official',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
category: 'development',
|
category: 'development',
|
||||||
hasLspServers: false,
|
hasLspServers: false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue