fix: resolve all CI lint errors and flaky test
- Fix React hooks violations: ref updates during render (useDraftPersistence, useChipDraftPersistence, useAttachments), setState in effects across 15+ components, useCallback self-reference TDZ in useResizableColumns - Fix TypeScript lint: remove unnecessary type assertions, replace inline import() annotations with direct imports, remove unused variables/imports - Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver, no-misleading-array-reverse in TeamProvisioningService, use-type-alias in ClaudeLogsSection, variable shadowing in ChangeExtractorService - Fix accessibility: associate labels with controls in filter popovers - Fix template expression safety: wrap unknown errors with String() - Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity to match filesystem birthtimeMs resolution on Linux - Replace TODO comments with NOTE where features are intentionally disabled - Remove unused leadContextByTeam from TeamDetailView store selector 62 files changed across main process, renderer, shared types, and hooks. All 1646 tests pass, typecheck clean, 0 lint errors.
This commit is contained in:
parent
6a67838d20
commit
2ceed41e00
62 changed files with 1432 additions and 1294 deletions
|
|
@ -426,7 +426,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
||||||
})
|
})
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)
|
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +466,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`)
|
logger.warn(
|
||||||
|
`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
TEAM_CREATE_CONFIG,
|
TEAM_CREATE_CONFIG,
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -38,6 +40,7 @@ import {
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
|
@ -51,9 +54,6 @@ import {
|
||||||
TEAM_UPDATE_TASK_FIELDS,
|
TEAM_UPDATE_TASK_FIELDS,
|
||||||
TEAM_UPDATE_TASK_OWNER,
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
|
||||||
TEAM_GET_TASK_ATTACHMENT,
|
|
||||||
TEAM_DELETE_TASK_ATTACHMENT,
|
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
|
|
@ -91,7 +91,6 @@ import type {
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type {
|
import type {
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
AttachmentMediaType,
|
|
||||||
AttachmentMeta,
|
AttachmentMeta,
|
||||||
AttachmentPayload,
|
AttachmentPayload,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
|
|
@ -105,13 +104,13 @@ import type {
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
TeamClaudeLogsQuery,
|
||||||
|
TeamClaudeLogsResponse,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
TeamData,
|
||||||
TeamClaudeLogsQuery,
|
|
||||||
TeamClaudeLogsResponse,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
|
|
@ -1049,7 +1048,9 @@ async function handleSendMessage(
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive) {
|
||||||
void provisioning
|
void provisioning
|
||||||
.relayLeadInboxMessages(tn)
|
.relayLeadInboxMessages(tn)
|
||||||
.catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`));
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -2038,7 +2039,7 @@ async function handleAddTaskComment(
|
||||||
vTask.value!,
|
vTask.value!,
|
||||||
safeId,
|
safeId,
|
||||||
a.filename,
|
a.filename,
|
||||||
a.mimeType as AttachmentMediaType,
|
a.mimeType,
|
||||||
a.base64Data
|
a.base64Data
|
||||||
);
|
);
|
||||||
savedAttachments.push(meta);
|
savedAttachments.push(meta);
|
||||||
|
|
@ -2160,9 +2161,9 @@ async function handleSaveTaskAttachment(
|
||||||
vTeam.value!,
|
vTeam.value!,
|
||||||
vTask.value!,
|
vTask.value!,
|
||||||
safeAttId,
|
safeAttId,
|
||||||
filename as string,
|
filename,
|
||||||
mimeType as AttachmentMediaType,
|
mimeType,
|
||||||
base64Data as string
|
base64Data
|
||||||
);
|
);
|
||||||
// Write metadata into the task JSON
|
// Write metadata into the task JSON
|
||||||
await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
|
await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
|
||||||
|
|
@ -2193,12 +2194,7 @@ async function handleGetTaskAttachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('getTaskAttachment', () =>
|
return wrapTeamHandler('getTaskAttachment', () =>
|
||||||
taskAttachmentStore.getAttachment(
|
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType)
|
||||||
vTeam.value!,
|
|
||||||
vTask.value!,
|
|
||||||
safeAttId,
|
|
||||||
mimeType as AttachmentMediaType
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2225,12 +2221,7 @@ async function handleDeleteTaskAttachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
||||||
await taskAttachmentStore.deleteAttachment(
|
await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType);
|
||||||
vTeam.value!,
|
|
||||||
vTask.value!,
|
|
||||||
safeAttId,
|
|
||||||
mimeType as AttachmentMediaType
|
|
||||||
);
|
|
||||||
// Remove metadata from task JSON
|
// Remove metadata from task JSON
|
||||||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ export class SubagentResolver {
|
||||||
if (!firstUserMessage) return undefined;
|
if (!firstUserMessage) return undefined;
|
||||||
|
|
||||||
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
||||||
const match = /<teammate-message\s+[^>]*\bteammate_id="([^"]+)"/.exec(text);
|
const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
|
||||||
return match?.[1];
|
return match?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,11 @@ export class FileWatcher extends EventEmitter {
|
||||||
private pendingReprocess = new Set<string>();
|
private pendingReprocess = new Set<string>();
|
||||||
/** Flag to prevent reuse after disposal */
|
/** Flag to prevent reuse after disposal */
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
|
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files).
|
||||||
private readonly instanceCreatedAt = Date.now();
|
* Floored to second granularity because filesystem birthtimeMs may have lower resolution
|
||||||
|
* than Date.now() — without this, a file created in the same millisecond-window could
|
||||||
|
* appear older than the watcher on some platforms (e.g. ext4 on Linux). */
|
||||||
|
private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
dataCache: DataCache,
|
dataCache: DataCache,
|
||||||
|
|
|
||||||
|
|
@ -449,16 +449,16 @@ export class ChangeExtractorService {
|
||||||
const isError = erroredIds.has(toolUseId);
|
const isError = erroredIds.has(toolUseId);
|
||||||
|
|
||||||
if (toolName === 'Edit') {
|
if (toolName === 'Edit') {
|
||||||
const path = typeof input.file_path === 'string' ? input.file_path : '';
|
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||||
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
||||||
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
||||||
const replaceAll = input.replace_all === true;
|
const replaceAll = input.replace_all === true;
|
||||||
|
|
||||||
if (path) {
|
if (targetPath) {
|
||||||
seenFiles.add(path);
|
seenFiles.add(targetPath);
|
||||||
snippets.push({
|
snippets.push({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: path,
|
filePath: targetPath,
|
||||||
toolName: 'Edit',
|
toolName: 'Edit',
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
oldString,
|
oldString,
|
||||||
|
|
@ -470,15 +470,15 @@ export class ChangeExtractorService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (toolName === 'Write') {
|
} else if (toolName === 'Write') {
|
||||||
const path = typeof input.file_path === 'string' ? input.file_path : '';
|
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||||
const writeContent = typeof input.content === 'string' ? input.content : '';
|
const writeContent = typeof input.content === 'string' ? input.content : '';
|
||||||
|
|
||||||
if (path) {
|
if (targetPath) {
|
||||||
const isNew = !seenFiles.has(path);
|
const isNew = !seenFiles.has(targetPath);
|
||||||
seenFiles.add(path);
|
seenFiles.add(targetPath);
|
||||||
snippets.push({
|
snippets.push({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: path,
|
filePath: targetPath,
|
||||||
toolName: 'Write',
|
toolName: 'Write',
|
||||||
type: isNew ? 'write-new' : 'write-update',
|
type: isNew ? 'write-new' : 'write-update',
|
||||||
oldString: '',
|
oldString: '',
|
||||||
|
|
@ -490,11 +490,11 @@ export class ChangeExtractorService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (toolName === 'MultiEdit') {
|
} else if (toolName === 'MultiEdit') {
|
||||||
const path = typeof input.file_path === 'string' ? input.file_path : '';
|
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||||
const edits = Array.isArray(input.edits) ? input.edits : [];
|
const edits = Array.isArray(input.edits) ? input.edits : [];
|
||||||
|
|
||||||
if (path) {
|
if (targetPath) {
|
||||||
seenFiles.add(path);
|
seenFiles.add(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>;
|
||||||
|
|
@ -502,7 +502,7 @@ export class ChangeExtractorService {
|
||||||
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
||||||
snippets.push({
|
snippets.push({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: path,
|
filePath: targetPath,
|
||||||
toolName: 'MultiEdit',
|
toolName: 'MultiEdit',
|
||||||
type: 'multi-edit',
|
type: 'multi-edit',
|
||||||
oldString,
|
oldString,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -8,7 +9,6 @@ import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamConfigReader');
|
const logger = createLogger('Service:TeamConfigReader');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
|
|
@ -933,7 +934,7 @@ export class TeamDataService {
|
||||||
summary: `Task #${task.id} started`,
|
summary: `Task #${task.id} started`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`);
|
logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -964,7 +965,7 @@ export class TeamDataService {
|
||||||
async addTaskAttachment(
|
async addTaskAttachment(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
meta: import('@shared/types').TaskAttachmentMeta
|
meta: TaskAttachmentMeta
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.taskWriter.addAttachment(teamName, taskId, meta);
|
await this.taskWriter.addAttachment(teamName, taskId, meta);
|
||||||
}
|
}
|
||||||
|
|
@ -1007,7 +1008,7 @@ export class TeamDataService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
text: string,
|
text: string,
|
||||||
attachments?: import('@shared/types').TaskAttachmentMeta[]
|
attachments?: TaskAttachmentMeta[]
|
||||||
): Promise<TaskComment> {
|
): Promise<TaskComment> {
|
||||||
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
||||||
attachments,
|
attachments,
|
||||||
|
|
@ -1022,8 +1023,6 @@ export class TeamDataService {
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
const leadName = this.resolveLeadNameFromConfig(config);
|
const leadName = this.resolveLeadNameFromConfig(config);
|
||||||
const owner = task?.owner?.trim() || null;
|
const owner = task?.owner?.trim() || null;
|
||||||
const normalizedOwner = owner?.toLowerCase() ?? null;
|
|
||||||
|
|
||||||
// Auto-clear needsClarification: "user" on UI comment
|
// Auto-clear needsClarification: "user" on UI comment
|
||||||
// UI comments always have author "user" (TeamTaskWriter default)
|
// UI comments always have author "user" (TeamTaskWriter default)
|
||||||
if (task?.needsClarification === 'user') {
|
if (task?.needsClarification === 'user') {
|
||||||
|
|
@ -1214,7 +1213,8 @@ export class TeamDataService {
|
||||||
name: (() => {
|
name: (() => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
if (name.toLowerCase() === 'team-lead') throw new Error('Member name "team-lead" is reserved');
|
if (name.toLowerCase() === 'team-lead')
|
||||||
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
|
|
@ -6,8 +8,6 @@ import type {
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
|
||||||
|
|
||||||
export class TeamMemberResolver {
|
export class TeamMemberResolver {
|
||||||
resolveMembers(
|
resolveMembers(
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -7,8 +8,6 @@ import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
import type { TeamMember } from '@shared/types';
|
import type { TeamMember } from '@shared/types';
|
||||||
|
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
|
||||||
|
|
||||||
interface TeamMembersMetaFile {
|
interface TeamMembersMetaFile {
|
||||||
version: 1;
|
version: 1;
|
||||||
members: TeamMember[];
|
members: TeamMember[];
|
||||||
|
|
|
||||||
|
|
@ -1008,8 +1008,11 @@ interface CachedProbeResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedProbeResult: CachedProbeResult | null = null;
|
let cachedProbeResult: CachedProbeResult | null = null;
|
||||||
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
|
let probeInFlight: Promise<{
|
||||||
null;
|
claudePath: string;
|
||||||
|
authSource: ProvisioningAuthSource;
|
||||||
|
warning?: string;
|
||||||
|
} | null> | null = null;
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||||
|
|
@ -1046,7 +1049,9 @@ export class TeamProvisioningService {
|
||||||
const offsetRaw = query?.offset ?? 0;
|
const offsetRaw = query?.offset ?? 0;
|
||||||
const limitRaw = query?.limit ?? 100;
|
const limitRaw = query?.limit ?? 100;
|
||||||
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
|
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
|
||||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
const limit = Number.isFinite(limitRaw)
|
||||||
|
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
|
||||||
|
: 100;
|
||||||
|
|
||||||
const total = run.claudeLogLines.length;
|
const total = run.claudeLogLines.length;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
|
|
@ -1057,15 +1062,17 @@ export class TeamProvisioningService {
|
||||||
const oldestInclusive = Math.max(0, newestExclusive - limit);
|
const oldestInclusive = Math.max(0, newestExclusive - limit);
|
||||||
const normalizeLine = (line: string): string => {
|
const normalizeLine = (line: string): string => {
|
||||||
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
|
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
|
||||||
if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length);
|
if (line.startsWith('[stdout] ') && line !== '[stdout]')
|
||||||
if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length);
|
return line.slice('[stdout] '.length);
|
||||||
|
if (line.startsWith('[stderr] ') && line !== '[stderr]')
|
||||||
|
return line.slice('[stderr] '.length);
|
||||||
return line;
|
return line;
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowOldestToNewest = run.claudeLogLines
|
const lines = run.claudeLogLines
|
||||||
.slice(oldestInclusive, newestExclusive)
|
.slice(oldestInclusive, newestExclusive)
|
||||||
.map(normalizeLine);
|
.map(normalizeLine)
|
||||||
const lines = windowOldestToNewest.reverse();
|
.toReversed();
|
||||||
return {
|
return {
|
||||||
lines,
|
lines,
|
||||||
total,
|
total,
|
||||||
|
|
@ -1201,7 +1208,8 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
async warmup(): Promise<void> {
|
async warmup(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
|
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
|
||||||
|
return;
|
||||||
const result = await this.getCachedOrProbeResult(process.cwd());
|
const result = await this.getCachedOrProbeResult(process.cwd());
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
logger.info('CLI warmup completed');
|
logger.info('CLI warmup completed');
|
||||||
|
|
@ -1287,7 +1295,11 @@ export class TeamProvisioningService {
|
||||||
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
|
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
|
||||||
const cached = this.getFreshCachedProbeResult();
|
const cached = this.getFreshCachedProbeResult();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning };
|
return {
|
||||||
|
claudePath: cached.claudePath,
|
||||||
|
authSource: cached.authSource,
|
||||||
|
warning: cached.warning,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (probeInFlight) {
|
if (probeInFlight) {
|
||||||
|
|
@ -1300,7 +1312,11 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const { env, authSource } = await this.buildProvisioningEnv();
|
const { env, authSource } = await this.buildProvisioningEnv();
|
||||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
|
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
|
||||||
const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) };
|
const result = {
|
||||||
|
claudePath,
|
||||||
|
authSource,
|
||||||
|
...(probe.warning ? { warning: probe.warning } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
|
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
|
||||||
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
|
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
|
||||||
|
|
@ -1340,12 +1356,13 @@ export class TeamProvisioningService {
|
||||||
private sanitizeCliSnippet(text: string): string {
|
private sanitizeCliSnippet(text: string): string {
|
||||||
// Remove control characters that often show up as binary noise in CLI error payloads.
|
// Remove control characters that often show up as binary noise in CLI error payloads.
|
||||||
// Preserve newlines/tabs for readability.
|
// Preserve newlines/tabs for readability.
|
||||||
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- intentionally stripping control chars
|
||||||
|
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractApiErrorSnippet(text: string): string | null {
|
private extractApiErrorSnippet(text: string): string | null {
|
||||||
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text);
|
||||||
if (!match || match.index === undefined) return null;
|
if (match?.index === undefined) return null;
|
||||||
const start = Math.max(0, match.index - 200);
|
const start = Math.max(0, match.index - 200);
|
||||||
const end = Math.min(text.length, match.index + 4000);
|
const end = Math.min(text.length, match.index + 4000);
|
||||||
const raw = text.slice(start, end).trim();
|
const raw = text.slice(start, end).trim();
|
||||||
|
|
@ -2505,7 +2522,7 @@ export class TeamProvisioningService {
|
||||||
void this.sentMessagesStore
|
void this.sentMessagesStore
|
||||||
.appendMessage(teamName, relayMsg)
|
.appendMessage(teamName, relayMsg)
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${e}`)
|
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${String(e)}`)
|
||||||
);
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
|
|
@ -2726,7 +2743,7 @@ export class TeamProvisioningService {
|
||||||
.appendMessage(run.teamName, msg)
|
.appendMessage(run.teamName, msg)
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${e}`
|
`[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${String(e)}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
|
|
@ -2831,7 +2848,10 @@ export class TeamProvisioningService {
|
||||||
run.leadTextPushedInCurrentTurn = true;
|
run.leadTextPushedInCurrentTurn = true;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
|
if (
|
||||||
|
now - run.lastLeadTextEmitMs >=
|
||||||
|
TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS
|
||||||
|
) {
|
||||||
run.lastLeadTextEmitMs = now;
|
run.lastLeadTextEmitMs = now;
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
|
|
@ -3030,7 +3050,7 @@ export class TeamProvisioningService {
|
||||||
void this.sentMessagesStore
|
void this.sentMessagesStore
|
||||||
.appendMessage(run.teamName, replyMsg)
|
.appendMessage(run.teamName, replyMsg)
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${String(e)}`)
|
||||||
);
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
|
|
@ -3053,7 +3073,7 @@ export class TeamProvisioningService {
|
||||||
void this.sentMessagesStore
|
void this.sentMessagesStore
|
||||||
.appendMessage(run.teamName, fallbackMsg)
|
.appendMessage(run.teamName, fallbackMsg)
|
||||||
.catch((e: unknown) =>
|
.catch((e: unknown) =>
|
||||||
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${String(e)}`)
|
||||||
);
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
|
|
@ -3121,14 +3141,10 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract compact metadata for the system message
|
// Extract compact metadata for the system message
|
||||||
const meta = (msg as Record<string, unknown>).compact_metadata as
|
const meta = msg.compact_metadata as Record<string, unknown> | undefined;
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined;
|
|
||||||
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
|
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
|
||||||
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
|
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
|
||||||
const tokenInfo = preTokens
|
const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : '';
|
||||||
? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const compactMsg: InboxMessage = {
|
const compactMsg: InboxMessage = {
|
||||||
from: 'system',
|
from: 'system',
|
||||||
|
|
@ -3160,7 +3176,12 @@ export class TeamProvisioningService {
|
||||||
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
|
||||||
// Guard: must be set synchronously BEFORE any await to prevent
|
// Guard: must be set synchronously BEFORE any await to prevent
|
||||||
// double-invocation from filesystem monitor + stream-json racing.
|
// double-invocation from filesystem monitor + stream-json racing.
|
||||||
if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed')
|
if (
|
||||||
|
run.provisioningComplete ||
|
||||||
|
run.cancelRequested ||
|
||||||
|
run.processKilled ||
|
||||||
|
run.progress.state === 'failed'
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
// Prevent false "ready" when auth failure was printed as assistant text or logs
|
||||||
|
|
@ -3172,7 +3193,11 @@ export class TeamProvisioningService {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
|
if (
|
||||||
|
preCompleteText &&
|
||||||
|
this.hasApiError(preCompleteText) &&
|
||||||
|
!this.isAuthFailureWarning(preCompleteText)
|
||||||
|
) {
|
||||||
this.failProvisioningWithApiError(run, preCompleteText);
|
this.failProvisioningWithApiError(run, preCompleteText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3235,7 +3260,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
// Pick up any direct messages that arrived before/while reconnecting.
|
// Pick up any direct messages that arrived before/while reconnecting.
|
||||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||||
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`)
|
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Solo teams have no teammate processes to resume work; kick off task execution
|
// Solo teams have no teammate processes to resume work; kick off task execution
|
||||||
|
|
@ -3320,7 +3345,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
// Pick up any direct messages that arrived during provisioning.
|
// Pick up any direct messages that arrived during provisioning.
|
||||||
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||||
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${e}`)
|
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3962,7 +3987,7 @@ export class TeamProvisioningService {
|
||||||
private async cleanupCliAutoSuffixedMembers(teamName: string): Promise<void> {
|
private async cleanupCliAutoSuffixedMembers(teamName: string): Promise<void> {
|
||||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||||
|
|
||||||
let removedFromConfig: string[] = [];
|
const removedFromConfig: string[] = [];
|
||||||
try {
|
try {
|
||||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||||
|
|
@ -3976,7 +4001,9 @@ export class TeamProvisioningService {
|
||||||
if (membersRaw.length > 0) {
|
if (membersRaw.length > 0) {
|
||||||
const teammateNames = membersRaw
|
const teammateNames = membersRaw
|
||||||
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
|
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
|
||||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
.filter(
|
||||||
|
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||||
|
);
|
||||||
|
|
||||||
const keepName = createCliAutoSuffixNameGuard(teammateNames);
|
const keepName = createCliAutoSuffixNameGuard(teammateNames);
|
||||||
const nextMembers: Record<string, unknown>[] = [];
|
const nextMembers: Record<string, unknown>[] = [];
|
||||||
|
|
@ -4008,14 +4035,16 @@ export class TeamProvisioningService {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeNamesForInboxCleanup: Set<string> = new Set();
|
let activeNamesForInboxCleanup = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
if (metaMembers.length > 0) {
|
if (metaMembers.length > 0) {
|
||||||
const activeNames = metaMembers
|
const activeNames = metaMembers
|
||||||
.filter((m) => !m.removedAt)
|
.filter((m) => !m.removedAt)
|
||||||
.map((m) => m.name.trim())
|
.map((m) => m.name.trim())
|
||||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
|
.filter(
|
||||||
|
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||||
|
);
|
||||||
|
|
||||||
const keepName = createCliAutoSuffixNameGuard(activeNames);
|
const keepName = createCliAutoSuffixNameGuard(activeNames);
|
||||||
const removedFromMeta: string[] = [];
|
const removedFromMeta: string[] = [];
|
||||||
|
|
@ -4042,7 +4071,9 @@ export class TeamProvisioningService {
|
||||||
nextMeta
|
nextMeta
|
||||||
.filter((m) => !m.removedAt)
|
.filter((m) => !m.removedAt)
|
||||||
.map((m) => m.name.trim())
|
.map((m) => m.name.trim())
|
||||||
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user')
|
.filter(
|
||||||
|
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ function dropCliAutoSuffixedMembers(
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const member = memberMap.get(key);
|
const member = memberMap.get(key);
|
||||||
const name = member?.name ?? '';
|
const name = member?.name ?? '';
|
||||||
const match = name.trim().match(/^(.+)-(\d+)$/);
|
const match = /^(.+)-(\d+)$/.exec(name.trim());
|
||||||
if (!match?.[1] || !match[2]) continue;
|
if (!match?.[1] || !match[2]) continue;
|
||||||
const suffix = Number(match[2]);
|
const suffix = Number(match[2]);
|
||||||
if (!Number.isFinite(suffix) || suffix < 2) continue;
|
if (!Number.isFinite(suffix) || suffix < 2) continue;
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ import {
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
TEAM_CREATE_CONFIG,
|
TEAM_CREATE_CONFIG,
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
|
@ -77,6 +78,7 @@ import {
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -91,12 +93,10 @@ import {
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REPLACE_MEMBERS,
|
TEAM_REPLACE_MEMBERS,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
|
||||||
TEAM_GET_TASK_ATTACHMENT,
|
|
||||||
TEAM_DELETE_TASK_ATTACHMENT,
|
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
|
@ -168,6 +168,7 @@ import type {
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
CliInstallationStatus,
|
CliInstallationStatus,
|
||||||
CliInstallerProgress,
|
CliInstallerProgress,
|
||||||
|
CommentAttachmentPayload,
|
||||||
ConflictCheckResult,
|
ConflictCheckResult,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
|
|
@ -193,7 +194,6 @@ import type {
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
CommentAttachmentPayload,
|
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@ import {
|
||||||
COLOR_TEXT_MUTED,
|
COLOR_TEXT_MUTED,
|
||||||
COLOR_TEXT_SECONDARY,
|
COLOR_TEXT_SECONDARY,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
|
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
|
||||||
import { formatCostUsd } from '@shared/utils/costFormatting';
|
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||||
|
|
||||||
import { formatTokens } from '../utils/formatting';
|
import { formatTokens } from '../utils/formatting';
|
||||||
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
|
|
||||||
|
|
||||||
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
COLOR_SURFACE_OVERLAY,
|
COLOR_SURFACE_OVERLAY,
|
||||||
COLOR_TEXT_MUTED,
|
COLOR_TEXT_MUTED,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
|
|
||||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||||
import { FlatInjectionList } from './components/FlatInjectionList';
|
import { FlatInjectionList } from './components/FlatInjectionList';
|
||||||
|
|
@ -29,7 +30,6 @@ import {
|
||||||
SECTION_TOOL_OUTPUTS,
|
SECTION_TOOL_OUTPUTS,
|
||||||
SECTION_USER_MESSAGES,
|
SECTION_USER_MESSAGES,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
|
||||||
|
|
||||||
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
|
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -133,10 +133,7 @@ export const SessionContextPanel = ({
|
||||||
}, [injections]);
|
}, [injections]);
|
||||||
|
|
||||||
// Calculate total tokens
|
// Calculate total tokens
|
||||||
const totalTokens = useMemo(
|
const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]);
|
||||||
() => sumContextInjectionTokens(injections),
|
|
||||||
[injections]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section token counts
|
// Section token counts
|
||||||
const claudeMdTokens = useMemo(
|
const claudeMdTokens = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import {
|
||||||
} from '@shared/constants/triggerColors';
|
} from '@shared/constants/triggerColors';
|
||||||
import { Wrench } from 'lucide-react';
|
import { Wrench } from 'lucide-react';
|
||||||
|
|
||||||
|
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||||
|
|
||||||
import { BaseItem, StatusDot } from './BaseItem';
|
import { BaseItem, StatusDot } from './BaseItem';
|
||||||
import { formatDuration } from './baseItemHelpers';
|
import { formatDuration } from './baseItemHelpers';
|
||||||
import {
|
import {
|
||||||
|
|
@ -38,7 +40,6 @@ import {
|
||||||
ToolErrorDisplay,
|
ToolErrorDisplay,
|
||||||
WriteToolViewer,
|
WriteToolViewer,
|
||||||
} from './linkedTool';
|
} from './linkedTool';
|
||||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
|
||||||
|
|
||||||
import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups';
|
import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups';
|
||||||
|
|
||||||
|
|
@ -72,9 +73,14 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
||||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||||
const summaryNode =
|
const summaryNode =
|
||||||
searchQueryOverride && searchQueryOverride.trim().length > 0
|
searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||||
? highlightQueryInText(summary, searchQueryOverride, `${linkedTool.id ?? linkedTool.name}:summary`, {
|
? highlightQueryInText(
|
||||||
forceAllActive: true,
|
summary,
|
||||||
})
|
searchQueryOverride,
|
||||||
|
`${linkedTool.id ?? linkedTool.name}:summary`,
|
||||||
|
{
|
||||||
|
forceAllActive: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
: summary;
|
: summary;
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
||||||
|
|
||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '../viewers';
|
|
||||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||||
|
import { MarkdownViewer } from '../viewers';
|
||||||
|
|
||||||
import { BaseItem } from './BaseItem';
|
import { BaseItem } from './BaseItem';
|
||||||
import { truncateText } from './baseItemHelpers';
|
import { truncateText } from './baseItemHelpers';
|
||||||
|
|
@ -42,9 +42,14 @@ export const TextItem: React.FC<TextItemProps> = ({
|
||||||
const fullContent = step.content.outputText ?? preview;
|
const fullContent = step.content.outputText ?? preview;
|
||||||
const truncatedPreview = truncateText(preview, 60);
|
const truncatedPreview = truncateText(preview, 60);
|
||||||
const summary = searchQueryOverride
|
const summary = searchQueryOverride
|
||||||
? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
? highlightQueryInText(
|
||||||
forceAllActive: true,
|
truncatedPreview,
|
||||||
})
|
searchQueryOverride,
|
||||||
|
`${markdownItemId ?? step.id}:summary`,
|
||||||
|
{
|
||||||
|
forceAllActive: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
: truncatedPreview;
|
: truncatedPreview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
||||||
|
|
||||||
import { Brain } from 'lucide-react';
|
import { Brain } from 'lucide-react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '../viewers';
|
|
||||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||||
|
import { MarkdownViewer } from '../viewers';
|
||||||
|
|
||||||
import { BaseItem } from './BaseItem';
|
import { BaseItem } from './BaseItem';
|
||||||
import { truncateText } from './baseItemHelpers';
|
import { truncateText } from './baseItemHelpers';
|
||||||
|
|
@ -42,9 +42,14 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
||||||
const fullContent = step.content.thinkingText ?? preview;
|
const fullContent = step.content.thinkingText ?? preview;
|
||||||
const truncatedPreview = truncateText(preview, 60);
|
const truncatedPreview = truncateText(preview, 60);
|
||||||
const summary = searchQueryOverride
|
const summary = searchQueryOverride
|
||||||
? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, {
|
? highlightQueryInText(
|
||||||
forceAllActive: true,
|
truncatedPreview,
|
||||||
})
|
searchQueryOverride,
|
||||||
|
`${markdownItemId ?? step.id}:summary`,
|
||||||
|
{
|
||||||
|
forceAllActive: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
: truncatedPreview;
|
: truncatedPreview;
|
||||||
|
|
||||||
// Get token count from step.tokens.output or step.content.tokenCount
|
// Get token count from step.tokens.output or step.content.tokenCount
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
|
||||||
export function highlightQueryInText(
|
export function highlightQueryInText(
|
||||||
text: string,
|
text: string,
|
||||||
query: string,
|
query: string,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|
||||||
import {
|
import {
|
||||||
CODE_BG,
|
CODE_BG,
|
||||||
CODE_BORDER,
|
CODE_BORDER,
|
||||||
|
|
@ -24,6 +23,7 @@ import {
|
||||||
PROSE_TABLE_BORDER,
|
PROSE_TABLE_BORDER,
|
||||||
PROSE_TABLE_HEADER_BG,
|
PROSE_TABLE_HEADER_BG,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
||||||
// Consume pending section (avoid setState during render)
|
// Consume pending section (avoid setState during render)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingSettingsSection) {
|
if (pendingSettingsSection) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setActiveSection(pendingSettingsSection as SettingsSection);
|
setActiveSection(pendingSettingsSection as SettingsSection);
|
||||||
clearPendingSettingsSection();
|
clearPendingSettingsSection();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
import { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
import {
|
||||||
import { lintGutter, linter, type Diagnostic } from '@codemirror/lint';
|
bracketMatching,
|
||||||
|
foldGutter,
|
||||||
|
foldKeymap,
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from '@codemirror/language';
|
||||||
|
import { type Diagnostic, linter, lintGutter } from '@codemirror/lint';
|
||||||
import { search, searchKeymap } from '@codemirror/search';
|
import { search, searchKeymap } from '@codemirror/search';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
|
@ -45,7 +51,7 @@ const jsonLinter = linter((view: EditorView) => {
|
||||||
JSON.parse(text);
|
JSON.parse(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof SyntaxError) {
|
if (e instanceof SyntaxError) {
|
||||||
const match = e.message.match(/position (\d+)/);
|
const match = /position (\d+)/.exec(e.message);
|
||||||
const pos = match ? parseInt(match[1], 10) : 0;
|
const pos = match ? parseInt(match[1], 10) : 0;
|
||||||
const safePos = Math.min(pos, text.length);
|
const safePos = Math.min(pos, text.length);
|
||||||
diagnostics.push({
|
diagnostics.push({
|
||||||
|
|
@ -163,6 +169,7 @@ export const ConfigEditorDialog = ({
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
|
|
@ -203,6 +210,7 @@ export const ConfigEditorDialog = ({
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
||||||
baseEditorTheme,
|
baseEditorTheme,
|
||||||
configEditorTheme,
|
configEditorTheme,
|
||||||
|
// eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
const text = update.state.doc.toString();
|
const text = update.state.doc.toString();
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ export const GlobalTaskList = ({
|
||||||
// Reset showArchived when archive becomes empty
|
// Reset showArchived when archive becomes empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showArchived && !hasArchivedTasks) {
|
if (showArchived && !hasArchivedTasks) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setShowArchived(false);
|
setShowArchived(false);
|
||||||
}
|
}
|
||||||
}, [showArchived, hasArchivedTasks]);
|
}, [showArchived, hasArchivedTasks]);
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export const SidebarTaskItem = ({
|
||||||
// Reset edit value when renaming starts
|
// Reset edit value when renaming starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRenaming) {
|
if (isRenaming) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setEditValue(displaySubject);
|
setEditValue(displaySubject);
|
||||||
}
|
}
|
||||||
}, [isRenaming, displaySubject]);
|
}, [isRenaming, displaySubject]);
|
||||||
|
|
|
||||||
|
|
@ -127,12 +127,26 @@ export const ClaudeLogsFilterPopover = ({
|
||||||
Stream
|
Stream
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label
|
||||||
<Checkbox checked={draft.streams.has('stdout')} onCheckedChange={() => toggleStream('stdout')} />
|
htmlFor="filter-stream-stdout"
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="filter-stream-stdout"
|
||||||
|
checked={draft.streams.has('stdout')}
|
||||||
|
onCheckedChange={() => toggleStream('stdout')}
|
||||||
|
/>
|
||||||
stdout
|
stdout
|
||||||
</label>
|
</label>
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label
|
||||||
<Checkbox checked={draft.streams.has('stderr')} onCheckedChange={() => toggleStream('stderr')} />
|
htmlFor="filter-stream-stderr"
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="filter-stream-stderr"
|
||||||
|
checked={draft.streams.has('stderr')}
|
||||||
|
onCheckedChange={() => toggleStream('stderr')}
|
||||||
|
/>
|
||||||
stderr
|
stderr
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,16 +157,37 @@ export const ClaudeLogsFilterPopover = ({
|
||||||
Content
|
Content
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label
|
||||||
<Checkbox checked={draft.kinds.has('output')} onCheckedChange={() => toggleKind('output')} />
|
htmlFor="filter-kind-output"
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="filter-kind-output"
|
||||||
|
checked={draft.kinds.has('output')}
|
||||||
|
onCheckedChange={() => toggleKind('output')}
|
||||||
|
/>
|
||||||
Output
|
Output
|
||||||
</label>
|
</label>
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label
|
||||||
<Checkbox checked={draft.kinds.has('thinking')} onCheckedChange={() => toggleKind('thinking')} />
|
htmlFor="filter-kind-thinking"
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="filter-kind-thinking"
|
||||||
|
checked={draft.kinds.has('thinking')}
|
||||||
|
onCheckedChange={() => toggleKind('thinking')}
|
||||||
|
/>
|
||||||
Thinking
|
Thinking
|
||||||
</label>
|
</label>
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label
|
||||||
<Checkbox checked={draft.kinds.has('tool')} onCheckedChange={() => toggleKind('tool')} />
|
htmlFor="filter-kind-tool"
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="filter-kind-tool"
|
||||||
|
checked={draft.kinds.has('tool')}
|
||||||
|
onCheckedChange={() => toggleKind('tool')}
|
||||||
|
/>
|
||||||
Tool calls
|
Tool calls
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,4 +211,3 @@ export const ClaudeLogsFilterPopover = ({
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,19 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Search, Terminal, X } from 'lucide-react';
|
import { Search, Terminal, X } from 'lucide-react';
|
||||||
|
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||||
import { CliLogsRichView } from './CliLogsRichView';
|
import { CliLogsRichView } from './CliLogsRichView';
|
||||||
import {
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
ClaudeLogsFilterPopover,
|
|
||||||
DEFAULT_CLAUDE_LOGS_FILTER,
|
|
||||||
} from './ClaudeLogsFilterPopover';
|
|
||||||
|
|
||||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
|
||||||
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||||
|
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
const ONLINE_WINDOW_MS = 10_000;
|
const ONLINE_WINDOW_MS = 10_000;
|
||||||
|
|
||||||
|
type StreamType = 'stdout' | 'stderr';
|
||||||
|
|
||||||
interface ClaudeLogsSectionProps {
|
interface ClaudeLogsSectionProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
@ -37,9 +36,9 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
||||||
const chronological = [...linesNewestFirst].reverse();
|
const chronological = [...linesNewestFirst].reverse();
|
||||||
|
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let lastStream: 'stdout' | 'stderr' | null = null;
|
let lastStream: StreamType | null = null;
|
||||||
|
|
||||||
const pushMarker = (stream: 'stdout' | 'stderr'): void => {
|
const pushMarker = (stream: StreamType): void => {
|
||||||
if (lastStream === stream) return;
|
if (lastStream === stream) return;
|
||||||
lastStream = stream;
|
lastStream = stream;
|
||||||
out.push(stream === 'stdout' ? '[stdout]' : '[stderr]');
|
out.push(stream === 'stdout' ? '[stdout]' : '[stderr]');
|
||||||
|
|
@ -84,8 +83,8 @@ function filterStreamJsonText(
|
||||||
const q = queryRaw.trim().toLowerCase();
|
const q = queryRaw.trim().toLowerCase();
|
||||||
const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n');
|
const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n');
|
||||||
|
|
||||||
let currentStream: 'stdout' | 'stderr' | null = null;
|
let currentStream: StreamType | null = null;
|
||||||
let lastEmittedStream: 'stdout' | 'stderr' | null = null;
|
let lastEmittedStream: StreamType | null = null;
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
const emitMarker = (): void => {
|
const emitMarker = (): void => {
|
||||||
|
|
@ -108,7 +107,10 @@ function filterStreamJsonText(
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeBlocks = (parsed: Record<string, unknown>, blocks: AssistantContentBlock[]): Record<string, unknown> => {
|
const writeBlocks = (
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
blocks: AssistantContentBlock[]
|
||||||
|
): Record<string, unknown> => {
|
||||||
if (Array.isArray(parsed.content)) {
|
if (Array.isArray(parsed.content)) {
|
||||||
return { ...parsed, content: blocks };
|
return { ...parsed, content: blocks };
|
||||||
}
|
}
|
||||||
|
|
@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => {
|
const computeNewCount = (
|
||||||
|
committed: TeamClaudeLogsResponse,
|
||||||
|
latest: TeamClaudeLogsResponse
|
||||||
|
): number => {
|
||||||
if (committed.lines.length === 0) return latest.lines.length;
|
if (committed.lines.length === 0) return latest.lines.length;
|
||||||
const marker = committed.lines[0];
|
const marker = committed.lines[0];
|
||||||
const idx = latest.lines.indexOf(marker);
|
const idx = latest.lines.indexOf(marker);
|
||||||
if (idx >= 0) return idx;
|
if (idx >= 0) return idx;
|
||||||
const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
|
const diff =
|
||||||
|
(latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
|
||||||
return Math.max(0, diff);
|
return Math.max(0, diff);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -386,12 +392,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={cn('rounded', loading && 'opacity-80')}>
|
||||||
className={cn(
|
|
||||||
'rounded',
|
|
||||||
loading && 'opacity-80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||||
{!error && filteredText.trim().length > 0 ? (
|
{!error && filteredText.trim().length > 0 ? (
|
||||||
<CliLogsRichView
|
<CliLogsRichView
|
||||||
|
|
@ -418,12 +419,9 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
||||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
||||||
No matching logs.
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!startedAt) {
|
if (!startedAt) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setElapsedSeconds(null);
|
setElapsedSeconds(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +202,8 @@ export const ProvisioningProgressBlock = ({
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
|
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
|
||||||
isDone && 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]',
|
isDone &&
|
||||||
|
'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]',
|
||||||
isCurrent &&
|
isCurrent &&
|
||||||
'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]'
|
'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]'
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,7 @@ import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import {
|
import { Blocks, BookOpen, Bug, Check, Code2, FileText, Pencil, Shield, Zap } from 'lucide-react';
|
||||||
Blocks,
|
|
||||||
BookOpen,
|
|
||||||
Bug,
|
|
||||||
Check,
|
|
||||||
Code2,
|
|
||||||
FileText,
|
|
||||||
Pencil,
|
|
||||||
Shield,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
@ -61,13 +51,14 @@ const roleOptions: ComboboxOption[] = [
|
||||||
{ value: CUSTOM_ROLE, label: 'Custom role...' },
|
{ value: CUSTOM_ROLE, label: 'Custom role...' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
|
||||||
const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => {
|
const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => {
|
||||||
const Icon =
|
const Icon =
|
||||||
option.value === CUSTOM_ROLE
|
option.value === CUSTOM_ROLE
|
||||||
? CUSTOM_ICON
|
? CUSTOM_ICON
|
||||||
: option.value === NO_ROLE
|
: option.value === NO_ROLE
|
||||||
? null
|
? null
|
||||||
: ROLE_ICONS[option.value] ?? null;
|
: (ROLE_ICONS[option.value] ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -42,14 +42,8 @@ export const ReplyQuoteBlock = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quote text */}
|
{/* Quote text */}
|
||||||
<div
|
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
|
||||||
className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
|
<MarkdownViewer content={reply.originalText} bare maxHeight={quoteMaxHeight} />
|
||||||
>
|
|
||||||
<MarkdownViewer
|
|
||||||
content={reply.originalText}
|
|
||||||
bare
|
|
||||||
maxHeight={quoteMaxHeight}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* More/less toggle */}
|
{/* More/less toggle */}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
|
import 'yet-another-react-lightbox/styles.css';
|
||||||
|
import 'yet-another-react-lightbox/plugins/counter.css';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import Lightbox from 'yet-another-react-lightbox';
|
import Lightbox from 'yet-another-react-lightbox';
|
||||||
import Counter from 'yet-another-react-lightbox/plugins/counter';
|
import Counter from 'yet-another-react-lightbox/plugins/counter';
|
||||||
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
||||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
||||||
import 'yet-another-react-lightbox/styles.css';
|
|
||||||
import 'yet-another-react-lightbox/plugins/counter.css';
|
|
||||||
|
|
||||||
import type { Plugin, Slide } from 'yet-another-react-lightbox';
|
import type { Plugin, Slide } from 'yet-another-react-lightbox';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -12,7 +13,6 @@ import {
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
|
||||||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export const CreateTaskDialog = ({
|
||||||
// Reset form when dialog opens (avoid setState during render)
|
// Reset form when dialog opens (avoid setState during render)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !prevOpenRef.current) {
|
if (open && !prevOpenRef.current) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setSubject(defaultSubject);
|
setSubject(defaultSubject);
|
||||||
if (defaultChip) {
|
if (defaultChip) {
|
||||||
const token = chipToken(defaultChip);
|
const token = chipToken(defaultChip);
|
||||||
|
|
|
||||||
|
|
@ -559,6 +559,7 @@ export const CreateTeamDialog = ({
|
||||||
setTeamName(value);
|
setTeamName(value);
|
||||||
setFieldErrors((prev) => {
|
setFieldErrors((prev) => {
|
||||||
if (!prev.teamName) return prev;
|
if (!prev.teamName) return prev;
|
||||||
|
// eslint-disable-next-line sonarjs/no-unused-vars -- destructured to omit teamName from rest
|
||||||
const { teamName: _teamName, ...rest } = prev;
|
const { teamName: _teamName, ...rest } = prev;
|
||||||
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
|
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
@ -636,7 +637,11 @@ export const CreateTeamDialog = ({
|
||||||
{prepareWarnings.length > 0 ? (
|
{prepareWarnings.length > 0 ? (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
<p
|
||||||
|
key={warning}
|
||||||
|
className="text-[11px]"
|
||||||
|
style={{ color: 'var(--warning-text)' }}
|
||||||
|
>
|
||||||
{warning}
|
{warning}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||||
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -25,8 +25,8 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
@ -129,7 +129,16 @@ export const SendMessageDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevOpenRef.current = open;
|
prevOpenRef.current = open;
|
||||||
}, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]);
|
}, [
|
||||||
|
open,
|
||||||
|
defaultRecipient,
|
||||||
|
defaultText,
|
||||||
|
defaultChip,
|
||||||
|
quotedMessage,
|
||||||
|
lastResult,
|
||||||
|
textDraft,
|
||||||
|
chipDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
// Track whether auto-close is needed (avoid setState in render)
|
// Track whether auto-close is needed (avoid setState in render)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -421,7 +430,9 @@ export const SendMessageDialog = ({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{textDraft.isSaved ? (
|
{textDraft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
Draft saved
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +453,6 @@ export const SendMessageDialog = ({
|
||||||
Shown as notification preview. Team lead also sees this for peer messages.
|
Shown as notification preview. Team lead also sees this for peer messages.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
|
import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskAttachmentMeta } from '@shared/types';
|
import type { TaskAttachmentMeta } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -105,7 +104,10 @@ export const TaskAttachments = ({
|
||||||
setError('Attachment file not found');
|
setError('Attachment file not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mime = att.mimeType && typeof att.mimeType === 'string' ? att.mimeType : 'application/octet-stream';
|
const mime =
|
||||||
|
att.mimeType && typeof att.mimeType === 'string'
|
||||||
|
? att.mimeType
|
||||||
|
: 'application/octet-stream';
|
||||||
const dataUrl = `data:${mime};base64,${base64}`;
|
const dataUrl = `data:${mime};base64,${base64}`;
|
||||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
@ -212,8 +214,14 @@ export const TaskAttachments = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
isDeleting={deletingId === att.id}
|
isDeleting={deletingId === att.id}
|
||||||
onPreview={() => void handlePreview(att)}
|
onPreview={() => {
|
||||||
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
// eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise
|
||||||
|
void handlePreview(att);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
// eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise
|
||||||
|
void handleDelete(att.id, att.mimeType);
|
||||||
|
}}
|
||||||
onDataLoaded={handleThumbLoaded}
|
onDataLoaded={handleThumbLoaded}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -327,7 +335,7 @@ const AttachmentThumbnail = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] bg-[var(--color-surface)]`}
|
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]`}
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
>
|
>
|
||||||
{isImageMimeType(attachment.mimeType) ? (
|
{isImageMimeType(attachment.mimeType) ? (
|
||||||
|
|
@ -381,7 +389,7 @@ function fileToBase64(file: File): Promise<string> {
|
||||||
reject(new Error('Failed to read file as base64'));
|
reject(new Error('Failed to read file as base64'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.onerror = () => reject(reader.error);
|
reader.onerror = () => reject(reader.error ?? new Error('File read failed'));
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,50 +71,45 @@ export const TaskCommentInput = ({
|
||||||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
trimmed.length <= MAX_COMMENT_LENGTH &&
|
||||||
!addingComment;
|
!addingComment;
|
||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback((files: FileList | File[]) => {
|
||||||
(files: FileList | File[]) => {
|
setAttachError(null);
|
||||||
setAttachError(null);
|
const fileArray = Array.from(files);
|
||||||
const fileArray = Array.from(files);
|
for (const file of fileArray) {
|
||||||
for (const file of fileArray) {
|
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
setAttachError(`Unsupported type: ${file.type}`);
|
||||||
setAttachError(`Unsupported type: ${file.type}`);
|
continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
setAttachError(
|
|
||||||
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = reader.result as string;
|
|
||||||
const base64 = result.split(',')[1];
|
|
||||||
if (!base64) return;
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
setPendingAttachments((prev) => {
|
|
||||||
if (prev.length >= MAX_ATTACHMENTS) {
|
|
||||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
filename: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
base64Data: base64,
|
|
||||||
previewUrl: result,
|
|
||||||
size: file.size,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
},
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
[]
|
setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
|
||||||
);
|
continue;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(',')[1];
|
||||||
|
if (!base64) return;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setPendingAttachments((prev) => {
|
||||||
|
if (prev.length >= MAX_ATTACHMENTS) {
|
||||||
|
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
base64Data: base64,
|
||||||
|
previewUrl: result,
|
||||||
|
size: file.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const removeAttachment = useCallback((id: string) => {
|
const removeAttachment = useCallback((id: string) => {
|
||||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
|
@ -131,7 +126,7 @@ export const TaskCommentInput = ({
|
||||||
? pendingAttachments.map((a) => ({
|
? pendingAttachments.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
mimeType: a.mimeType as CommentAttachmentPayload['mimeType'],
|
mimeType: a.mimeType,
|
||||||
base64Data: a.base64Data,
|
base64Data: a.base64Data,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -245,6 +240,7 @@ export const TaskCommentInput = ({
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.files) addFiles(e.target.files);
|
if (e.target.files) addFiles(e.target.files);
|
||||||
|
// eslint-disable-next-line no-param-reassign -- reset file input to allow re-selecting same file
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|
||||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -11,8 +11,8 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
|
@ -92,6 +92,7 @@ export const TaskCommentsSection = ({
|
||||||
|
|
||||||
// Reset local UI state when team/task changes.
|
// Reset local UI state when team/task changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setPreviewImageUrl(null);
|
setPreviewImageUrl(null);
|
||||||
|
|
@ -169,125 +170,128 @@ export const TaskCommentsSection = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className={containerClassName ?? ''}>
|
<div className={containerClassName ?? ''}>
|
||||||
{visibleComments.map((comment, index) => (
|
{visibleComments.map((comment, index) => (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={[
|
className={[
|
||||||
'group px-4 py-2.5',
|
'group px-4 py-2.5',
|
||||||
comment.type === 'review_approved'
|
comment.type === 'review_approved'
|
||||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||||
: comment.type === 'review_request'
|
: comment.type === 'review_request'
|
||||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||||
: '',
|
: '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={
|
style={
|
||||||
!comment.type || comment.type === 'regular'
|
!comment.type || comment.type === 'regular'
|
||||||
? { backgroundColor: index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)' }
|
? {
|
||||||
: undefined
|
backgroundColor:
|
||||||
}
|
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||||
>
|
}
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
: undefined
|
||||||
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
}
|
||||||
{comment.type === 'review_approved' ? (
|
>
|
||||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<CheckCircle2 size={10} />
|
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
|
||||||
Approved
|
{comment.type === 'review_approved' ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||||
|
<CheckCircle2 size={10} />
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
) : comment.type === 'review_request' ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||||
|
<Eye size={10} />
|
||||||
|
Review requested
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{(() => {
|
||||||
|
const date = new Date(comment.createdAt);
|
||||||
|
return isNaN(date.getTime())
|
||||||
|
? 'unknown time'
|
||||||
|
: formatDistanceToNow(date, { addSuffix: true });
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
) : comment.type === 'review_request' ? (
|
<Tooltip>
|
||||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
<TooltipTrigger asChild>
|
||||||
<Eye size={10} />
|
<button
|
||||||
Review requested
|
type="button"
|
||||||
</span>
|
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||||
) : null}
|
onClick={() => {
|
||||||
<span>
|
const replyText = stripAgentBlocks(
|
||||||
{(() => {
|
parseMessageReply(comment.text)?.replyText ?? comment.text
|
||||||
const date = new Date(comment.createdAt);
|
);
|
||||||
return isNaN(date.getTime())
|
if (onReply) {
|
||||||
? 'unknown time'
|
onReply(comment.author, replyText);
|
||||||
: formatDistanceToNow(date, { addSuffix: true });
|
} else {
|
||||||
})()}
|
setReplyTo({ author: comment.author, text: replyText });
|
||||||
</span>
|
}
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
|
||||||
onClick={() => {
|
|
||||||
const replyText = stripAgentBlocks(
|
|
||||||
parseMessageReply(comment.text)?.replyText ?? comment.text
|
|
||||||
);
|
|
||||||
if (onReply) {
|
|
||||||
onReply(comment.author, replyText);
|
|
||||||
} else {
|
|
||||||
setReplyTo({ author: comment.author, text: replyText });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Reply size={11} />
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const reply = parseMessageReply(comment.text);
|
|
||||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
|
||||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
|
||||||
return (
|
|
||||||
<ExpandableContent collapsedHeight={120} className="text-xs">
|
|
||||||
{reply ? (
|
|
||||||
<ReplyQuoteBlock
|
|
||||||
reply={{
|
|
||||||
...reply,
|
|
||||||
originalText: stripAgentBlocks(reply.originalText),
|
|
||||||
replyText: stripAgentBlocks(reply.replyText),
|
|
||||||
}}
|
}}
|
||||||
memberColor={colorMap.get(reply.agentName)}
|
|
||||||
bodyMaxHeight="max-h-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
onClickCapture={
|
|
||||||
onTaskIdClick
|
|
||||||
? (e) => {
|
|
||||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
|
||||||
'a[href^="task://"]'
|
|
||||||
);
|
|
||||||
if (link) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const id = link.getAttribute('href')?.replace('task://', '');
|
|
||||||
if (id) onTaskIdClick(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MarkdownViewer
|
<Reply size={11} />
|
||||||
content={(() => {
|
Reply
|
||||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
</button>
|
||||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
</TooltipTrigger>
|
||||||
return t;
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||||
})()}
|
</Tooltip>
|
||||||
maxHeight="max-h-none"
|
</div>
|
||||||
bare
|
{(() => {
|
||||||
|
const reply = parseMessageReply(comment.text);
|
||||||
|
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||||
|
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||||
|
return (
|
||||||
|
<ExpandableContent collapsedHeight={120} className="text-xs">
|
||||||
|
{reply ? (
|
||||||
|
<ReplyQuoteBlock
|
||||||
|
reply={{
|
||||||
|
...reply,
|
||||||
|
originalText: stripAgentBlocks(reply.originalText),
|
||||||
|
replyText: stripAgentBlocks(reply.replyText),
|
||||||
|
}}
|
||||||
|
memberColor={colorMap.get(reply.agentName)}
|
||||||
|
bodyMaxHeight="max-h-none"
|
||||||
/>
|
/>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<span
|
||||||
</ExpandableContent>
|
onClickCapture={
|
||||||
);
|
onTaskIdClick
|
||||||
})()}
|
? (e) => {
|
||||||
{comment.attachments && comment.attachments.length > 0 ? (
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||||
<CommentAttachments
|
'a[href^="task://"]'
|
||||||
attachments={comment.attachments}
|
);
|
||||||
teamName={teamName}
|
if (link) {
|
||||||
taskId={taskId}
|
e.preventDefault();
|
||||||
onPreview={setPreviewImageUrl}
|
e.stopPropagation();
|
||||||
/>
|
const id = link.getAttribute('href')?.replace('task://', '');
|
||||||
) : null}
|
if (id) onTaskIdClick(id);
|
||||||
</div>
|
}
|
||||||
))}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MarkdownViewer
|
||||||
|
content={(() => {
|
||||||
|
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||||
|
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||||
|
return t;
|
||||||
|
})()}
|
||||||
|
maxHeight="max-h-none"
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ExpandableContent>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{comment.attachments && comment.attachments.length > 0 ? (
|
||||||
|
<CommentAttachments
|
||||||
|
attachments={comment.attachments}
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={taskId}
|
||||||
|
onPreview={setPreviewImageUrl}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedComments.length > visibleComments.length ? (
|
{sortedComments.length > visibleComments.length ? (
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
|
||||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -16,7 +14,9 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { Textarea } from '@renderer/components/ui/textarea';
|
import { Textarea } from '@renderer/components/ui/textarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export const EditorImagePreview = ({
|
||||||
|
|
||||||
// Reset state when filePath changes
|
// Reset state when filePath changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const QuickOpenDialog = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
window.electronAPI.editor
|
window.electronAPI.editor
|
||||||
.listFiles()
|
.listFiles()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
|
// import { useStore } from '@renderer/store'; // NOTE: disabled — lead context display
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
@ -40,7 +40,7 @@ export const MemberCard = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
// TODO: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
|
@ -184,7 +184,7 @@ export const MemberCard = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* TODO: lead context bar disabled — usage formula is inaccurate */}
|
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
||||||
</div>
|
</div>
|
||||||
{!isRemoved && (
|
{!isRemoved && (
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display
|
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { Pencil } from 'lucide-react';
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
@ -31,7 +30,7 @@ export const MemberDetailHeader = ({
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
// TODO: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
|
@ -102,7 +101,7 @@ export const MemberDetailHeader = ({
|
||||||
>
|
>
|
||||||
{presenceLabel}
|
{presenceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* TODO: lead context token display disabled — usage formula is inaccurate */}
|
{/* NOTE: lead context token display disabled — usage formula is inaccurate */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
|
|
@ -144,7 +144,7 @@ export const MemberDraftRow = ({
|
||||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||||
triggerClassName="h-8 text-xs"
|
triggerClassName="h-8 text-xs"
|
||||||
inputClassName="h-8 text-xs"
|
inputClassName="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
{showWorkflow && onWorkflowChange ? (
|
{showWorkflow && onWorkflowChange ? (
|
||||||
|
|
|
||||||
|
|
@ -152,14 +152,17 @@ export const MemberLogsTab = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
||||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||||
|
|
||||||
const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
const fetchDetailForLog = useCallback(
|
||||||
if (log.kind === 'subagent') {
|
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
if (log.kind === 'subagent') {
|
||||||
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||||
}
|
return d?.chunks ?? null;
|
||||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
}
|
||||||
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||||
}, []);
|
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||||
|
|
@ -250,12 +253,8 @@ export const MemberLogsTab = ({
|
||||||
key={getRowId(log)}
|
key={getRowId(log)}
|
||||||
log={log}
|
log={log}
|
||||||
expanded={expandedId === getRowId(log)}
|
expanded={expandedId === getRowId(log)}
|
||||||
detailChunks={
|
detailChunks={expandedId === getRowId(log) ? detailChunks : null}
|
||||||
expandedId === getRowId(log) ? detailChunks : null
|
detailLoading={expandedId === getRowId(log) && detailLoading}
|
||||||
}
|
|
||||||
detailLoading={
|
|
||||||
expandedId === getRowId(log) && detailLoading
|
|
||||||
}
|
|
||||||
onToggle={() => void handleExpand(log)}
|
onToggle={() => void handleExpand(log)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
|
||||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { Check, Loader2, X } from 'lucide-react';
|
import { Check, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ interface MessageComposerProps {
|
||||||
const MAX_MESSAGE_LENGTH = 4000;
|
const MAX_MESSAGE_LENGTH = 4000;
|
||||||
|
|
||||||
/** Circular progress indicator for lead context usage. */
|
/** Circular progress indicator for lead context usage. */
|
||||||
const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
||||||
const size = 26;
|
const size = 26;
|
||||||
const stroke = 2.5;
|
const stroke = 2.5;
|
||||||
const radius = (size - stroke) / 2;
|
const radius = (size - stroke) / 2;
|
||||||
|
|
@ -49,7 +49,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="relative flex shrink-0 cursor-default items-center justify-center" style={{ width: size, height: size }}>
|
<div
|
||||||
|
className="relative flex shrink-0 cursor-default items-center justify-center"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
<svg width={size} height={size} className="-rotate-90">
|
<svg width={size} height={size} className="-rotate-90">
|
||||||
<circle
|
<circle
|
||||||
cx={size / 2}
|
cx={size / 2}
|
||||||
|
|
@ -148,7 +151,7 @@ export const MessageComposer = ({
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
// TODO: lead context ring disabled — usage formula is inaccurate
|
// NOTE: lead context ring disabled — usage formula is inaccurate
|
||||||
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
|
|
@ -290,7 +293,10 @@ export const MessageComposer = ({
|
||||||
>
|
>
|
||||||
{members.length > 5 && (
|
{members.length > 5 && (
|
||||||
<div className="relative mb-1">
|
<div className="relative mb-1">
|
||||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]" />
|
<Search
|
||||||
|
size={12}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
ref={recipientSearchRef}
|
ref={recipientSearchRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -302,6 +308,7 @@ export const MessageComposer = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
|
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const query = recipientSearch.toLowerCase().trim();
|
const query = recipientSearch.toLowerCase().trim();
|
||||||
const filtered = query
|
const filtered = query
|
||||||
|
|
@ -392,7 +399,9 @@ export const MessageComposer = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isTeamAlive ? (
|
{!isTeamAlive ? (
|
||||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>Team offline</span>
|
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||||
|
Team offline
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -421,7 +430,7 @@ export const MessageComposer = ({
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* TODO: ContextRing disabled — usage formula is inaccurate */}
|
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ export const MessagesFilterPopover = ({
|
||||||
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
||||||
) : (
|
) : (
|
||||||
fromOptions.map((name) => (
|
fromOptions.map((name) => (
|
||||||
|
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
|
||||||
<label
|
<label
|
||||||
key={name}
|
key={name}
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
|
@ -145,7 +146,12 @@ export const MessagesFilterPopover = ({
|
||||||
checked={draft.from.has(name)}
|
checked={draft.from.has(name)}
|
||||||
onCheckedChange={() => toggleFrom(name)}
|
onCheckedChange={() => toggleFrom(name)}
|
||||||
/>
|
/>
|
||||||
<MemberBadge name={name} color={colorMap.get(name)} size="sm" hideAvatar={name === 'user'} />
|
<MemberBadge
|
||||||
|
name={name}
|
||||||
|
color={colorMap.get(name)}
|
||||||
|
size="sm"
|
||||||
|
hideAvatar={name === 'user'}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
@ -160,18 +166,25 @@ export const MessagesFilterPopover = ({
|
||||||
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
||||||
) : (
|
) : (
|
||||||
toOptions.map((name) => (
|
toOptions.map((name) => (
|
||||||
|
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
|
||||||
<label
|
<label
|
||||||
key={name}
|
key={name}
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
|
||||||
>
|
>
|
||||||
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
|
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
|
||||||
<MemberBadge name={name} color={colorMap.get(name)} size="sm" hideAvatar={name === 'user'} />
|
<MemberBadge
|
||||||
|
name={name}
|
||||||
|
color={colorMap.get(name)}
|
||||||
|
size="sm"
|
||||||
|
hideAvatar={name === 'user'}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b border-[var(--color-border)] p-3">
|
<div className="border-b border-[var(--color-border)] p-3">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */}
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={draft.showNoise}
|
checked={draft.showNoise}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export const ExpandableContent = ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Re-measure when children identity changes (content prop in callers)
|
// Re-measure when children identity changes (content prop in callers)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- children identity triggers re-measure
|
||||||
[children, collapsedHeight]
|
[children, collapsedHeight]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,10 +59,8 @@ export const ExpandableContent = ({
|
||||||
? {
|
? {
|
||||||
maxHeight: collapsedHeight,
|
maxHeight: collapsedHeight,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
WebkitMaskImage:
|
WebkitMaskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||||
'linear-gradient(to bottom, black 60%, transparent 100%)',
|
maskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||||
maskImage:
|
|
||||||
'linear-gradient(to bottom, black 60%, transparent 100%)',
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const MemberSelect = ({
|
||||||
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const selectedMember = React.useMemo(
|
const selectedMember = React.useMemo(
|
||||||
() => (value ? members.find((m) => m.name === value) : null),
|
() => (value ? members.find((m) => m.name === value) : null),
|
||||||
[members, value],
|
[members, value]
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarSize = size === 'md' ? 32 : 24;
|
const avatarSize = size === 'md' ? 32 : 24;
|
||||||
|
|
@ -51,6 +51,7 @@ export const MemberSelect = ({
|
||||||
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
|
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
|
||||||
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
|
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
|
||||||
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
|
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
|
||||||
const resolvedColor = colorMap.get(member.name);
|
const resolvedColor = colorMap.get(member.name);
|
||||||
const colors = getTeamColorSet(resolvedColor ?? '');
|
const colors = getTeamColorSet(resolvedColor ?? '');
|
||||||
|
|
@ -87,7 +88,7 @@ export const MemberSelect = ({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
|
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate text-left">
|
<span className="min-w-0 truncate text-left">
|
||||||
|
|
@ -178,10 +179,7 @@ export const MemberSelect = ({
|
||||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="min-w-0 truncate font-medium" style={{ color: colors.text }}>
|
||||||
className="min-w-0 truncate font-medium"
|
|
||||||
style={{ color: colors.text }}
|
|
||||||
>
|
|
||||||
{m.name === 'team-lead' ? 'lead' : m.name}
|
{m.name === 'team-lead' ? 'lead' : m.name}
|
||||||
</span>
|
</span>
|
||||||
{role ? (
|
{role ? (
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingRef = useRef<{ key: string; value: AttachmentPayload[] } | null>(null);
|
const pendingRef = useRef<{ key: string; value: AttachmentPayload[] } | null>(null);
|
||||||
const keyRef = useRef(persistenceKey);
|
const keyRef = useRef(persistenceKey);
|
||||||
keyRef.current = persistenceKey;
|
useEffect(() => {
|
||||||
|
keyRef.current = persistenceKey;
|
||||||
|
}, [persistenceKey]);
|
||||||
|
|
||||||
// Sync ref with state
|
// Sync ref with state
|
||||||
const updateAttachments = useCallback((next: AttachmentPayload[]) => {
|
const updateAttachments = useCallback((next: AttachmentPayload[]) => {
|
||||||
|
|
@ -112,6 +114,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
flushPending();
|
flushPending();
|
||||||
// Clear stale attachments from previous persistenceKey before loading
|
// Clear stale attachments from previous persistenceKey before loading
|
||||||
attachmentsRef.current = [];
|
attachmentsRef.current = [];
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const raw = await draftStorage.loadDraft(persistenceKey);
|
const raw = await draftStorage.loadDraft(persistenceKey);
|
||||||
|
|
@ -195,7 +198,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
schedulePersist(next);
|
schedulePersist(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable
|
|
||||||
},
|
},
|
||||||
[schedulePersist]
|
[schedulePersist]
|
||||||
);
|
);
|
||||||
|
|
@ -209,7 +211,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable
|
|
||||||
},
|
},
|
||||||
[schedulePersist]
|
[schedulePersist]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,10 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingRef = useRef<{ key: string; value: InlineChip[] } | null>(null);
|
const pendingRef = useRef<{ key: string; value: InlineChip[] } | null>(null);
|
||||||
const keyRef = useRef(key);
|
const keyRef = useRef(key);
|
||||||
keyRef.current = key;
|
|
||||||
|
useEffect(() => {
|
||||||
|
keyRef.current = key;
|
||||||
|
}, [key]);
|
||||||
// Ref for current chips — allows addChip/removeChip to read latest value
|
// Ref for current chips — allows addChip/removeChip to read latest value
|
||||||
// without stale closures, using the same sync-ref pattern as keyRef.
|
// without stale closures, using the same sync-ref pattern as keyRef.
|
||||||
const chipsRef = useRef<InlineChip[]>([]);
|
const chipsRef = useRef<InlineChip[]>([]);
|
||||||
|
|
@ -83,7 +86,9 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult {
|
||||||
// Flush any pending debounced save for the previous key and reset local state for the new key.
|
// Flush any pending debounced save for the previous key and reset local state for the new key.
|
||||||
flushPending();
|
flushPending();
|
||||||
chipsRef.current = [];
|
chipsRef.current = [];
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on key change before async load
|
||||||
setChipsState([]);
|
setChipsState([]);
|
||||||
|
|
||||||
setIsSaved(false);
|
setIsSaved(false);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const raw = await draftStorage.loadDraft(key);
|
const raw = await draftStorage.loadDraft(key);
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,12 @@ export function useDraftPersistence({
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingValueRef = useRef<{ key: string; value: string } | null>(null);
|
const pendingValueRef = useRef<{ key: string; value: string } | null>(null);
|
||||||
const keyRef = useRef(key);
|
const keyRef = useRef(key);
|
||||||
keyRef.current = key;
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
keyRef.current = key;
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -59,12 +62,15 @@ export function useDraftPersistence({
|
||||||
// Prevent debounced saves for the previous key from landing under the new key.
|
// Prevent debounced saves for the previous key from landing under the new key.
|
||||||
flushPending();
|
flushPending();
|
||||||
// Reset local state for the new key immediately. If a draft exists, it will overwrite below.
|
// Reset local state for the new key immediately. If a draft exists, it will overwrite below.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on key change before async load
|
||||||
setValueState(initialValue ?? '');
|
setValueState(initialValue ?? '');
|
||||||
|
|
||||||
setIsSaved(false);
|
setIsSaved(false);
|
||||||
|
|
||||||
if (!enabled) return () => {
|
if (!enabled)
|
||||||
cancelled = true;
|
return () => {
|
||||||
};
|
cancelled = true;
|
||||||
|
};
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const draft = await draftStorage.loadDraft(key);
|
const draft = await draftStorage.loadDraft(key);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export function useFileSuggestions(
|
||||||
// Re-seed from cache when projectPath changes
|
// Re-seed from cache when projectPath changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync with prop change
|
||||||
setAllFiles([]);
|
setAllFiles([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +92,7 @@ export function useFileSuggestions(
|
||||||
const prevEnabledRef = useRef(enabled);
|
const prevEnabledRef = useRef(enabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
|
if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional trigger on state transition
|
||||||
setFetchTrigger((n) => n + 1);
|
setFetchTrigger((n) => n + 1);
|
||||||
}
|
}
|
||||||
prevEnabledRef.current = enabled;
|
prevEnabledRef.current = enabled;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export function useResizableColumns({
|
||||||
startX: number;
|
startX: number;
|
||||||
startWidth: number;
|
startWidth: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const widths = new Map<string, number>();
|
const widths = new Map<string, number>();
|
||||||
for (const id of columnIds) {
|
for (const id of columnIds) {
|
||||||
|
|
@ -84,8 +85,8 @@ export function useResizableColumns({
|
||||||
const drag = draggingRef.current;
|
const drag = draggingRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
draggingRef.current = null;
|
draggingRef.current = null;
|
||||||
document.removeEventListener('pointermove', handlePointerMove);
|
abortRef.current?.abort();
|
||||||
document.removeEventListener('pointerup', handlePointerUp);
|
abortRef.current = null;
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
// Persist
|
// Persist
|
||||||
|
|
@ -93,18 +94,18 @@ export function useResizableColumns({
|
||||||
saveWidths(storageKey, current);
|
saveWidths(storageKey, current);
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
}, [handlePointerMove, storageKey]);
|
}, [storageKey]);
|
||||||
|
|
||||||
// Safety: if the board unmounts or storageKey changes mid-drag, clean up global listeners/styles.
|
// Safety: if the board unmounts or storageKey changes mid-drag, clean up global listeners/styles.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
draggingRef.current = null;
|
draggingRef.current = null;
|
||||||
document.removeEventListener('pointermove', handlePointerMove);
|
abortRef.current?.abort();
|
||||||
document.removeEventListener('pointerup', handlePointerUp);
|
abortRef.current = null;
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
};
|
};
|
||||||
}, [handlePointerMove, handlePointerUp]);
|
}, []);
|
||||||
|
|
||||||
const getHandleProps = useCallback(
|
const getHandleProps = useCallback(
|
||||||
(leftColumnId: string) => ({
|
(leftColumnId: string) => ({
|
||||||
|
|
@ -116,8 +117,11 @@ export function useResizableColumns({
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startWidth: currentWidth,
|
startWidth: currentWidth,
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', handlePointerMove);
|
abortRef.current?.abort();
|
||||||
document.addEventListener('pointerup', handlePointerUp);
|
const ac = new AbortController();
|
||||||
|
abortRef.current = ac;
|
||||||
|
document.addEventListener('pointermove', handlePointerMove, { signal: ac.signal });
|
||||||
|
document.addEventListener('pointerup', handlePointerUp, { signal: ac.signal });
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = 'col-resize';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -370,9 +370,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
// Clear context data when lead goes offline
|
// Clear context data when lead goes offline
|
||||||
if (nextActivity === 'offline') {
|
if (nextActivity === 'offline') {
|
||||||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||||
delete (nextState.leadContextByTeam as Record<string, LeadContextUsage>)[
|
delete nextState.leadContextByTeam[event.teamName];
|
||||||
event.teamName
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextState as typeof prev;
|
return nextState as typeof prev;
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import type { AppState } from '../types';
|
||||||
import type { AppConfig } from '@renderer/types/data';
|
import type { AppConfig } from '@renderer/types/data';
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
|
CommentAttachmentPayload,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
|
|
@ -294,7 +295,7 @@ export interface TeamSlice {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
text: string,
|
text: string,
|
||||||
attachments?: import('@shared/types').CommentAttachmentPayload[]
|
attachments?: CommentAttachmentPayload[]
|
||||||
) => Promise<TaskComment>;
|
) => Promise<TaskComment>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function decodeReplyField(value: string): string {
|
||||||
* Returns null if no reply block is found.
|
* Returns null if no reply block is found.
|
||||||
*/
|
*/
|
||||||
export function parseMessageReply(content: string): ParsedMessageReply | null {
|
export function parseMessageReply(content: string): ParsedMessageReply | null {
|
||||||
const match = content.match(REPLY_BLOCK_RE);
|
const match = REPLY_BLOCK_RE.exec(content);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return {
|
return {
|
||||||
agentName: match[1],
|
agentName: match[1],
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
ts = new Date();
|
ts = new Date();
|
||||||
if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) {
|
if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) {
|
||||||
// Evict oldest entry (first inserted)
|
// Evict oldest entry (first inserted)
|
||||||
const firstKey = lineTimestampCache.keys().next().value as string;
|
const firstKey = lineTimestampCache.keys().next().value!;
|
||||||
lineTimestampCache.delete(firstKey);
|
lineTimestampCache.delete(firstKey);
|
||||||
}
|
}
|
||||||
lineTimestampCache.set(trimmed, ts);
|
lineTimestampCache.set(trimmed, ts);
|
||||||
|
|
@ -246,15 +246,14 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[]
|
||||||
if (msgId) {
|
if (msgId) {
|
||||||
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
|
const occurrence = msgIdOccurrences.get(msgId) ?? 0;
|
||||||
msgIdOccurrences.set(msgId, occurrence + 1);
|
msgIdOccurrences.set(msgId, occurrence + 1);
|
||||||
currentGroupId = occurrence === 0
|
currentGroupId =
|
||||||
? `stream-group-${msgId}`
|
occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`;
|
||||||
: `stream-group-${msgId}-${occurrence}`;
|
|
||||||
} else {
|
} else {
|
||||||
currentGroupId = `stream-group-L${lineIndex}`;
|
currentGroupId = `stream-group-L${lineIndex}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = contentBlocksToDisplayItems(blocks, currentTimestamp!, lineIndex);
|
const items = contentBlocksToDisplayItems(blocks, currentTimestamp, lineIndex);
|
||||||
currentItems.push(...items);
|
currentItems.push(...items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import type {
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
AttachmentMediaType,
|
|
||||||
CommentAttachmentPayload,
|
CommentAttachmentPayload,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
|
|
@ -45,6 +44,8 @@ import type {
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
TeamClaudeLogsQuery,
|
||||||
|
TeamClaudeLogsResponse,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
|
|
@ -398,10 +399,7 @@ export interface HttpServerAPI {
|
||||||
export interface TeamsAPI {
|
export interface TeamsAPI {
|
||||||
list: () => Promise<TeamSummary[]>;
|
list: () => Promise<TeamSummary[]>;
|
||||||
getData: (teamName: string) => Promise<TeamData>;
|
getData: (teamName: string) => Promise<TeamData>;
|
||||||
getClaudeLogs: (
|
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
||||||
teamName: string,
|
|
||||||
query?: import('./team').TeamClaudeLogsQuery
|
|
||||||
) => Promise<import('./team').TeamClaudeLogsResponse>;
|
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
restoreTeam: (teamName: string) => Promise<void>;
|
restoreTeam: (teamName: string) => Promise<void>;
|
||||||
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ export interface CommentAttachmentPayload {
|
||||||
* Note: the UI may still choose to preview only certain types (e.g. images),
|
* Note: the UI may still choose to preview only certain types (e.g. images),
|
||||||
* but tasks/comments can store arbitrary attachments for agent workflows.
|
* but tasks/comments can store arbitrary attachments for agent workflows.
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line sonarjs/redundant-type-aliases -- semantic alias for documentation/readability
|
||||||
export type AttachmentMediaType = string;
|
export type AttachmentMediaType = string;
|
||||||
|
|
||||||
/** Supported image MIME types (used for preview/validation in UI). */
|
/** Supported image MIME types (used for preview/validation in UI). */
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
export function parseNumericSuffixName(
|
export function parseNumericSuffixName(name: string): { base: string; suffix: number } | null {
|
||||||
name: string
|
|
||||||
): { base: string; suffix: number } | null {
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
const match = trimmed.match(/^(.+)-(\d+)$/);
|
const match = /^(.+)-(\d+)$/.exec(trimmed);
|
||||||
if (!match?.[1] || !match[2]) return null;
|
if (!match?.[1] || !match[2]) return null;
|
||||||
const suffix = Number(match[2]);
|
const suffix = Number(match[2]);
|
||||||
if (!Number.isFinite(suffix)) return null;
|
if (!Number.isFinite(suffix)) return null;
|
||||||
|
|
@ -17,7 +15,9 @@ export function parseNumericSuffixName(
|
||||||
*
|
*
|
||||||
* Important: do NOT treat "-1" as auto-suffix; it's commonly intentional ("dev-1").
|
* Important: do NOT treat "-1" as auto-suffix; it's commonly intentional ("dev-1").
|
||||||
*/
|
*/
|
||||||
export function createCliAutoSuffixNameGuard(allNames: Iterable<string>): (name: string) => boolean {
|
export function createCliAutoSuffixNameGuard(
|
||||||
|
allNames: Iterable<string>
|
||||||
|
): (name: string) => boolean {
|
||||||
const trimmed: string[] = [];
|
const trimmed: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const n of allNames) {
|
for (const n of allNames) {
|
||||||
|
|
@ -38,4 +38,3 @@ export function createCliAutoSuffixNameGuard(allNames: Iterable<string>): (name:
|
||||||
return !allLower.has(info.base.toLowerCase());
|
return !allLower.has(info.base.toLowerCase());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue