commit
ea6fefe7cf
123 changed files with 7926 additions and 2454 deletions
|
|
@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our
|
|||
|
||||
## Approach 1: Filesystem + SQLite Metadata (Recommended)
|
||||
|
||||
**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table.
|
||||
**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table.
|
||||
|
||||
### Pros
|
||||
- Best I/O performance — direct filesystem reads, no serialization overhead.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "claude-agent-teams-ui",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
|
|
@ -80,6 +80,7 @@
|
|||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/merge": "^6.12.0",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
|
|
@ -134,6 +135,7 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"yet-another-react-lightbox": "^3.29.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ importers:
|
|||
'@codemirror/language-data':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/lint':
|
||||
specifier: ^6.9.5
|
||||
version: 6.9.5
|
||||
'@codemirror/merge':
|
||||
specifier: ^6.12.0
|
||||
version: 6.12.0
|
||||
|
|
@ -230,6 +233,9 @@ importers:
|
|||
unified:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
yet-another-react-lightbox:
|
||||
specifier: ^3.29.1
|
||||
version: 3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
zustand:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
|
||||
|
|
@ -573,8 +579,8 @@ packages:
|
|||
'@codemirror/legacy-modes@6.5.2':
|
||||
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
|
||||
|
||||
'@codemirror/lint@6.9.4':
|
||||
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==}
|
||||
'@codemirror/lint@6.9.5':
|
||||
resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
|
||||
|
||||
'@codemirror/merge@6.12.0':
|
||||
resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
|
||||
|
|
@ -6164,6 +6170,20 @@ packages:
|
|||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yet-another-react-lightbox@3.29.1:
|
||||
resolution: {integrity: sha512-0cpa+wlleiy2cWNjS9qrcY0+SgZQH/4PDx2uupLMI9Ofip1f7pCgZ95PlVp/EsFsO4ufwOTea51bkLhcEXJJSg==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
'@types/react': ^16 || ^17 || ^18 || ^19
|
||||
'@types/react-dom': ^16 || ^17 || ^18 || ^19
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
react-dom: ^16.8.0 || ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -6432,7 +6452,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.4
|
||||
'@codemirror/lint': 6.9.5
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
|
@ -6592,7 +6612,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
|
||||
'@codemirror/lint@6.9.4':
|
||||
'@codemirror/lint@6.9.5':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
|
|
@ -13183,6 +13203,14 @@ snapshots:
|
|||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yet-another-react-lightbox@3.29.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zip-stream@4.1.1:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
getTrafficLightPositionForZoom,
|
||||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||
} from '@shared/constants';
|
||||
import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
|
|
@ -132,42 +133,6 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
|||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox message types that are internal coordination noise — not useful as OS notifications.
|
||||
* Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts.
|
||||
*/
|
||||
const INBOX_NOISE_TYPES = new Set([
|
||||
'idle_notification',
|
||||
'shutdown_approved',
|
||||
'teammate_terminated',
|
||||
'shutdown_request',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parses an inbox message text that may be serialized JSON.
|
||||
* Returns null if not valid JSON or not an object.
|
||||
*/
|
||||
function parseInboxJson(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// not JSON — plain text message
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */
|
||||
function isInboxNoiseMessage(text: string): boolean {
|
||||
const parsed = parseInboxJson(text);
|
||||
if (!parsed) return false;
|
||||
return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts human-readable summary and body from an inbox message.
|
||||
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}).
|
||||
|
|
@ -448,11 +413,22 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
void teamProvisioningService
|
||||
.relayLeadInboxMessages(teamName)
|
||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
||||
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
||||
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
||||
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
if (match && teamDataService) {
|
||||
const inboxName = match[1];
|
||||
void teamDataService
|
||||
.getLeadMemberName(teamName)
|
||||
.then((leadName) => {
|
||||
if (!leadName || inboxName !== leadName) return;
|
||||
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||
|
|
@ -490,7 +466,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
void teamDataService
|
||||
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||
.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 {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,18 @@ import {
|
|||
TEAM_CREATE,
|
||||
TEAM_CREATE_CONFIG,
|
||||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_GET_TASK_ATTACHMENT,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -37,6 +40,7 @@ import {
|
|||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
|
|
@ -50,11 +54,9 @@ import {
|
|||
TEAM_UPDATE_TASK_FIELDS,
|
||||
TEAM_UPDATE_TASK_OWNER,
|
||||
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
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
|
|
@ -89,7 +91,6 @@ import type {
|
|||
} from '../services';
|
||||
import type {
|
||||
AttachmentFileData,
|
||||
AttachmentMediaType,
|
||||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
|
|
@ -103,6 +104,8 @@ import type {
|
|||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
|
|
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
|
|||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(TEAM_LIST, handleListTeams);
|
||||
ipcMain.handle(TEAM_GET_DATA, handleGetData);
|
||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
|
||||
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
|
||||
|
|
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(TEAM_LIST);
|
||||
ipcMain.removeHandler(TEAM_GET_DATA);
|
||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_CREATE);
|
||||
ipcMain.removeHandler(TEAM_LAUNCH);
|
||||
|
|
@ -387,12 +392,6 @@ async function handleGetData(
|
|||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
if (isAlive) {
|
||||
void provisioning
|
||||
.relayLeadInboxMessages(tn)
|
||||
.catch((e: unknown) => logger.warn(`Relay failed for ${tn}: ${e}`));
|
||||
}
|
||||
|
||||
const displayName = data.config.name || tn;
|
||||
const projectPath = data.config.projectPath;
|
||||
|
||||
|
|
@ -634,6 +633,39 @@ async function validateProvisioningRequest(
|
|||
};
|
||||
}
|
||||
|
||||
async function handleGetClaudeLogs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
query?: unknown
|
||||
): Promise<IpcResult<TeamClaudeLogsResponse>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
let parsed: TeamClaudeLogsQuery | undefined;
|
||||
if (query !== undefined) {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return { success: false, error: 'query must be an object' };
|
||||
}
|
||||
const q = query as Record<string, unknown>;
|
||||
parsed = {
|
||||
offset: typeof q.offset === 'number' ? q.offset : undefined,
|
||||
limit: typeof q.limit === 'number' ? q.limit : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return wrapTeamHandler('getClaudeLogs', async () => {
|
||||
const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
|
||||
return {
|
||||
lines: data.lines,
|
||||
total: data.total,
|
||||
hasMore: data.hasMore,
|
||||
updatedAt: data.updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateTeam(
|
||||
event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
|
|
@ -937,6 +969,13 @@ async function handleSendMessage(
|
|||
}
|
||||
|
||||
if (stdinSent) {
|
||||
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
// Persistence is best-effort — stdin already delivered the message
|
||||
let result: SendMessageResult;
|
||||
try {
|
||||
|
|
@ -944,20 +983,14 @@ async function handleSendMessage(
|
|||
tn,
|
||||
leadName,
|
||||
payload.text!,
|
||||
payload.summary
|
||||
payload.summary,
|
||||
attachmentMeta
|
||||
);
|
||||
} catch (persistError) {
|
||||
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
|
||||
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` };
|
||||
}
|
||||
|
||||
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
// Save attachment binary data to disk (best-effort)
|
||||
if (validatedAttachments?.length && result.messageId) {
|
||||
void attachmentStore
|
||||
|
|
@ -982,18 +1015,42 @@ async function handleSendMessage(
|
|||
}
|
||||
|
||||
// Inbox path: offline lead or regular members (no attachment support)
|
||||
const baseText = payload.text!.trim();
|
||||
const memberDeliveryText = isLeadRecipient
|
||||
? baseText
|
||||
: [
|
||||
baseText,
|
||||
'',
|
||||
AGENT_BLOCK_OPEN,
|
||||
'You received a direct message from the human user via the UI.',
|
||||
'Please reply back to recipient "user" with a short, human-readable answer.',
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
AGENT_BLOCK_CLOSE,
|
||||
].join('\n');
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: memberName,
|
||||
text: payload.text!,
|
||||
text: memberDeliveryText,
|
||||
summary: payload.summary,
|
||||
from: payload.from,
|
||||
});
|
||||
|
||||
// Best-effort: if team is alive and recipient is a teammate (not lead),
|
||||
// also forward via the live lead process so in-process teammates receive it.
|
||||
if (!isLeadRecipient && isAlive) {
|
||||
try {
|
||||
await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary);
|
||||
} catch (e: unknown) {
|
||||
logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort relay for lead via inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
void provisioning
|
||||
.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;
|
||||
|
|
@ -1982,7 +2039,7 @@ async function handleAddTaskComment(
|
|||
vTask.value!,
|
||||
safeId,
|
||||
a.filename,
|
||||
a.mimeType as AttachmentMediaType,
|
||||
a.mimeType,
|
||||
a.base64Data
|
||||
);
|
||||
savedAttachments.push(meta);
|
||||
|
|
@ -2104,9 +2161,9 @@ async function handleSaveTaskAttachment(
|
|||
vTeam.value!,
|
||||
vTask.value!,
|
||||
safeAttId,
|
||||
filename as string,
|
||||
mimeType as AttachmentMediaType,
|
||||
base64Data as string
|
||||
filename,
|
||||
mimeType,
|
||||
base64Data
|
||||
);
|
||||
// Write metadata into the task JSON
|
||||
await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
|
||||
|
|
@ -2137,12 +2194,7 @@ async function handleGetTaskAttachment(
|
|||
}
|
||||
|
||||
return wrapTeamHandler('getTaskAttachment', () =>
|
||||
taskAttachmentStore.getAttachment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
safeAttId,
|
||||
mimeType as AttachmentMediaType
|
||||
)
|
||||
taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2169,12 +2221,7 @@ async function handleDeleteTaskAttachment(
|
|||
}
|
||||
|
||||
return wrapTeamHandler('deleteTaskAttachment', async () => {
|
||||
await taskAttachmentStore.deleteAttachment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
safeAttId,
|
||||
mimeType as AttachmentMediaType
|
||||
);
|
||||
await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType);
|
||||
// Remove metadata from task JSON
|
||||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export class SubagentResolver {
|
|||
if (!firstUserMessage) return undefined;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ export class FileWatcher extends EventEmitter {
|
|||
private pendingReprocess = new Set<string>();
|
||||
/** Flag to prevent reuse after disposal */
|
||||
private disposed = false;
|
||||
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */
|
||||
private readonly instanceCreatedAt = Date.now();
|
||||
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files).
|
||||
* 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(
|
||||
dataCache: DataCache,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export class UpdaterService {
|
|||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('Check for updates failed:', getErrorMessage(error));
|
||||
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ export class UpdaterService {
|
|||
await autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.error('Download update failed:', getErrorMessage(error));
|
||||
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -449,16 +449,16 @@ export class ChangeExtractorService {
|
|||
const isError = erroredIds.has(toolUseId);
|
||||
|
||||
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 newString = typeof input.new_string === 'string' ? input.new_string : '';
|
||||
const replaceAll = input.replace_all === true;
|
||||
|
||||
if (path) {
|
||||
seenFiles.add(path);
|
||||
if (targetPath) {
|
||||
seenFiles.add(targetPath);
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: path,
|
||||
filePath: targetPath,
|
||||
toolName: 'Edit',
|
||||
type: 'edit',
|
||||
oldString,
|
||||
|
|
@ -470,15 +470,15 @@ export class ChangeExtractorService {
|
|||
});
|
||||
}
|
||||
} 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 : '';
|
||||
|
||||
if (path) {
|
||||
const isNew = !seenFiles.has(path);
|
||||
seenFiles.add(path);
|
||||
if (targetPath) {
|
||||
const isNew = !seenFiles.has(targetPath);
|
||||
seenFiles.add(targetPath);
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: path,
|
||||
filePath: targetPath,
|
||||
toolName: 'Write',
|
||||
type: isNew ? 'write-new' : 'write-update',
|
||||
oldString: '',
|
||||
|
|
@ -490,11 +490,11 @@ export class ChangeExtractorService {
|
|||
});
|
||||
}
|
||||
} 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 : [];
|
||||
|
||||
if (path) {
|
||||
seenFiles.add(path);
|
||||
if (targetPath) {
|
||||
seenFiles.add(targetPath);
|
||||
for (const edit of edits) {
|
||||
if (!edit || typeof edit !== 'object') continue;
|
||||
const editObj = edit as Record<string, unknown>;
|
||||
|
|
@ -502,7 +502,7 @@ export class ChangeExtractorService {
|
|||
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
||||
snippets.push({
|
||||
toolUseId,
|
||||
filePath: path,
|
||||
filePath: targetPath,
|
||||
toolName: 'MultiEdit',
|
||||
type: 'multi-edit',
|
||||
oldString,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ function nowIso() {
|
|||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeId() {
|
||||
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
|
||||
}
|
||||
|
||||
function formatError(err) {
|
||||
if (!err) return 'Unknown error';
|
||||
if (typeof err === 'string') return err;
|
||||
|
|
@ -42,6 +46,29 @@ function die(message, code = 1) {
|
|||
process.exit(code);
|
||||
}
|
||||
|
||||
function isSafePathSegment(value) {
|
||||
const v = String(value == null ? '' : value);
|
||||
if (v.length === 0 || v.trim().length === 0) return false;
|
||||
if (v === '.' || v === '..') return false;
|
||||
if (v.includes('/') || v.includes('\\\\')) return false;
|
||||
if (v.includes('..')) return false;
|
||||
if (v.includes('\0')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertSafePathSegment(label, value) {
|
||||
const v = String(value == null ? '' : value);
|
||||
if (!isSafePathSegment(v)) {
|
||||
die('Invalid ' + String(label));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function getTaskJsonPath(paths, taskId) {
|
||||
const id = assertSafePathSegment('taskId', taskId);
|
||||
return path.join(paths.tasksDir, id + '.json');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [], flags: {} };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
|
|
@ -124,7 +151,7 @@ function getTeamName(flags) {
|
|||
(typeof flags.team === 'string' && flags.team.trim()) ||
|
||||
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
|
||||
'';
|
||||
if (explicit) return explicit;
|
||||
if (explicit) return assertSafePathSegment('team', explicit);
|
||||
const inferred = inferTeamNameFromScriptPath();
|
||||
if (inferred) return inferred;
|
||||
die('Missing --team (and could not infer team name from script path)');
|
||||
|
|
@ -175,6 +202,235 @@ function atomicWrite(filePath, data) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attachments (task + comment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
function sanitizeFilename(original) {
|
||||
const raw = String(original == null ? '' : original).trim();
|
||||
const parts = raw.split(/[\\/]/);
|
||||
const base = (parts.length ? parts[parts.length - 1] : raw).trim();
|
||||
const cleaned = base
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
.replace(/[\\/]/g, '_')
|
||||
.trim();
|
||||
if (!cleaned) return 'attachment';
|
||||
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
|
||||
}
|
||||
|
||||
function readFileHeader(filePath, maxBytes) {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(maxBytes);
|
||||
const bytes = fs.readSync(fd, buf, 0, maxBytes, 0);
|
||||
return buf.slice(0, bytes);
|
||||
} finally {
|
||||
try { fs.closeSync(fd); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function startsWithBytes(buf, bytes) {
|
||||
if (!buf || buf.length < bytes.length) return false;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (buf[i] !== bytes[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function detectMimeTypeFromPathAndHeader(filePath, filename) {
|
||||
const name = String(filename || '').toLowerCase();
|
||||
const ext = path.extname(name);
|
||||
|
||||
// Fast path by extension for common types.
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.pdf') return 'application/pdf';
|
||||
if (ext === '.txt') return 'text/plain';
|
||||
if (ext === '.md') return 'text/markdown';
|
||||
if (ext === '.json') return 'application/json';
|
||||
if (ext === '.zip') return 'application/zip';
|
||||
|
||||
// Sniff magic bytes for a few important formats.
|
||||
let header;
|
||||
try {
|
||||
header = readFileHeader(filePath, 16);
|
||||
} catch {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
if (startsWithBytes(header, [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])) return 'image/png'; // PNG
|
||||
if (startsWithBytes(header, [0xff,0xd8,0xff])) return 'image/jpeg'; // JPEG
|
||||
if (header.length >= 6) {
|
||||
const sig6 = header.slice(0, 6).toString('ascii');
|
||||
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif';
|
||||
}
|
||||
if (header.length >= 12) {
|
||||
const riff = header.slice(0, 4).toString('ascii');
|
||||
const webp = header.slice(8, 12).toString('ascii');
|
||||
if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp';
|
||||
}
|
||||
if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
|
||||
if (startsWithBytes(header, [0x50,0x4b,0x03,0x04])) return 'application/zip';
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function getTaskAttachmentsDir(paths, taskId) {
|
||||
const id = assertSafePathSegment('taskId', taskId);
|
||||
return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, id);
|
||||
}
|
||||
|
||||
function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
|
||||
const safeName = sanitizeFilename(filename);
|
||||
return path.join(getTaskAttachmentsDir(paths, taskId), String(attachmentId) + '--' + safeName);
|
||||
}
|
||||
|
||||
function ensureSourceFileReadable(srcPath) {
|
||||
const st = fs.statSync(srcPath);
|
||||
if (!st.isFile()) die('Not a file: ' + String(srcPath));
|
||||
if (st.size > MAX_TASK_ATTACHMENT_BYTES) {
|
||||
die(
|
||||
'Attachment too large: ' +
|
||||
(st.size / (1024 * 1024)).toFixed(1) +
|
||||
' MB (max ' +
|
||||
String(MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)) +
|
||||
' MB)'
|
||||
);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
function copyOrLinkFile(srcPath, destPath, mode, allowFallback) {
|
||||
const m = String(mode || 'copy').toLowerCase();
|
||||
if (m === 'link') {
|
||||
try {
|
||||
fs.linkSync(srcPath, destPath);
|
||||
return { mode: 'link', fallbackUsed: false };
|
||||
} catch (e) {
|
||||
if (!allowFallback) throw e;
|
||||
// Fall back to copy (cross-device link, permissions, etc.)
|
||||
try {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
return { mode: 'copy', fallbackUsed: true };
|
||||
} catch (e2) {
|
||||
// Bubble up most useful error
|
||||
throw e2 || e;
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
return { mode: 'copy', fallbackUsed: false };
|
||||
}
|
||||
|
||||
function saveTaskAttachmentFile(paths, taskId, flags) {
|
||||
const rawFile = (typeof flags.file === 'string' && flags.file.trim())
|
||||
? flags.file.trim()
|
||||
: (typeof flags.path === 'string' && flags.path.trim())
|
||||
? flags.path.trim()
|
||||
: '';
|
||||
if (!rawFile) die('Missing --file <path>');
|
||||
|
||||
const srcPath = path.resolve(rawFile);
|
||||
ensureSourceFileReadable(srcPath);
|
||||
|
||||
const filename = (typeof flags.filename === 'string' && flags.filename.trim())
|
||||
? flags.filename.trim()
|
||||
: path.basename(srcPath);
|
||||
const mimeType = (typeof flags['mime-type'] === 'string' && flags['mime-type'].trim())
|
||||
? flags['mime-type'].trim()
|
||||
: (typeof flags.mimeType === 'string' && flags.mimeType.trim())
|
||||
? flags.mimeType.trim()
|
||||
: detectMimeTypeFromPathAndHeader(srcPath, filename);
|
||||
|
||||
const attachmentId = makeId();
|
||||
const dir = getTaskAttachmentsDir(paths, taskId);
|
||||
ensureDir(dir);
|
||||
const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename);
|
||||
const allowFallback = !(flags['no-fallback'] === true);
|
||||
|
||||
if (fs.existsSync(destPath)) die('Attachment destination already exists');
|
||||
const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback);
|
||||
|
||||
// Verify write/link
|
||||
const st = fs.statSync(destPath);
|
||||
if (!st.isFile() || st.size < 0) die('Attachment write verification failed');
|
||||
|
||||
const meta = {
|
||||
id: attachmentId,
|
||||
filename: filename,
|
||||
mimeType: mimeType,
|
||||
size: st.size,
|
||||
addedAt: nowIso(),
|
||||
};
|
||||
return { meta: meta, storedPath: destPath, storageMode: result.mode, fallbackUsed: result.fallbackUsed };
|
||||
}
|
||||
|
||||
function addAttachmentToTask(paths, taskId, meta) {
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
try {
|
||||
const ref = readTask(paths, taskId);
|
||||
const task = ref.task;
|
||||
const taskPath = ref.taskPath;
|
||||
const existing = Array.isArray(task.attachments) ? task.attachments : [];
|
||||
if (existing.some(function(a) { return a && a.id === meta.id; })) return;
|
||||
task.attachments = existing.concat([meta]);
|
||||
writeTask(taskPath, task);
|
||||
// Verify meta persisted (best-effort)
|
||||
const verify = readJson(taskPath, null);
|
||||
if (verify && Array.isArray(verify.attachments) && verify.attachments.some(function(a) { return a && a.id === meta.id; })) {
|
||||
return;
|
||||
}
|
||||
// Verification failed (concurrent overwrite) — retry
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt === 7) throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function addAttachmentToComment(paths, taskId, commentId, meta) {
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
try {
|
||||
const ref = readTask(paths, taskId);
|
||||
const task = ref.task;
|
||||
const taskPath = ref.taskPath;
|
||||
const comments = Array.isArray(task.comments) ? task.comments : [];
|
||||
const idx = comments.findIndex(function(c) { return c && String(c.id) === String(commentId); });
|
||||
if (idx < 0) die('Comment not found: ' + String(commentId));
|
||||
const comment = comments[idx];
|
||||
const existing = Array.isArray(comment.attachments) ? comment.attachments : [];
|
||||
if (!existing.some(function(a) { return a && a.id === meta.id; })) {
|
||||
comment.attachments = existing.concat([meta]);
|
||||
}
|
||||
// Persist update (single atomic write)
|
||||
task.comments = comments;
|
||||
writeTask(taskPath, task);
|
||||
|
||||
// Verify
|
||||
const verify = readJson(taskPath, null);
|
||||
if (verify && Array.isArray(verify.comments)) {
|
||||
const vc = verify.comments.find(function(c) { return c && String(c.id) === String(commentId); });
|
||||
if (vc && Array.isArray(vc.attachments) && vc.attachments.some(function(a) { return a && a.id === meta.id; })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Retry on verification failure
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt === 7) throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
|
||||
|
|
@ -189,8 +445,9 @@ function normalizeColumn(value) {
|
|||
|
||||
function getPaths(flags, teamName) {
|
||||
const claudeDir = getClaudeDir(flags);
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
||||
const safeTeam = assertSafePathSegment('team', teamName);
|
||||
const teamDir = path.join(claudeDir, 'teams', safeTeam);
|
||||
const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
|
||||
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
||||
const processesPath = path.join(teamDir, 'processes.json');
|
||||
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
||||
|
|
@ -206,7 +463,7 @@ function inferLeadName(paths) {
|
|||
}
|
||||
|
||||
function readTask(paths, taskId) {
|
||||
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||
const taskPath = getTaskJsonPath(paths, taskId);
|
||||
const task = readJson(taskPath, null);
|
||||
if (!task) die('Task not found: ' + String(taskId));
|
||||
return { taskPath, task };
|
||||
|
|
@ -297,9 +554,7 @@ function addTaskComment(paths, taskId, flags) {
|
|||
}
|
||||
|
||||
existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
commentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
commentId = makeId();
|
||||
comment = {
|
||||
id: commentId,
|
||||
author: from,
|
||||
|
|
@ -377,13 +632,13 @@ function parseIdList(value) {
|
|||
|
||||
function taskExists(paths, taskId) {
|
||||
try {
|
||||
fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK);
|
||||
fs.accessSync(getTaskJsonPath(paths, taskId), fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function readTaskObject(paths, taskId) {
|
||||
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||
var taskPath = getTaskJsonPath(paths, taskId);
|
||||
var t = readJson(taskPath, null);
|
||||
if (!t) die('Task not found: #' + taskId);
|
||||
return { task: t, taskPath: taskPath };
|
||||
|
|
@ -398,7 +653,8 @@ function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
|||
if (visited[current]) continue;
|
||||
visited[current] = true;
|
||||
try {
|
||||
var t = readJson(path.join(paths.tasksDir, current + '.json'), null);
|
||||
if (!isSafePathSegment(current)) continue;
|
||||
var t = readJson(getTaskJsonPath(paths, current), null);
|
||||
if (t && Array.isArray(t.blockedBy)) {
|
||||
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
|
||||
}
|
||||
|
|
@ -500,7 +756,7 @@ function createTask(paths, flags) {
|
|||
let taskPath;
|
||||
while (true) {
|
||||
nextId = getNextTaskId(paths);
|
||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
taskPath = getTaskJsonPath(paths, nextId);
|
||||
var createdAt = nowIso();
|
||||
task = {
|
||||
id: nextId,
|
||||
|
|
@ -595,12 +851,11 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
|
||||
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
||||
const safeTo = assertSafePathSegment('to', to);
|
||||
const inboxPath = path.join(paths.teamDir, 'inboxes', safeTo + '.json');
|
||||
ensureDir(path.dirname(inboxPath));
|
||||
|
||||
const messageId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
const messageId = makeId();
|
||||
const payload = {
|
||||
from,
|
||||
to,
|
||||
|
|
@ -640,9 +895,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
|||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
var reviewCommentId = makeId();
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
|
|
@ -681,9 +934,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
var reviewCommentId = makeId();
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
|
|
@ -747,7 +998,7 @@ function processRegister(paths, flags) {
|
|||
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
|
||||
|
||||
const entry = {
|
||||
id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())),
|
||||
id: existingIdx >= 0 ? list[existingIdx].id : makeId(),
|
||||
port: port,
|
||||
url: url,
|
||||
label: label,
|
||||
|
|
@ -802,7 +1053,8 @@ function taskBriefing(paths, teamName, flags) {
|
|||
var allTasks = [];
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
try {
|
||||
var taskPath = path.join(paths.tasksDir, ids[i] + '.json');
|
||||
if (!isSafePathSegment(ids[i])) continue;
|
||||
var taskPath = getTaskJsonPath(paths, ids[i]);
|
||||
var t = readJson(taskPath, null);
|
||||
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) {
|
||||
try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; }
|
||||
|
|
@ -936,6 +1188,8 @@ function printHelp() {
|
|||
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
|
||||
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||
' node teamctl.js task attach <id> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
|
||||
' node teamctl.js task comment-attach <id> <commentId> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
|
||||
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
||||
' node teamctl.js task briefing --for <member-name> [--team <team>]',
|
||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||
|
|
@ -951,6 +1205,8 @@ function printHelp() {
|
|||
'Options:',
|
||||
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
||||
' --claude-dir <path> Override ~/.claude location',
|
||||
' --mode <copy|link> For attachments: copy into storage (default) or try hardlink to avoid duplication',
|
||||
' --no-fallback For --mode link: fail instead of falling back to copy',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
|
@ -1044,7 +1300,7 @@ async function main() {
|
|||
const tasks = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null));
|
||||
tasks.push(readJson(getTaskJsonPath(paths, id), null));
|
||||
} catch {}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n');
|
||||
|
|
@ -1069,6 +1325,42 @@ async function main() {
|
|||
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'attach') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
if (!id) die('Usage: task attach <id> --file <path>');
|
||||
// Save file to storage first, then update task metadata
|
||||
const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
|
||||
try {
|
||||
addAttachmentToTask(paths, String(id), saved.meta);
|
||||
} catch (e) {
|
||||
// Best-effort cleanup of orphaned file on failure
|
||||
try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
|
||||
throw e;
|
||||
}
|
||||
if (saved.fallbackUsed) {
|
||||
process.stderr.write('WARN: link failed; fell back to copy\n');
|
||||
}
|
||||
process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'comment-attach') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
const commentId = rest[1] || args.flags['comment-id'] || args.flags.commentId;
|
||||
if (!id || !commentId) die('Usage: task comment-attach <id> <commentId> --file <path>');
|
||||
const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
|
||||
try {
|
||||
addAttachmentToComment(paths, String(id), String(commentId), saved.meta);
|
||||
} catch (e) {
|
||||
// Best-effort cleanup of orphaned file on failure
|
||||
try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
|
||||
throw e;
|
||||
}
|
||||
if (saved.fallbackUsed) {
|
||||
process.stderr.write('WARN: link failed; fell back to copy\n');
|
||||
}
|
||||
process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'set-clarification') {
|
||||
const id = rest[0] || args.flags.id;
|
||||
const val = rest[1] || args.flags.value;
|
||||
|
|
@ -1252,7 +1544,11 @@ export class TeamAgentToolsInstaller {
|
|||
}
|
||||
|
||||
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) {
|
||||
return toolPath;
|
||||
// Even when app version is unchanged, the generated script can evolve.
|
||||
// Keep the installed tool idempotent by content, not only by version.
|
||||
if (current === desired) {
|
||||
return toolPath;
|
||||
}
|
||||
}
|
||||
|
||||
await atomicWriteAsync(toolPath, desired);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
|||
const logger = createLogger('Service:TeamAttachmentStore');
|
||||
|
||||
const ATTACHMENTS_DIR = 'attachments';
|
||||
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
|
||||
|
||||
export class TeamAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
|
|
@ -58,6 +59,10 @@ export class TeamAttachmentStore {
|
|||
|
||||
let raw: string;
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) {
|
||||
return [];
|
||||
}
|
||||
raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -244,6 +245,15 @@ export class TeamConfigReader {
|
|||
}
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
for (const [key, member] of Array.from(memberMap.entries())) {
|
||||
if (!keepName(member.name)) {
|
||||
memberMap.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const summary: TeamSummary = {
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { killProcessByPid } from '@main/utils/processKill';
|
|||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -32,6 +33,7 @@ import { TeamTaskWriter } from './TeamTaskWriter';
|
|||
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AttachmentMeta,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
|
|
@ -41,6 +43,7 @@ import type {
|
|||
ResolvedTeamMember,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
|
|
@ -256,8 +259,9 @@ export class TeamDataService {
|
|||
}
|
||||
mark('messages');
|
||||
|
||||
let leadTexts: InboxMessage[] = [];
|
||||
try {
|
||||
const leadTexts = await this.extractLeadSessionTexts(config);
|
||||
leadTexts = await this.extractLeadSessionTexts(config);
|
||||
if (leadTexts.length > 0) {
|
||||
messages = [...messages, ...leadTexts];
|
||||
}
|
||||
|
|
@ -266,8 +270,9 @@ export class TeamDataService {
|
|||
}
|
||||
mark('leadTexts');
|
||||
|
||||
let sentMessages: InboxMessage[] = [];
|
||||
try {
|
||||
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
||||
sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
||||
if (sentMessages.length > 0) {
|
||||
messages = [...messages, ...sentMessages];
|
||||
}
|
||||
|
|
@ -276,6 +281,33 @@ export class TeamDataService {
|
|||
}
|
||||
mark('sentMessages');
|
||||
|
||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||
if (leadTexts.length > 0 && sentMessages.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source !== 'lead_session') continue;
|
||||
leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich messages without leadSessionId: assign current session for lead_process/user_sent.
|
||||
// lead_process messages surviving dedup are from the current session;
|
||||
// user_sent messages written before this feature lack the field.
|
||||
if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
let metaMembers: TeamConfig['members'] = [];
|
||||
|
|
@ -618,19 +650,31 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
|
||||
const name = request.name.trim();
|
||||
if (!name) {
|
||||
throw new Error('Member name cannot be empty');
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||
);
|
||||
}
|
||||
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase());
|
||||
const existing = members.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
if (existing.removedAt) {
|
||||
throw new Error(`Name "${request.name}" was previously used by a removed member`);
|
||||
throw new Error(`Name "${name}" was previously used by a removed member`);
|
||||
}
|
||||
throw new Error(`Member "${request.name}" already exists`);
|
||||
throw new Error(`Member "${name}" already exists`);
|
||||
}
|
||||
|
||||
const newMember: TeamMember = {
|
||||
name: request.name,
|
||||
name,
|
||||
role: request.role?.trim() || undefined,
|
||||
workflow: request.workflow?.trim() || undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColor(members.filter((m) => !m.removedAt).length),
|
||||
joinedAt: Date.now(),
|
||||
|
|
@ -678,6 +722,12 @@ export class TeamDataService {
|
|||
if (name.toLowerCase() === 'team-lead') {
|
||||
throw new Error('Member name "team-lead" is reserved');
|
||||
}
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||
);
|
||||
}
|
||||
nextByName.add(name.toLowerCase());
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
return {
|
||||
|
|
@ -812,6 +862,7 @@ export class TeamDataService {
|
|||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `New task #${task.id} assigned`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -856,6 +907,7 @@ export class TeamDataService {
|
|||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `Task #${task.id} started`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -911,9 +963,10 @@ export class TeamDataService {
|
|||
from: last.actor,
|
||||
text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`,
|
||||
summary: `Task #${task.id} started`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`);
|
||||
logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -944,7 +997,7 @@ export class TeamDataService {
|
|||
async addTaskAttachment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
meta: import('@shared/types').TaskAttachmentMeta
|
||||
meta: TaskAttachmentMeta
|
||||
): Promise<void> {
|
||||
await this.taskWriter.addAttachment(teamName, taskId, meta);
|
||||
}
|
||||
|
|
@ -987,7 +1040,7 @@ export class TeamDataService {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: import('@shared/types').TaskAttachmentMeta[]
|
||||
attachments?: TaskAttachmentMeta[]
|
||||
): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
||||
attachments,
|
||||
|
|
@ -1001,14 +1054,14 @@ export class TeamDataService {
|
|||
]);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
const leadName = this.resolveLeadNameFromConfig(config);
|
||||
|
||||
const owner = task?.owner?.trim() || null;
|
||||
// Auto-clear needsClarification: "user" on UI comment
|
||||
// UI comments always have author "user" (TeamTaskWriter default)
|
||||
if (task?.needsClarification === 'user') {
|
||||
await this.taskWriter.setNeedsClarification(teamName, taskId, null);
|
||||
}
|
||||
|
||||
if (task?.owner && !this.isLeadOwner(task.owner, leadName)) {
|
||||
if (task && owner && !this.isLeadOwner(owner, leadName)) {
|
||||
// Notify non-lead task owner via inbox (lead → member message)
|
||||
const parts = [
|
||||
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||
|
|
@ -1018,12 +1071,13 @@ export class TeamDataService {
|
|||
AGENT_BLOCK_CLOSE,
|
||||
];
|
||||
await this.sendMessage(teamName, {
|
||||
member: task.owner,
|
||||
member: owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} else if (task?.owner && this.isLeadOwner(task.owner, leadName)) {
|
||||
} else if (task && owner && this.isLeadOwner(owner, leadName)) {
|
||||
// Notify lead about user's comment on their own task.
|
||||
// Write to lead's inbox — relay delivers to stdin when process is alive.
|
||||
const parts = [
|
||||
|
|
@ -1038,6 +1092,7 @@ export class TeamDataService {
|
|||
from: 'user',
|
||||
text: parts.join('\n'),
|
||||
summary: `Comment on #${taskId}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1076,9 +1131,19 @@ export class TeamDataService {
|
|||
teamName: string,
|
||||
leadName: string,
|
||||
text: string,
|
||||
summary?: string
|
||||
summary?: string,
|
||||
attachments?: AttachmentMeta[]
|
||||
): Promise<SendMessageResult> {
|
||||
const messageId = randomUUID();
|
||||
|
||||
let leadSessionId: string | undefined;
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
leadSessionId = config?.leadSessionId;
|
||||
} catch {
|
||||
// non-critical — proceed without sessionId
|
||||
}
|
||||
|
||||
const msg: InboxMessage = {
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
|
|
@ -1088,6 +1153,8 @@ export class TeamDataService {
|
|||
summary,
|
||||
messageId,
|
||||
source: 'user_sent',
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
leadSessionId,
|
||||
};
|
||||
await this.sentMessagesStore.appendMessage(teamName, msg);
|
||||
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
||||
|
|
@ -1146,6 +1213,7 @@ export class TeamDataService {
|
|||
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` +
|
||||
AGENT_BLOCK_CLOSE,
|
||||
summary: `Review request for #${taskId}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.kanbanManager
|
||||
|
|
@ -1187,7 +1255,19 @@ export class TeamDataService {
|
|||
await this.membersMetaStore.writeMembers(
|
||||
request.teamName,
|
||||
request.members.map((member, index) => ({
|
||||
name: member.name,
|
||||
name: (() => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
if (name.toLowerCase() === 'team-lead')
|
||||
throw new Error('Member name "team-lead" is reserved');
|
||||
const suffixInfo = parseNumericSuffixName(name);
|
||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||
throw new Error(
|
||||
`Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`
|
||||
);
|
||||
}
|
||||
return name;
|
||||
})(),
|
||||
role: member.role?.trim() || undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColor(index),
|
||||
|
|
@ -1217,9 +1297,24 @@ export class TeamDataService {
|
|||
// Dedup broadcasts: same sender + same text → process only once
|
||||
const processedTexts = new Set<string>();
|
||||
|
||||
function isAutomatedCommentNotification(msg: InboxMessage): boolean {
|
||||
const summary = typeof msg.summary === 'string' ? msg.summary : '';
|
||||
if (!/^Comment on #\d+/.test(summary)) return false;
|
||||
const text = typeof msg.text === 'string' ? msg.text : '';
|
||||
if (!text) return false;
|
||||
// These are system-generated inbox messages that already correspond to a real task comment.
|
||||
// Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI.
|
||||
if (text.includes('Reply to this comment using:')) return true;
|
||||
if (text.startsWith('Comment on task #')) return true;
|
||||
if (text.startsWith('New comment from user on your task #')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
|
||||
if (msg.source === 'lead_session' || msg.source === 'lead_process') continue;
|
||||
if (msg.source === 'system_notification') continue;
|
||||
if (isAutomatedCommentNotification(msg)) continue;
|
||||
|
||||
const textKey = `${msg.from}\0${msg.text}`;
|
||||
if (processedTexts.has(textKey)) continue;
|
||||
|
|
@ -1325,6 +1420,7 @@ export class TeamDataService {
|
|||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId: config.leadSessionId,
|
||||
});
|
||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||
}
|
||||
|
|
@ -1347,7 +1443,34 @@ export class TeamDataService {
|
|||
|
||||
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
||||
if (patch.op !== 'request_changes') {
|
||||
// Keep kanban + task.status consistent:
|
||||
// - moving a task into kanban review/approved implies the work is complete
|
||||
// - request_changes already moves it back to in_progress and clears kanban entry
|
||||
if (patch.op !== 'set_column') {
|
||||
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousState = await this.kanbanManager.getState(teamName);
|
||||
const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId];
|
||||
|
||||
await this.kanbanManager.updateTask(teamName, taskId, patch);
|
||||
|
||||
try {
|
||||
await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user');
|
||||
} catch (error) {
|
||||
// Best-effort rollback of kanban move if task status update failed.
|
||||
if (previousKanbanEntry) {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||
.catch(() => undefined);
|
||||
} else {
|
||||
await this.kanbanManager
|
||||
.updateTask(teamName, taskId, { op: 'remove' })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1374,6 +1497,7 @@ export class TeamDataService {
|
|||
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
|
||||
`Please fix and mark it as completed when ready.`,
|
||||
summary: `Fix request for #${taskId}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (error) {
|
||||
await this.taskWriter
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ export class TeamInboxWriter {
|
|||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
|
||||
const messageId = randomUUID();
|
||||
|
||||
const attachmentMeta = request.attachments?.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
const payload: InboxMessage = {
|
||||
from: request.from ?? 'user',
|
||||
to: request.member,
|
||||
|
|
@ -21,6 +28,8 @@ export class TeamInboxWriter {
|
|||
read: false,
|
||||
summary: request.summary,
|
||||
messageId,
|
||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
...(request.source && { source: request.source }),
|
||||
};
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
MemberStatus,
|
||||
|
|
@ -78,6 +80,14 @@ export class TeamMemberResolver {
|
|||
// (recipient of SendMessage to "user"). It's not a real AI teammate.
|
||||
names.delete('user');
|
||||
|
||||
// Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists.
|
||||
const keepName = createCliAutoSuffixNameGuard(names);
|
||||
for (const name of Array.from(names)) {
|
||||
if (!keepName(name)) {
|
||||
names.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const members: ResolvedTeamMember[] = [];
|
||||
for (const name of names) {
|
||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -90,6 +91,15 @@ export class TeamMembersMetaStore {
|
|||
deduped.set(normalized.name, normalized);
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(deduped.keys());
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
for (const name of allNames) {
|
||||
if (!keepName(name)) {
|
||||
deduped.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +113,15 @@ export class TeamMembersMetaStore {
|
|||
deduped.set(normalized.name, normalized);
|
||||
}
|
||||
|
||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||
const allNames = Array.from(deduped.keys());
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
for (const name of allNames) {
|
||||
if (!keepName(name)) {
|
||||
deduped.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: TeamMembersMetaFile = {
|
||||
version: 1,
|
||||
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -75,6 +75,7 @@ export class TeamSentMessagesStore {
|
|||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
||||
const KNOWN_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
|
|
@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore {
|
|||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.trim().length === 0 ||
|
||||
value === '.' ||
|
||||
value === '..' ||
|
||||
value.includes('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('..') ||
|
||||
|
|
@ -37,14 +40,33 @@ export class TeamTaskAttachmentStore {
|
|||
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
||||
}
|
||||
|
||||
/** Returns the file path for a specific attachment. */
|
||||
private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
|
||||
this.assertSafePathSegment('attachmentId', attachmentId);
|
||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
|
||||
private sanitizeStoredFilename(original: string): string {
|
||||
const raw = String(original ?? '').trim();
|
||||
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
|
||||
const cleaned = base
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
.replace(/[\\/]/g, '_')
|
||||
.trim();
|
||||
if (!cleaned) return 'attachment';
|
||||
// Keep filenames bounded to avoid OS/path length issues.
|
||||
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
|
||||
}
|
||||
|
||||
/** Map MIME type to file extension. */
|
||||
private mimeToExt(mimeType: AttachmentMediaType): string {
|
||||
/** Returns the file path for a stored attachment (new format). */
|
||||
private getStoredFilePath(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
filename: string
|
||||
): string {
|
||||
this.assertSafePathSegment('attachmentId', attachmentId);
|
||||
const safeName = this.sanitizeStoredFilename(filename);
|
||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
|
||||
}
|
||||
|
||||
/** Map known MIME types to file extension (legacy storage format). */
|
||||
private mimeToExt(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
|
|
@ -54,9 +76,54 @@ export class TeamTaskAttachmentStore {
|
|||
return '.gif';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return '.bin';
|
||||
}
|
||||
}
|
||||
|
||||
private async findAttachmentFilePath(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
mimeType?: string
|
||||
): Promise<string | null> {
|
||||
const dir = this.getTaskDir(teamName, taskId);
|
||||
|
||||
// 1) Prefer legacy path for known image types (older storage format).
|
||||
if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`);
|
||||
try {
|
||||
const stat = await fs.promises.stat(legacy);
|
||||
if (stat.isFile()) return legacy;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 2) New format: "<id>--<filename>"
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const prefix = `${attachmentId}--`;
|
||||
const matches = entries.filter((e) => e.startsWith(prefix));
|
||||
if (matches.length > 0) {
|
||||
return path.join(dir, matches[0]);
|
||||
}
|
||||
|
||||
// 3) Fallback: any file starting with "<id>." (covers legacy when mimeType missing/wrong).
|
||||
const dotPrefix = `${attachmentId}.`;
|
||||
const dotMatches = entries.filter((e) => e.startsWith(dotPrefix));
|
||||
if (dotMatches.length > 0) {
|
||||
return path.join(dir, dotMatches[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
// Non-directory or other IO errors should surface.
|
||||
throw error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an attachment to disk. Data is expected as a base64-encoded string.
|
||||
* Returns metadata for the saved attachment.
|
||||
|
|
@ -69,10 +136,6 @@ export class TeamTaskAttachmentStore {
|
|||
mimeType: AttachmentMediaType,
|
||||
base64Data: string
|
||||
): Promise<TaskAttachmentMeta> {
|
||||
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||
throw new Error(`Unsupported MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const trimmed = base64Data.trim();
|
||||
// Avoid allocating huge Buffers for obviously too-large payloads.
|
||||
// Base64 decoded size is roughly 3/4 of the string length minus padding.
|
||||
|
|
@ -94,8 +157,7 @@ export class TeamTaskAttachmentStore {
|
|||
const dir = this.getTaskDir(teamName, taskId);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = this.mimeToExt(mimeType);
|
||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
||||
const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename);
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
|
||||
const meta: TaskAttachmentMeta = {
|
||||
|
|
@ -119,8 +181,8 @@ export class TeamTaskAttachmentStore {
|
|||
attachmentId: string,
|
||||
mimeType: AttachmentMediaType
|
||||
): Promise<string | null> {
|
||||
const ext = this.mimeToExt(mimeType);
|
||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
||||
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
|
|
@ -142,8 +204,8 @@ export class TeamTaskAttachmentStore {
|
|||
attachmentId: string,
|
||||
mimeType: AttachmentMediaType
|
||||
): Promise<void> {
|
||||
const ext = this.mimeToExt(mimeType);
|
||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
||||
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import * as path from 'path';
|
|||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
|
||||
import type {
|
||||
AttachmentMediaType,
|
||||
StatusTransition,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
|
|
@ -20,12 +19,18 @@ import type {
|
|||
const logger = createLogger('Service:TeamTaskReader');
|
||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
function isValidMimeTypeString(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const v = value.trim();
|
||||
if (!v) return false;
|
||||
// Keep it reasonably bounded and avoid control characters.
|
||||
if (v.length > 200) return false;
|
||||
if (v.includes('\0') || /[\r\n]/.test(v)) return false;
|
||||
// Minimal MIME shape: type/subtype
|
||||
const slash = v.indexOf('/');
|
||||
if (slash <= 0 || slash === v.length - 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export class TeamTaskReader {
|
||||
/**
|
||||
|
|
@ -172,8 +177,12 @@ export class TeamTaskReader {
|
|||
: 'pending',
|
||||
workIntervals,
|
||||
statusHistory,
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||
blocks: Array.isArray(parsed.blocks)
|
||||
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy)
|
||||
? (parsed.blockedBy as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: undefined,
|
||||
related: Array.isArray(parsed.related)
|
||||
? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: undefined,
|
||||
|
|
@ -197,19 +206,32 @@ export class TeamTaskReader {
|
|||
? c.type
|
||||
: ('regular' as const),
|
||||
attachments: Array.isArray(c.attachments)
|
||||
? (c.attachments as unknown[]).filter(
|
||||
(a): a is TaskAttachmentMeta =>
|
||||
Boolean(a) &&
|
||||
typeof a === 'object' &&
|
||||
typeof (a as Record<string, unknown>).id === 'string' &&
|
||||
typeof (a as Record<string, unknown>).filename === 'string' &&
|
||||
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
||||
VALID_ATTACHMENT_MIME_TYPES.has(
|
||||
(a as Record<string, unknown>).mimeType as string
|
||||
) &&
|
||||
typeof (a as Record<string, unknown>).size === 'number' &&
|
||||
typeof (a as Record<string, unknown>).addedAt === 'string'
|
||||
)
|
||||
? (() => {
|
||||
const filtered = (c.attachments as unknown[])
|
||||
.filter((a): a is TaskAttachmentMeta => {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const row = a as Record<string, unknown>;
|
||||
const size = row.size;
|
||||
return (
|
||||
typeof row.id === 'string' &&
|
||||
typeof row.filename === 'string' &&
|
||||
typeof row.mimeType === 'string' &&
|
||||
isValidMimeTypeString(row.mimeType) &&
|
||||
typeof size === 'number' &&
|
||||
Number.isFinite(size) &&
|
||||
size >= 0 &&
|
||||
typeof row.addedAt === 'string'
|
||||
);
|
||||
})
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: String(a.mimeType).trim(),
|
||||
size: a.size,
|
||||
addedAt: a.addedAt,
|
||||
}));
|
||||
return filtered.length > 0 ? filtered : undefined;
|
||||
})()
|
||||
: undefined,
|
||||
}))
|
||||
: undefined,
|
||||
|
|
@ -221,23 +243,25 @@ export class TeamTaskReader {
|
|||
deletedAt: undefined, // deleted tasks are filtered out below
|
||||
attachments: Array.isArray(parsed.attachments)
|
||||
? (parsed.attachments as unknown[])
|
||||
.filter(
|
||||
(a): a is TaskAttachmentMeta =>
|
||||
Boolean(a) &&
|
||||
typeof a === 'object' &&
|
||||
typeof (a as Record<string, unknown>).id === 'string' &&
|
||||
typeof (a as Record<string, unknown>).filename === 'string' &&
|
||||
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
||||
VALID_ATTACHMENT_MIME_TYPES.has(
|
||||
(a as Record<string, unknown>).mimeType as string
|
||||
) &&
|
||||
typeof (a as Record<string, unknown>).size === 'number' &&
|
||||
typeof (a as Record<string, unknown>).addedAt === 'string'
|
||||
)
|
||||
.filter((a): a is TaskAttachmentMeta => {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const row = a as Record<string, unknown>;
|
||||
const size = row.size;
|
||||
return (
|
||||
typeof row.id === 'string' &&
|
||||
typeof row.filename === 'string' &&
|
||||
typeof row.mimeType === 'string' &&
|
||||
isValidMimeTypeString(row.mimeType) &&
|
||||
typeof size === 'number' &&
|
||||
Number.isFinite(size) &&
|
||||
size >= 0 &&
|
||||
typeof row.addedAt === 'string'
|
||||
);
|
||||
})
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
mimeType: String(a.mimeType).trim(),
|
||||
size: a.size,
|
||||
addedAt: a.addedAt,
|
||||
}))
|
||||
|
|
@ -256,6 +280,17 @@ export class TeamTaskReader {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11).
|
||||
// Fall back to stable lexicographic ordering for unexpected non-numeric IDs.
|
||||
tasks.sort((a, b) => {
|
||||
const aIsNumeric = /^\d+$/.test(a.id);
|
||||
const bIsNumeric = /^\d+$/.test(b.id);
|
||||
if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(b.id);
|
||||
if (aIsNumeric) return -1;
|
||||
if (bIsNumeric) return 1;
|
||||
return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,9 @@ export class TeamTaskWriter {
|
|||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const prevStatus = task.status;
|
||||
if (prevStatus === status) {
|
||||
return;
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Maintain workIntervals as periods of time where status === 'in_progress'.
|
||||
|
|
|
|||
|
|
@ -250,6 +250,25 @@ function mergeMember(
|
|||
});
|
||||
}
|
||||
|
||||
function dropCliAutoSuffixedMembers(
|
||||
memberMap: Map<string, { name: string; role?: string; color?: string }>
|
||||
): void {
|
||||
const keys = Array.from(memberMap.keys());
|
||||
const allLower = new Set(keys); // keys are already lowercased
|
||||
for (const key of keys) {
|
||||
const member = memberMap.get(key);
|
||||
const name = member?.name ?? '';
|
||||
const match = /^(.+)-(\d+)$/.exec(name.trim());
|
||||
if (!match?.[1] || !match[2]) continue;
|
||||
const suffix = Number(match[2]);
|
||||
if (!Number.isFinite(suffix) || suffix < 2) continue;
|
||||
const baseLower = match[1].toLowerCase();
|
||||
if (allLower.has(baseLower)) {
|
||||
memberMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listTeams(
|
||||
payload: ListTeamsPayload
|
||||
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
|
||||
|
|
@ -392,6 +411,8 @@ async function listTeams(
|
|||
}
|
||||
}
|
||||
|
||||
dropCliAutoSuffixedMembers(memberMap);
|
||||
|
||||
const members = Array.from(memberMap.values());
|
||||
const summary = {
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
|
|||
/** Get detailed team data */
|
||||
export const TEAM_GET_DATA = 'team:getData';
|
||||
|
||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||
|
||||
/** Update team kanban state */
|
||||
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
||||
|
||||
|
|
|
|||
|
|
@ -67,15 +67,18 @@ import {
|
|||
TEAM_CREATE,
|
||||
TEAM_CREATE_CONFIG,
|
||||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_GET_TASK_ATTACHMENT,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
|
|
@ -90,12 +93,10 @@ import {
|
|||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_GET_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
|
|
@ -167,6 +168,7 @@ import type {
|
|||
ClaudeRootInfo,
|
||||
CliInstallationStatus,
|
||||
CliInstallerProgress,
|
||||
CommentAttachmentPayload,
|
||||
ConflictCheckResult,
|
||||
ContextInfo,
|
||||
CreateTaskRequest,
|
||||
|
|
@ -192,11 +194,12 @@ import type {
|
|||
SshConnectionConfig,
|
||||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
CommentAttachmentPayload,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
|
|
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
|
|||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => {
|
||||
return invokeIpcWithResult<TeamClaudeLogsResponse>(TEAM_GET_CLAUDE_LOGS, teamName, query);
|
||||
},
|
||||
deleteTeam: async (teamName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ import type {
|
|||
SshLastConnection,
|
||||
SubagentDetail,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
|
|
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getClaudeLogs: async (
|
||||
_teamName: string,
|
||||
_query?: TeamClaudeLogsQuery
|
||||
): Promise<TeamClaudeLogsResponse> => {
|
||||
console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode');
|
||||
return { lines: [], total: 0, hasMore: false };
|
||||
},
|
||||
deleteTeam: async (_teamName: string): Promise<void> => {
|
||||
throw new Error('Team deletion is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ interface DisplayItemListProps {
|
|||
onItemClick: (itemId: string) => void;
|
||||
expandedItemIds: Set<string>;
|
||||
aiGroupId: string;
|
||||
/** Render order for display items (visual only). */
|
||||
order?: 'chronological' | 'newest-first';
|
||||
/** Optional local search query override for markdown highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Tool use ID to highlight for error deep linking */
|
||||
highlightToolUseId?: string;
|
||||
/** Custom highlight color from trigger */
|
||||
|
|
@ -66,6 +70,8 @@ export const DisplayItemList = ({
|
|||
onItemClick,
|
||||
expandedItemIds,
|
||||
aiGroupId,
|
||||
order = 'chronological',
|
||||
searchQueryOverride,
|
||||
highlightToolUseId,
|
||||
highlightColor,
|
||||
notificationColorMap,
|
||||
|
|
@ -96,7 +102,13 @@ export const DisplayItemList = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div
|
||||
className={
|
||||
order === 'newest-first'
|
||||
? 'min-w-0 flex flex-col-reverse gap-2'
|
||||
: 'min-w-0 space-y-2'
|
||||
}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
let itemKey = '';
|
||||
let element: React.ReactNode = null;
|
||||
|
|
@ -120,6 +132,8 @@ export const DisplayItemList = ({
|
|||
preview={truncateText(item.content, 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
|
@ -143,6 +157,8 @@ export const DisplayItemList = ({
|
|||
preview={truncateText(item.content, 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
|
@ -155,6 +171,7 @@ export const DisplayItemList = ({
|
|||
linkedTool={item.tool}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
isHighlighted={highlightToolUseId === item.tool.id}
|
||||
highlightColor={highlightColor}
|
||||
notificationDotColor={notificationColorMap?.get(item.tool.id)}
|
||||
|
|
@ -235,7 +252,12 @@ export const DisplayItemList = ({
|
|||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
>
|
||||
<MarkdownViewer content={inputContent} copyable />
|
||||
<MarkdownViewer
|
||||
content={inputContent}
|
||||
copyable
|
||||
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
</BaseItem>
|
||||
);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
|
||||
import { formatCostUsd } from '@shared/utils/costFormatting';
|
||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -110,7 +111,7 @@ export const SessionContextHeader = ({
|
|||
)}
|
||||
</div>
|
||||
{/* Percentage of total */}
|
||||
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
|
||||
{formatPercentOfTotal(totalTokens, totalSessionTokens) && (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 tabular-nums"
|
||||
style={{
|
||||
|
|
@ -118,7 +119,7 @@ export const SessionContextHeader = ({
|
|||
color: COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
{Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total
|
||||
{formatPercentOfTotal(totalTokens, totalSessionTokens)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
COLOR_SURFACE_OVERLAY,
|
||||
COLOR_TEXT_MUTED,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
|
||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||
import { FlatInjectionList } from './components/FlatInjectionList';
|
||||
|
|
@ -132,10 +133,7 @@ export const SessionContextPanel = ({
|
|||
}, [injections]);
|
||||
|
||||
// Calculate total tokens
|
||||
const totalTokens = useMemo(
|
||||
() => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0),
|
||||
[injections]
|
||||
);
|
||||
const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]);
|
||||
|
||||
// Section token counts
|
||||
const claudeMdTokens = useMemo(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface BaseItemProps {
|
|||
/** Primary label (e.g., "Thinking", "Output", tool name) */
|
||||
label: string;
|
||||
/** Summary text shown after the label */
|
||||
summary?: string;
|
||||
summary?: React.ReactNode;
|
||||
/** Token count to display */
|
||||
tokenCount?: number;
|
||||
/** Label for tokens (default: "tokens") */
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
} from '@shared/constants/triggerColors';
|
||||
import { Wrench } from 'lucide-react';
|
||||
|
||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||
|
||||
import { BaseItem, StatusDot } from './BaseItem';
|
||||
import { formatDuration } from './baseItemHelpers';
|
||||
import {
|
||||
|
|
@ -45,6 +47,8 @@ interface LinkedToolItemProps {
|
|||
linkedTool: LinkedToolItemType;
|
||||
onClick: () => void;
|
||||
isExpanded: boolean;
|
||||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Whether this item should be highlighted for error deep linking */
|
||||
isHighlighted?: boolean;
|
||||
/** Custom highlight color from trigger */
|
||||
|
|
@ -59,6 +63,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
linkedTool,
|
||||
onClick,
|
||||
isExpanded,
|
||||
searchQueryOverride,
|
||||
isHighlighted,
|
||||
highlightColor,
|
||||
notificationDotColor,
|
||||
|
|
@ -66,6 +71,17 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
}) => {
|
||||
const status = getToolStatus(linkedTool);
|
||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||
const summaryNode =
|
||||
searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||
? highlightQueryInText(
|
||||
summary,
|
||||
searchQueryOverride,
|
||||
`${linkedTool.id ?? linkedTool.name}:summary`,
|
||||
{
|
||||
forceAllActive: true,
|
||||
}
|
||||
)
|
||||
: summary;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Combined ref callback - handles both internal ref and external registration
|
||||
|
|
@ -155,7 +171,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
/>
|
||||
}
|
||||
label={linkedTool.name}
|
||||
summary={summary}
|
||||
summary={summaryNode}
|
||||
tokenCount={getToolContextTokens(linkedTool)}
|
||||
status={status}
|
||||
durationMs={linkedTool.durationMs}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||
import { MarkdownViewer } from '../viewers';
|
||||
|
||||
import { BaseItem } from './BaseItem';
|
||||
|
|
@ -15,6 +16,10 @@ interface TextItemProps {
|
|||
preview: string;
|
||||
onClick: () => void;
|
||||
isExpanded: boolean;
|
||||
/** Optional local search query for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Optional stable item id for search highlighting */
|
||||
markdownItemId?: string;
|
||||
/** Additional classes for highlighting (e.g., error deep linking) */
|
||||
highlightClasses?: string;
|
||||
/** Inline styles for highlighting (used by custom hex colors) */
|
||||
|
|
@ -28,12 +33,24 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
preview,
|
||||
onClick,
|
||||
isExpanded,
|
||||
searchQueryOverride,
|
||||
markdownItemId,
|
||||
highlightClasses,
|
||||
highlightStyle,
|
||||
notificationDotColor,
|
||||
}) => {
|
||||
const fullContent = step.content.outputText ?? preview;
|
||||
const truncatedPreview = truncateText(preview, 60);
|
||||
const summary = searchQueryOverride
|
||||
? highlightQueryInText(
|
||||
truncatedPreview,
|
||||
searchQueryOverride,
|
||||
`${markdownItemId ?? step.id}:summary`,
|
||||
{
|
||||
forceAllActive: true,
|
||||
}
|
||||
)
|
||||
: truncatedPreview;
|
||||
|
||||
// Get token count from step.tokens.output or step.content.tokenCount
|
||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||
|
|
@ -42,7 +59,7 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
<BaseItem
|
||||
icon={<MessageSquare className="size-4" />}
|
||||
label="Output"
|
||||
summary={truncatedPreview}
|
||||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
|
|
@ -50,7 +67,13 @@ export const TextItem: React.FC<TextItemProps> = ({
|
|||
highlightStyle={highlightStyle}
|
||||
notificationDotColor={notificationDotColor}
|
||||
>
|
||||
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable />
|
||||
<MarkdownViewer
|
||||
content={fullContent}
|
||||
maxHeight="max-h-96"
|
||||
copyable
|
||||
itemId={markdownItemId}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
</BaseItem>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
import { highlightQueryInText } from '../searchHighlightUtils';
|
||||
import { MarkdownViewer } from '../viewers';
|
||||
|
||||
import { BaseItem } from './BaseItem';
|
||||
|
|
@ -15,6 +16,10 @@ interface ThinkingItemProps {
|
|||
preview: string;
|
||||
onClick: () => void;
|
||||
isExpanded: boolean;
|
||||
/** Optional local search query for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
/** Optional stable item id for search highlighting */
|
||||
markdownItemId?: string;
|
||||
/** Additional classes for highlighting (e.g., error deep linking) */
|
||||
highlightClasses?: string;
|
||||
/** Inline styles for highlighting (used by custom hex colors) */
|
||||
|
|
@ -28,12 +33,24 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
preview,
|
||||
onClick,
|
||||
isExpanded,
|
||||
searchQueryOverride,
|
||||
markdownItemId,
|
||||
highlightClasses,
|
||||
highlightStyle,
|
||||
notificationDotColor,
|
||||
}) => {
|
||||
const fullContent = step.content.thinkingText ?? preview;
|
||||
const truncatedPreview = truncateText(preview, 60);
|
||||
const summary = searchQueryOverride
|
||||
? highlightQueryInText(
|
||||
truncatedPreview,
|
||||
searchQueryOverride,
|
||||
`${markdownItemId ?? step.id}:summary`,
|
||||
{
|
||||
forceAllActive: true,
|
||||
}
|
||||
)
|
||||
: truncatedPreview;
|
||||
|
||||
// Get token count from step.tokens.output or step.content.tokenCount
|
||||
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
|
||||
|
|
@ -42,7 +59,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
<BaseItem
|
||||
icon={<Brain className="size-4" />}
|
||||
label="Thinking"
|
||||
summary={truncatedPreview}
|
||||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
|
|
@ -50,7 +67,13 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
|
|||
highlightStyle={highlightStyle}
|
||||
notificationDotColor={notificationDotColor}
|
||||
>
|
||||
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable />
|
||||
<MarkdownViewer
|
||||
content={fullContent}
|
||||
maxHeight="max-h-96"
|
||||
copyable
|
||||
itemId={markdownItemId}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
</BaseItem>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface SearchContext {
|
|||
matchCounter: { current: number };
|
||||
isCurrentItem: boolean;
|
||||
currentMatchIndexInItem: number | null;
|
||||
/** When true, render all matches using the "current" highlight style */
|
||||
forceAllActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,7 +81,8 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode
|
|||
}
|
||||
|
||||
const isCurrentResult =
|
||||
ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current;
|
||||
ctx.forceAllActive === true ||
|
||||
(ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current);
|
||||
|
||||
parts.push(
|
||||
React.createElement(
|
||||
|
|
@ -109,6 +112,19 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode
|
|||
return parts;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
|
||||
export function highlightQueryInText(
|
||||
text: string,
|
||||
query: string,
|
||||
itemId: string,
|
||||
options?: { forceAllActive?: boolean }
|
||||
): React.ReactNode {
|
||||
const ctx = createSearchContext(query, itemId, [], -1);
|
||||
if (!ctx) return text;
|
||||
if (options?.forceAllActive) ctx.forceAllActive = true;
|
||||
return highlightSearchInChildren(text, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process React children to highlight search terms in text nodes.
|
||||
* Preserves the React element tree structure (markdown components, etc.)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||
import {
|
||||
CODE_BG,
|
||||
CODE_BORDER,
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
PROSE_TABLE_BORDER,
|
||||
PROSE_TABLE_HEADER_BG,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
|
@ -48,6 +49,8 @@ interface MarkdownViewerProps {
|
|||
label?: string; // Optional label like "Thinking", "Output", etc.
|
||||
/** When provided, enables search term highlighting within the markdown */
|
||||
itemId?: string;
|
||||
/** Optional override for search highlighting (local search, e.g. Claude logs) */
|
||||
searchQueryOverride?: string;
|
||||
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */
|
||||
copyable?: boolean;
|
||||
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||
|
|
@ -60,6 +63,15 @@ interface MarkdownViewerProps {
|
|||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Custom URL transform that preserves task:// and mention:// protocols.
|
||||
* react-markdown v10 strips non-standard protocols by default.
|
||||
*/
|
||||
function allowCustomProtocols(url: string): string {
|
||||
if (url.startsWith('task://') || url.startsWith('mention://')) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
|
||||
function isRelativeUrl(url: string): boolean {
|
||||
return (
|
||||
|
|
@ -200,13 +212,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
),
|
||||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
// task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem)
|
||||
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
||||
// mention:// links render as colored inline badges
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('mention://')) {
|
||||
const path = href.slice('mention://'.length);
|
||||
const slashIdx = path.indexOf('/');
|
||||
const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
|
||||
let color = '';
|
||||
try {
|
||||
color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
|
||||
} catch {
|
||||
// malformed percent-encoding — use empty color
|
||||
}
|
||||
const colorSet = getTeamColorSet(color);
|
||||
const bg = colorSet.badge;
|
||||
return (
|
||||
|
|
@ -223,6 +240,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
</span>
|
||||
);
|
||||
}
|
||||
if (href?.startsWith('task://')) {
|
||||
const taskId = href.slice('task://'.length);
|
||||
return (
|
||||
<TaskTooltip taskId={taskId}>
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer font-medium no-underline hover:underline"
|
||||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</TaskTooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
|
@ -230,7 +262,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href && !href.startsWith('task://')) {
|
||||
if (href) {
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
|
|
@ -418,6 +450,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
className = '',
|
||||
label,
|
||||
itemId,
|
||||
searchQueryOverride,
|
||||
copyable = false,
|
||||
bare = false,
|
||||
baseDir,
|
||||
|
|
@ -560,10 +593,17 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
}
|
||||
|
||||
// Create search context (fresh each render so counter starts at 0)
|
||||
const effectiveQuery = (searchQueryOverride ?? searchQuery).trim();
|
||||
const effectiveMatches = searchQueryOverride ? [] : searchMatches;
|
||||
const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex;
|
||||
const searchCtx =
|
||||
searchQuery && itemId
|
||||
? createSearchContext(searchQuery, itemId, searchMatches, currentSearchIndex)
|
||||
effectiveQuery && itemId
|
||||
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
|
||||
: null;
|
||||
// Local search (Claude logs): use bright highlight for all matches (no "current result" concept).
|
||||
if (searchCtx && searchQueryOverride) {
|
||||
searchCtx.forceAllActive = true;
|
||||
}
|
||||
|
||||
// Create markdown components with optional search highlighting
|
||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
|
|
@ -629,6 +669,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={allowCustomProtocols}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
|
|
|||
25
src/renderer/components/common/WarningBanner.tsx
Normal file
25
src/renderer/components/common/WarningBanner.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface WarningBannerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WarningBanner = ({
|
||||
children,
|
||||
className = '',
|
||||
icon,
|
||||
}: WarningBannerProps): React.JSX.Element => (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${className}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
{icon ?? <AlertTriangle size={14} className="mt-0.5 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
|||
{tab.type === 'notifications' && <NotificationsView />}
|
||||
{tab.type === 'settings' && <SettingsView />}
|
||||
{tab.type === 'teams' && <TeamListView />}
|
||||
{tab.type === 'team' && <TeamDetailView teamName={tab.teamName ?? ''} />}
|
||||
{tab.type === 'team' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamDetailView teamName={tab.teamName ?? ''} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'session' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<SessionTabContent tab={tab} isActive={isActive} />
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => {
|
|||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<AppLogo size={22} className="shrink-0" />
|
||||
</div>
|
||||
{isMacElectron && (
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<AppLogo size={22} className="shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
|
|||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
Activity,
|
||||
|
|
@ -66,6 +67,13 @@ export const SortableTab = ({
|
|||
)
|
||||
);
|
||||
|
||||
const teamColor = useStore((s) => {
|
||||
if (tab.type !== 'team' || !tab.teamName) return null;
|
||||
const team = s.teamByName[tab.teamName];
|
||||
return team?.color ?? null;
|
||||
});
|
||||
const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
data: {
|
||||
|
|
@ -81,13 +89,18 @@ export const SortableTab = ({
|
|||
transition: isDragging ? 'none' : transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
backgroundColor: isActive
|
||||
? 'var(--color-surface-raised)'
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-raised)'
|
||||
: isHovered
|
||||
? 'var(--color-surface-overlay)'
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-overlay)'
|
||||
: 'transparent',
|
||||
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
||||
};
|
||||
|
||||
const Icon = TAB_ICONS[tab.type];
|
||||
|
|
@ -112,11 +125,7 @@ export const SortableTab = ({
|
|||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isActive}
|
||||
className={
|
||||
isTeamTab
|
||||
? 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab flex-col rounded-md'
|
||||
: 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5'
|
||||
}
|
||||
className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
||||
style={style}
|
||||
onClick={(e) => onTabClick(tab.id, e)}
|
||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||
|
|
@ -130,32 +139,18 @@ export const SortableTab = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className={isTeamTab ? 'flex min-w-0 items-center gap-2 px-3 pb-0.5 pt-1' : 'contents'}>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{tab.fromSearch && (
|
||||
<span title="Opened from search">
|
||||
<Search className="size-3 shrink-0 text-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<span title="Pinned session">
|
||||
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{tab.fromSearch && (
|
||||
<span title="Opened from search">
|
||||
<Search className="size-3 shrink-0 text-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<span title="Pinned session">
|
||||
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
{isTeamTab && (
|
||||
<TeamTabSectionNav
|
||||
teamName={tab.teamName!}
|
||||
|
|
@ -169,6 +164,18 @@ export const SortableTab = ({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -302,13 +302,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
scrollContainerRef.current = el;
|
||||
setDroppableRef(el);
|
||||
}}
|
||||
className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto"
|
||||
className="scrollbar-none flex min-w-0 flex-1 items-center gap-1"
|
||||
style={
|
||||
{
|
||||
maxWidth: '75%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
|
@ -351,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
Gives users a reliable window-drag target regardless of how many tabs are open.
|
||||
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
|
||||
<div
|
||||
className="flex-1 self-stretch"
|
||||
className="min-w-[48px] flex-1 self-stretch"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ChevronDown, Columns3, History, MessageSquare, Users } from 'lucide-react';
|
||||
import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
|
|||
{ id: 'team', label: 'Team', icon: Users },
|
||||
{ id: 'sessions', label: 'Sessions', icon: History },
|
||||
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
|
||||
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
|
||||
{ id: 'messages', label: 'Messages', icon: MessageSquare },
|
||||
];
|
||||
|
||||
|
|
@ -70,11 +71,11 @@ export const TeamTabSectionNav = ({
|
|||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<div className="shrink-0" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="flex h-3.5 w-full items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
className="flex size-4 items-center justify-center rounded-sm text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((prev) => !prev);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ export const TriggerPreview = ({
|
|||
|
||||
{/* Truncation warning - only shown when timeout or count limit hit */}
|
||||
{previewResult.truncated && (
|
||||
<div className="flex items-center gap-2 rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-400">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>
|
||||
Search stopped early (timeout or count limit). Actual matches may be higher.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Provides UI for managing notifications, display settings, and advanced options.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
|
@ -23,15 +23,14 @@ export const SettingsView = (): React.JSX.Element | null => {
|
|||
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
|
||||
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
|
||||
|
||||
// Consume pending section during render (React-recommended pattern for adjusting state on prop change)
|
||||
const [prevPending, setPrevPending] = useState<string | null>(null);
|
||||
if (pendingSettingsSection !== prevPending) {
|
||||
setPrevPending(pendingSettingsSection);
|
||||
// Consume pending section (avoid setState during render)
|
||||
useEffect(() => {
|
||||
if (pendingSettingsSection) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setActiveSection(pendingSettingsSection as SettingsSection);
|
||||
clearPendingSettingsSection();
|
||||
}
|
||||
}
|
||||
}, [pendingSettingsSection, clearPendingSettingsSection]);
|
||||
|
||||
const {
|
||||
config,
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import appIcon from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||
import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||
|
||||
import { SettingsSectionHeader } from '../components';
|
||||
|
||||
import { CliStatusSection } from './CliStatusSection';
|
||||
import { ConfigEditorDialog } from './ConfigEditorDialog';
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
readonly saving: boolean;
|
||||
|
|
@ -30,6 +31,7 @@ export const AdvancedSection = ({
|
|||
}: AdvancedSectionProps): React.JSX.Element => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
const checkForUpdates = useStore((s) => s.checkForUpdates);
|
||||
|
|
@ -95,6 +97,17 @@ export const AdvancedSection = ({
|
|||
<div>
|
||||
<SettingsSectionHeader title="Configuration" />
|
||||
<div className="space-y-2 py-2">
|
||||
<button
|
||||
onClick={() => setConfigEditorOpen(true)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<FileEdit className="size-4" />
|
||||
Edit Config
|
||||
</button>
|
||||
<button
|
||||
onClick={onResetToDefaults}
|
||||
disabled={saving}
|
||||
|
|
@ -195,6 +208,14 @@ export const AdvancedSection = ({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigEditorDialog
|
||||
open={configEditorOpen}
|
||||
onClose={() => setConfigEditorOpen(false)}
|
||||
onConfigSaved={() => {
|
||||
// Config saved via editor — settings page will pick up changes on next render
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
fetchCliStatus,
|
||||
installCli,
|
||||
isBusy,
|
||||
cliStatusLoading,
|
||||
} = useCliInstaller();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
{cliStatus.installed && !cliStatus.updateAvailable && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
disabled={cliStatusLoading}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
{cliStatusLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
416
src/renderer/components/settings/sections/ConfigEditorDialog.tsx
Normal file
416
src/renderer/components/settings/sections/ConfigEditorDialog.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
/**
|
||||
* ConfigEditorDialog — inline JSON config editor powered by CodeMirror.
|
||||
*
|
||||
* Opens as a dialog, shows the full app config as formatted JSON.
|
||||
* Auto-saves on changes with debounce. Shows validation errors for malformed JSON.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { type Diagnostic, linter, lintGutter } from '@codemirror/lint';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { AlertTriangle, Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
// =============================================================================
|
||||
// JSON Linter
|
||||
// =============================================================================
|
||||
|
||||
const jsonLinter = linter((view: EditorView) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const text = view.state.doc.toString();
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const match = /position (\d+)/.exec(e.message);
|
||||
const pos = match ? parseInt(match[1], 10) : 0;
|
||||
const safePos = Math.min(pos, text.length);
|
||||
diagnostics.push({
|
||||
from: safePos,
|
||||
to: Math.min(safePos + 1, text.length),
|
||||
severity: 'error',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ConfigEditorDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfigSaved: (config: AppConfig) => void;
|
||||
}
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const ConfigEditorDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfigSaved,
|
||||
}: ConfigEditorDialogProps): React.JSX.Element | null => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const initialConfigRef = useRef<string>('');
|
||||
|
||||
const saveConfig = useCallback(
|
||||
async (jsonText: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as AppConfig;
|
||||
setJsonError(null);
|
||||
setSaveStatus('saving');
|
||||
|
||||
// Save each section separately via existing API
|
||||
if (parsed.general) {
|
||||
await api.config.update('general', parsed.general);
|
||||
}
|
||||
if (parsed.notifications) {
|
||||
await api.config.update('notifications', parsed.notifications);
|
||||
}
|
||||
if (parsed.display) {
|
||||
await api.config.update('display', parsed.display);
|
||||
}
|
||||
if (parsed.sessions) {
|
||||
await api.config.update('sessions', parsed.sessions);
|
||||
}
|
||||
|
||||
// Re-fetch to get the canonical saved state
|
||||
const fresh = await api.config.get();
|
||||
onConfigSaved(fresh);
|
||||
useStore.setState({ appConfig: fresh });
|
||||
initialConfigRef.current = JSON.stringify(fresh, null, 2);
|
||||
|
||||
setSaveStatus('saved');
|
||||
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
|
||||
savedRevertTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
setJsonError(e.message);
|
||||
setSaveStatus('idle');
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
setJsonError(e instanceof Error ? e.message : 'Failed to save config');
|
||||
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
|
||||
savedRevertTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
setJsonError(null);
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onConfigSaved]
|
||||
);
|
||||
|
||||
const scheduleSave = useCallback(
|
||||
(jsonText: string) => {
|
||||
// Validate JSON before scheduling save
|
||||
try {
|
||||
JSON.parse(jsonText);
|
||||
setJsonError(null);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
setJsonError(e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
void saveConfig(jsonText);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[saveConfig]
|
||||
);
|
||||
|
||||
// Initialize CodeMirror when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let destroyed = false;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setLoading(true);
|
||||
setSaveStatus('idle');
|
||||
setJsonError(null);
|
||||
|
||||
const init = async (): Promise<void> => {
|
||||
try {
|
||||
const config = await api.config.get();
|
||||
if (destroyed) return;
|
||||
|
||||
const jsonText = JSON.stringify(config, null, 2);
|
||||
initialConfigRef.current = jsonText;
|
||||
setLoading(false);
|
||||
|
||||
// Wait for DOM render
|
||||
requestAnimationFrame(() => {
|
||||
if (destroyed || !editorRef.current) return;
|
||||
|
||||
// Clean up existing view
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: jsonText,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
json(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
jsonLinter,
|
||||
lintGutter(),
|
||||
search(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
||||
baseEditorTheme,
|
||||
configEditorTheme,
|
||||
// eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const text = update.state.doc.toString();
|
||||
scheduleSave(text);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: editorRef.current,
|
||||
});
|
||||
viewRef.current = view;
|
||||
});
|
||||
} catch (e) {
|
||||
if (destroyed) return;
|
||||
setLoading(false);
|
||||
setJsonError(e instanceof Error ? e.message : 'Failed to load config');
|
||||
}
|
||||
};
|
||||
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
|
||||
};
|
||||
}, [open, scheduleSave]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Edit Configuration
|
||||
</h2>
|
||||
<SaveStatusBadge status={saveStatus} error={jsonError} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{loading ? (
|
||||
<div
|
||||
className="flex h-96 items-center justify-center gap-2 text-sm"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading config...
|
||||
</div>
|
||||
) : (
|
||||
<div ref={editorRef} className="config-editor-container h-full min-h-[400px]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className="flex items-center justify-between border-t px-4 py-2.5"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Changes auto-save after editing
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd
|
||||
className="rounded px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-muted)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
Esc
|
||||
</kbd>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Save Status Badge
|
||||
// =============================================================================
|
||||
|
||||
const SaveStatusBadge = ({
|
||||
status,
|
||||
error,
|
||||
}: {
|
||||
status: SaveStatus;
|
||||
error: string | null;
|
||||
}): React.JSX.Element | null => {
|
||||
if (status === 'idle' && !error) return null;
|
||||
|
||||
if (error && status !== 'saving') {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}
|
||||
title={error}
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{status === 'error' ? 'Save failed' : 'Invalid JSON'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'saving') {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{ backgroundColor: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'saved') {
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
|
||||
style={{ backgroundColor: 'rgba(74, 222, 128, 0.15)', color: '#4ade80' }}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
Saved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Editor Theme Override
|
||||
// =============================================================================
|
||||
|
||||
const configEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
maxHeight: 'calc(85vh - 100px)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
padding: '8px 0',
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0 8px',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
paddingLeft: '4px',
|
||||
},
|
||||
});
|
||||
|
|
@ -366,11 +366,16 @@ export const GeneralSection = ({
|
|||
confirmLabel: 'Restart',
|
||||
});
|
||||
if (shouldRelaunch) {
|
||||
onGeneralToggle('useNativeTitleBar', v);
|
||||
// Small delay to let config persist before relaunch
|
||||
setTimeout(() => {
|
||||
void window.electronAPI?.windowControls?.relaunch();
|
||||
}, 200);
|
||||
// Await config write before relaunch to avoid race condition on Windows
|
||||
// (antivirus/NTFS can delay file writes beyond a fixed timeout)
|
||||
try {
|
||||
await api.config.update('general', { useNativeTitleBar: v });
|
||||
} catch {
|
||||
// If save fails, still try to toggle via the normal path
|
||||
onGeneralToggle('useNativeTitleBar', v);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
void window.electronAPI?.windowControls?.relaunch();
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
|
|
@ -503,7 +508,7 @@ export const GeneralSection = ({
|
|||
{candidate.path}
|
||||
</p>
|
||||
{!candidate.hasProjectsDir && (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||
No projects directory detected
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ type VirtualItem =
|
|||
* Mismatch causes items to overlap!
|
||||
*/
|
||||
const HEADER_HEIGHT = 28;
|
||||
const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx
|
||||
const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx
|
||||
const LOADER_HEIGHT = 36;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
|
@ -96,6 +97,7 @@ export const GlobalTaskList = ({
|
|||
globalTasksLoading,
|
||||
globalTasksInitialized,
|
||||
fetchAllTasks,
|
||||
softDeleteTask,
|
||||
projects,
|
||||
viewMode,
|
||||
repositoryGroups,
|
||||
|
|
@ -106,6 +108,7 @@ export const GlobalTaskList = ({
|
|||
globalTasksLoading: s.globalTasksLoading,
|
||||
globalTasksInitialized: s.globalTasksInitialized,
|
||||
fetchAllTasks: s.fetchAllTasks,
|
||||
softDeleteTask: s.softDeleteTask,
|
||||
projects: s.projects,
|
||||
viewMode: s.viewMode,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
|
|
@ -145,6 +148,29 @@ export const GlobalTaskList = ({
|
|||
setRenamingTaskKey(null);
|
||||
};
|
||||
|
||||
const handleDeleteTask = async (teamName: string, taskId: string): Promise<void> => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete task',
|
||||
message: `Move task #${taskId} to trash?`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
try {
|
||||
await softDeleteTask(teamName, taskId);
|
||||
await fetchAllTasks();
|
||||
} catch (err) {
|
||||
void confirm({
|
||||
title: 'Failed to delete task',
|
||||
message: err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||
confirmLabel: 'OK',
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch tasks on mount — loading guard in the store action prevents
|
||||
// duplicate IPC calls when the centralized init chain is already fetching.
|
||||
useEffect(() => {
|
||||
|
|
@ -224,6 +250,7 @@ export const GlobalTaskList = ({
|
|||
// Reset showArchived when archive becomes empty
|
||||
useEffect(() => {
|
||||
if (showArchived && !hasArchivedTasks) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setShowArchived(false);
|
||||
}
|
||||
}, [showArchived, hasArchivedTasks]);
|
||||
|
|
@ -329,6 +356,7 @@ export const GlobalTaskList = ({
|
|||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
|
|
@ -425,6 +453,7 @@ export const GlobalTaskList = ({
|
|||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
|
|
@ -472,6 +501,7 @@ export const GlobalTaskList = ({
|
|||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
|
|
@ -523,6 +553,7 @@ export const GlobalTaskList = ({
|
|||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
|
|
|
|||
|
|
@ -240,13 +240,13 @@ export const SessionItem = ({
|
|||
}
|
||||
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]);
|
||||
|
||||
// Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll
|
||||
// Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`h-[48px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
|
||||
className={`h-[58px] w-full overflow-hidden border-b px-3 py-2 text-left transition-colors ${isActive ? '' : 'bg-transparent hover:bg-surface-raised'}`}
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
|
||||
|
|
@ -268,7 +268,7 @@ export const SessionItem = ({
|
|||
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
|
||||
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium leading-tight"
|
||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
||||
>
|
||||
{session.firstMessage ?? 'Untitled'}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export const SidebarTaskItem = ({
|
|||
// Reset edit value when renaming starts
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setEditValue(displaySubject);
|
||||
}
|
||||
}, [isRenaming, displaySubject]);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@renderer/components/ui/context-menu';
|
||||
import { Archive, ArchiveRestore, Pencil, Pin, PinOff } from 'lucide-react';
|
||||
import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { GlobalTask } from '@shared/types';
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ export interface TaskContextMenuProps {
|
|||
onTogglePin: () => void;
|
||||
onToggleArchive: () => void;
|
||||
onRename: () => void;
|
||||
onDelete?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
|
|||
onTogglePin,
|
||||
onToggleArchive,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
}: TaskContextMenuProps): React.JSX.Element => {
|
||||
return (
|
||||
|
|
@ -68,6 +70,19 @@ export const TaskContextMenu = ({
|
|||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
|
||||
{onDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={onDelete}
|
||||
className="text-red-400 focus:text-red-400"
|
||||
>
|
||||
<Trash2 className="size-3.5 shrink-0" />
|
||||
<span>Delete task</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
|
|
|||
213
src/renderer/components/team/ClaudeLogsFilterPopover.tsx
Normal file
213
src/renderer/components/team/ClaudeLogsFilterPopover.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
export type ClaudeLogStream = 'stdout' | 'stderr';
|
||||
export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
|
||||
|
||||
export interface ClaudeLogsFilterState {
|
||||
streams: Set<ClaudeLogStream>;
|
||||
kinds: Set<ClaudeLogKind>;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
|
||||
streams: new Set<ClaudeLogStream>(['stdout', 'stderr']),
|
||||
kinds: new Set<ClaudeLogKind>(['output', 'thinking', 'tool']),
|
||||
};
|
||||
|
||||
function setEquals<T>(a: Set<T>, b: Set<T>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const v of a) if (!b.has(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getActiveCount(filter: ClaudeLogsFilterState): number {
|
||||
let count = 0;
|
||||
if (!setEquals(filter.streams, DEFAULT_CLAUDE_LOGS_FILTER.streams)) count += 1;
|
||||
if (!setEquals(filter.kinds, DEFAULT_CLAUDE_LOGS_FILTER.kinds)) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
interface ClaudeLogsFilterPopoverProps {
|
||||
filter: ClaudeLogsFilterState;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApply: (filter: ClaudeLogsFilterState) => void;
|
||||
}
|
||||
|
||||
export const ClaudeLogsFilterPopover = ({
|
||||
filter,
|
||||
open,
|
||||
onOpenChange,
|
||||
onApply,
|
||||
}: ClaudeLogsFilterPopoverProps): React.JSX.Element => {
|
||||
const [draft, setDraft] = useState<ClaudeLogsFilterState>(() => ({
|
||||
streams: new Set(filter.streams),
|
||||
kinds: new Set(filter.kinds),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const next = { streams: new Set(filter.streams), kinds: new Set(filter.kinds) };
|
||||
queueMicrotask(() => setDraft(next));
|
||||
}, [open, filter.streams, filter.kinds]);
|
||||
|
||||
const activeCount = useMemo(() => getActiveCount(filter), [filter]);
|
||||
const draftCount = useMemo(() => getActiveCount(draft), [draft]);
|
||||
|
||||
const toggleStream = (stream: ClaudeLogStream): void => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.streams);
|
||||
if (next.has(stream)) next.delete(stream);
|
||||
else next.add(stream);
|
||||
// Prevent empty selection (keep at least one)
|
||||
if (next.size === 0) {
|
||||
next.add(stream);
|
||||
}
|
||||
return { ...prev, streams: next };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKind = (kind: ClaudeLogKind): void => {
|
||||
setDraft((prev) => {
|
||||
const next = new Set(prev.kinds);
|
||||
if (next.has(kind)) next.delete(kind);
|
||||
else next.add(kind);
|
||||
// Prevent empty selection (keep at least one)
|
||||
if (next.size === 0) {
|
||||
next.add(kind);
|
||||
}
|
||||
return { ...prev, kinds: next };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
onApply(draft);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = (): void => {
|
||||
const empty = {
|
||||
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
||||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
||||
};
|
||||
setDraft(empty);
|
||||
onApply(empty);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Filter Claude logs"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Filter logs</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Stream
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Content
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
<label
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
disabled={draftCount === 0}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
431
src/renderer/components/team/ClaudeLogsSection.tsx
Normal file
431
src/renderer/components/team/ClaudeLogsSection.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Search, Terminal, X } from 'lucide-react';
|
||||
|
||||
import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||
import { CliLogsRichView } from './CliLogsRichView';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
|
||||
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
|
||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const POLL_MS = 2000;
|
||||
const ONLINE_WINDOW_MS = 10_000;
|
||||
|
||||
type StreamType = 'stdout' | 'stderr';
|
||||
|
||||
interface ClaudeLogsSectionProps {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
function isRecent(updatedAt: string | undefined): boolean {
|
||||
if (!updatedAt) return false;
|
||||
const t = Date.parse(updatedAt);
|
||||
if (Number.isNaN(t)) return false;
|
||||
return Date.now() - t <= ONLINE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
||||
// We want to feed CliLogsRichView the exact format it expects:
|
||||
// - marker lines: "[stdout]" / "[stderr]"
|
||||
// - raw JSON lines without any "[stdout] " prefix
|
||||
const chronological = [...linesNewestFirst].reverse();
|
||||
|
||||
const out: string[] = [];
|
||||
let lastStream: StreamType | null = null;
|
||||
|
||||
const pushMarker = (stream: StreamType): void => {
|
||||
if (lastStream === stream) return;
|
||||
lastStream = stream;
|
||||
out.push(stream === 'stdout' ? '[stdout]' : '[stderr]');
|
||||
};
|
||||
|
||||
for (const rawLine of chronological) {
|
||||
const line = rawLine ?? '';
|
||||
if (line === '[stdout]' || line === '[stderr]') {
|
||||
lastStream = line === '[stdout]' ? 'stdout' : 'stderr';
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[stdout] ')) {
|
||||
pushMarker('stdout');
|
||||
out.push(line.slice('[stdout] '.length));
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('[stderr] ')) {
|
||||
pushMarker('stderr');
|
||||
out.push(line.slice('[stderr] '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
type AssistantContentBlock =
|
||||
| { type: 'text'; text?: string }
|
||||
| { type: 'thinking'; thinking?: string }
|
||||
| { type: 'tool_use'; id?: string; name?: string; input?: Record<string, unknown> }
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
function filterStreamJsonText(
|
||||
linesNewestFirst: string[],
|
||||
queryRaw: string,
|
||||
filter: ClaudeLogsFilterState
|
||||
): string {
|
||||
const q = queryRaw.trim().toLowerCase();
|
||||
const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n');
|
||||
|
||||
let currentStream: StreamType | null = null;
|
||||
let lastEmittedStream: StreamType | null = null;
|
||||
const out: string[] = [];
|
||||
|
||||
const emitMarker = (): void => {
|
||||
if (!currentStream) return;
|
||||
if (lastEmittedStream === currentStream) return;
|
||||
out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]');
|
||||
lastEmittedStream = currentStream;
|
||||
};
|
||||
|
||||
const extractBlocks = (parsed: Record<string, unknown>): AssistantContentBlock[] | null => {
|
||||
if (parsed.type !== 'assistant') return null;
|
||||
if (Array.isArray(parsed.content)) {
|
||||
return parsed.content as AssistantContentBlock[];
|
||||
}
|
||||
const msg = parsed.message;
|
||||
if (msg && typeof msg === 'object') {
|
||||
const inner = msg as Record<string, unknown>;
|
||||
if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const writeBlocks = (
|
||||
parsed: Record<string, unknown>,
|
||||
blocks: AssistantContentBlock[]
|
||||
): Record<string, unknown> => {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
return { ...parsed, content: blocks };
|
||||
}
|
||||
const msg = parsed.message;
|
||||
if (msg && typeof msg === 'object') {
|
||||
return { ...parsed, message: { ...(msg as Record<string, unknown>), content: blocks } };
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
for (const rawLine of chronological) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line) continue;
|
||||
|
||||
if (line === '[stdout]' || line === '[stderr]') {
|
||||
currentStream = line === '[stdout]' ? 'stdout' : 'stderr';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentStream && !filter.streams.has(currentStream)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Non-JSON lines are ignored to keep view consistent with CliLogsRichView.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') continue;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const blocks = extractBlocks(obj);
|
||||
if (!blocks) {
|
||||
// Keep only assistant messages for now (CliLogsRichView renders these richly).
|
||||
continue;
|
||||
}
|
||||
|
||||
const filteredBlocks = blocks.filter((b) => {
|
||||
if (!b || typeof b !== 'object') return false;
|
||||
if (b.type === 'text') return filter.kinds.has('output');
|
||||
if (b.type === 'thinking') return filter.kinds.has('thinking');
|
||||
if (b.type === 'tool_use') return filter.kinds.has('tool');
|
||||
// Unknown block types: keep (they're rare, and dropping can hide content)
|
||||
return true;
|
||||
});
|
||||
if (filteredBlocks.length === 0) continue;
|
||||
|
||||
const searchTextParts: string[] = [];
|
||||
for (const b of filteredBlocks) {
|
||||
if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text);
|
||||
if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking);
|
||||
if (b.type === 'tool_use') {
|
||||
if (typeof b.name === 'string') searchTextParts.push(b.name);
|
||||
if (b.input && typeof b.input === 'object') {
|
||||
try {
|
||||
searchTextParts.push(JSON.stringify(b.input));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const haystack = searchTextParts.join('\n').toLowerCase();
|
||||
if (q && !haystack.includes(q)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
emitMarker();
|
||||
const nextObj = writeBlocks(obj, filteredBlocks);
|
||||
out.push(JSON.stringify(nextObj));
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
const [pendingNewCount, setPendingNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const atTopRef = useRef(true);
|
||||
const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const committedRef = useRef<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const pendingCountRef = useRef(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
|
||||
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
||||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
||||
}));
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
setData({ lines: [], total: 0, hasMore: false });
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
latestRef.current = null;
|
||||
atTopRef.current = true;
|
||||
setError(null);
|
||||
setSearchQuery('');
|
||||
setFilter({
|
||||
streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams),
|
||||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
||||
});
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
committedRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingCountRef.current = pendingNewCount;
|
||||
}, [pendingNewCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const computeNewCount = (
|
||||
committed: TeamClaudeLogsResponse,
|
||||
latest: TeamClaudeLogsResponse
|
||||
): number => {
|
||||
if (committed.lines.length === 0) return latest.lines.length;
|
||||
const marker = committed.lines[0];
|
||||
const idx = latest.lines.indexOf(marker);
|
||||
if (idx >= 0) return idx;
|
||||
const diff =
|
||||
(latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
|
||||
return Math.max(0, diff);
|
||||
};
|
||||
|
||||
const fetchLogs = async (): Promise<void> => {
|
||||
if (inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
setLoading(true);
|
||||
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
|
||||
if (cancelled) return;
|
||||
latestRef.current = next;
|
||||
if (atTopRef.current) {
|
||||
setData(next);
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
} else {
|
||||
setPending(next);
|
||||
const base = computeNewCount(committedRef.current, next);
|
||||
setPendingNewCount((prev) => Math.max(prev, base));
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchLogs();
|
||||
const id = window.setInterval(() => void fetchLogs(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [teamName, visibleCount]);
|
||||
|
||||
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
|
||||
const badge = data.total > 0 ? data.total : undefined;
|
||||
const showMoreVisible = data.hasMore;
|
||||
|
||||
const headerExtra = online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const filteredText = useMemo(() => {
|
||||
if (data.lines.length === 0) return '';
|
||||
const isDefault =
|
||||
filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size &&
|
||||
filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size &&
|
||||
[...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) &&
|
||||
[...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k));
|
||||
|
||||
if (!searchQuery.trim() && isDefault) {
|
||||
return normalizeToStreamJsonText(data.lines);
|
||||
}
|
||||
return filterStreamJsonText(data.lines, searchQuery, filter);
|
||||
}, [data.lines, searchQuery, filter]);
|
||||
|
||||
const applyPending = (): void => {
|
||||
const latest = latestRef.current ?? pending;
|
||||
if (!latest) return;
|
||||
setData(latest);
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
// Jump to newest
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="claude-logs"
|
||||
title="Claude logs"
|
||||
icon={<Terminal size={14} />}
|
||||
badge={badge}
|
||||
headerExtra={headerExtra}
|
||||
defaultOpen
|
||||
contentClassName="pt-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
|
||||
<span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
) : (
|
||||
'Team is not running.'
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ClaudeLogsFilterPopover
|
||||
filter={filter}
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
onApply={setFilter}
|
||||
/>
|
||||
{pendingNewCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
|
||||
onClick={applyPending}
|
||||
title="Show newest logs"
|
||||
>
|
||||
+{pendingNewCount} new
|
||||
</Button>
|
||||
)}
|
||||
{showMoreVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('rounded', loading && 'opacity-80')}>
|
||||
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||
{!error && filteredText.trim().length > 0 ? (
|
||||
<CliLogsRichView
|
||||
// Parser expects chronological order; UI shows newest-first.
|
||||
cliLogsTail={filteredText}
|
||||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className="max-h-[320px] p-2"
|
||||
containerRefCallback={(el) => {
|
||||
logContainerRef.current = el;
|
||||
}}
|
||||
onScroll={({ scrollTop }) => {
|
||||
const atTop = scrollTop <= 8;
|
||||
atTopRef.current = atTop;
|
||||
if (atTop && pendingCountRef.current > 0) {
|
||||
applyPending();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!error && data.lines.length === 0 ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
||||
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
|
||||
import { Bot, ChevronRight } from 'lucide-react';
|
||||
|
|
@ -18,6 +19,11 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
|||
|
||||
interface CliLogsRichViewProps {
|
||||
cliLogsTail: string;
|
||||
order?: 'oldest-first' | 'newest-first';
|
||||
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
|
||||
containerRefCallback?: (el: HTMLDivElement | null) => void;
|
||||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -43,10 +49,12 @@ const FlatGroupItem = ({
|
|||
group,
|
||||
expandedItemIds,
|
||||
onItemClick,
|
||||
searchQueryOverride,
|
||||
}: {
|
||||
group: StreamJsonGroup;
|
||||
expandedItemIds: Set<string>;
|
||||
onItemClick: (itemId: string) => void;
|
||||
searchQueryOverride?: string;
|
||||
}): React.JSX.Element => {
|
||||
const groupItemIds = useMemo(
|
||||
() => scopedItemIds(expandedItemIds, group.id),
|
||||
|
|
@ -63,6 +71,7 @@ const FlatGroupItem = ({
|
|||
onItemClick={handleItemClick}
|
||||
expandedItemIds={groupItemIds}
|
||||
aiGroupId={group.id}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -76,12 +85,14 @@ const StreamGroup = ({
|
|||
onToggle,
|
||||
expandedItemIds,
|
||||
onItemClick,
|
||||
searchQueryOverride,
|
||||
}: {
|
||||
group: StreamJsonGroup;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedItemIds: Set<string>;
|
||||
onItemClick: (itemId: string) => void;
|
||||
searchQueryOverride?: string;
|
||||
}): React.JSX.Element => {
|
||||
// Scope item IDs to this group to avoid cross-group collisions
|
||||
const groupItemIds = useMemo(
|
||||
|
|
@ -109,7 +120,11 @@ const StreamGroup = ({
|
|||
/>
|
||||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{group.summary}
|
||||
{searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||
? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, {
|
||||
forceAllActive: true,
|
||||
})
|
||||
: group.summary}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
|
|
@ -119,6 +134,7 @@ const StreamGroup = ({
|
|||
onItemClick={handleItemClick}
|
||||
expandedItemIds={groupItemIds}
|
||||
aiGroupId={group.id}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -128,9 +144,13 @@ const StreamGroup = ({
|
|||
|
||||
export const CliLogsRichView = ({
|
||||
cliLogsTail,
|
||||
order = 'oldest-first',
|
||||
onScroll,
|
||||
containerRefCallback,
|
||||
searchQueryOverride,
|
||||
className,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -151,9 +171,13 @@ export const CliLogsRichView = ({
|
|||
// Auto-scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
if (order === 'newest-first') {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [cliLogsTail]);
|
||||
}, [cliLogsTail, order]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupId: string) => {
|
||||
setCollapsedGroupIds((prev) => {
|
||||
|
|
@ -184,11 +208,18 @@ export const CliLogsRichView = ({
|
|||
const hasContent = cliLogsTail.trim().length > 0;
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={(el) => {
|
||||
scrollRef.current = el;
|
||||
containerRefCallback?.(el);
|
||||
}}
|
||||
className={cn(
|
||||
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||
className
|
||||
)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
||||
}}
|
||||
>
|
||||
{hasContent ? (
|
||||
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
|
|
@ -203,9 +234,21 @@ export const CliLogsRichView = ({
|
|||
);
|
||||
}
|
||||
|
||||
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
||||
{groups.map((group) =>
|
||||
<div
|
||||
ref={(el) => {
|
||||
scrollRef.current = el;
|
||||
containerRefCallback?.(el);
|
||||
}}
|
||||
className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
||||
}}
|
||||
>
|
||||
{visibleGroups.map((group) =>
|
||||
group.items.length === 1 ? (
|
||||
// Single item — render flat without collapsible group wrapper
|
||||
<FlatGroupItem
|
||||
|
|
@ -213,6 +256,7 @@ export const CliLogsRichView = ({
|
|||
group={group}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
) : (
|
||||
<StreamGroup
|
||||
|
|
@ -222,6 +266,7 @@ export const CliLogsRichView = ({
|
|||
onToggle={() => handleGroupToggle(group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const CollapsibleTeamSection = ({
|
|||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className={`mt-1.5 min-w-0 overflow-x-clip pb-2 ${contentClassName ?? ''}`}>
|
||||
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -44,27 +44,67 @@ function formatElapsed(seconds: number): string {
|
|||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useElapsedTimer(startedAt?: string): string | null {
|
||||
const [elapsed, setElapsed] = useState<string | null>(null);
|
||||
function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return () => setElapsed(null);
|
||||
if (!startedAt) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setElapsedSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const startMs = Date.parse(startedAt);
|
||||
if (isNaN(startMs)) return () => setElapsed(null);
|
||||
if (isNaN(startMs)) {
|
||||
setElapsedSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const computeElapsedSeconds = (): number =>
|
||||
Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
|
||||
if (!isRunning) {
|
||||
// Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick.
|
||||
setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev));
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = (): void => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
setElapsed(formatElapsed(seconds));
|
||||
setElapsedSeconds(computeElapsedSeconds());
|
||||
};
|
||||
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [startedAt]);
|
||||
}, [startedAt, isRunning]);
|
||||
|
||||
if (!startedAt) return null;
|
||||
return elapsed;
|
||||
if (elapsedSeconds === null) return null;
|
||||
return formatElapsed(elapsedSeconds);
|
||||
}
|
||||
|
||||
function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
|
||||
if (!raw) return null;
|
||||
if (!isError) return raw;
|
||||
|
||||
const looksLikeRawApiEnvelope =
|
||||
raw.includes('API Error: 400') &&
|
||||
(raw.includes('"_requests"') ||
|
||||
raw.includes('"session_id"') ||
|
||||
raw.includes('"parent_tool_use_id"') ||
|
||||
raw.includes('\\u000'));
|
||||
|
||||
if (!looksLikeRawApiEnvelope) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return (
|
||||
'API Error: 400\n\n' +
|
||||
'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' +
|
||||
'Open **CLI logs** below for readable diagnostics.'
|
||||
);
|
||||
}
|
||||
|
||||
export const ProvisioningProgressBlock = ({
|
||||
|
|
@ -81,11 +121,12 @@ export const ProvisioningProgressBlock = ({
|
|||
assistantOutput,
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
|
||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
|
|
@ -99,6 +140,14 @@ export const ProvisioningProgressBlock = ({
|
|||
setLiveOutputOpen(defaultLiveOutputOpen);
|
||||
}, [defaultLiveOutputOpen]);
|
||||
|
||||
// On error with logs available, prioritize logs view over noisy live stream payload.
|
||||
useEffect(() => {
|
||||
if (isError && cliLogsTail) {
|
||||
setLogsOpen(true);
|
||||
setLiveOutputOpen(false);
|
||||
}
|
||||
}, [isError, cliLogsTail]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -137,27 +186,26 @@ export const ProvisioningProgressBlock = ({
|
|||
<p
|
||||
className={cn(
|
||||
'mt-1.5 text-xs',
|
||||
isError ? 'text-red-200' : 'text-[var(--color-text-muted)]'
|
||||
isError ? 'text-[var(--step-error-text)]' : 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-0.5">
|
||||
<div className="mt-2 flex items-center justify-center gap-1 overflow-x-auto pb-0.5">
|
||||
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
|
||||
const isDone = currentStepIndex >= 0 && index < currentStepIndex;
|
||||
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center gap-1">
|
||||
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
|
||||
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
|
||||
isDone &&
|
||||
'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]',
|
||||
isCurrent &&
|
||||
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
|
||||
'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]'
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
|
||||
|
|
@ -165,7 +213,6 @@ export const ProvisioningProgressBlock = ({
|
|||
</span>
|
||||
{STEP_LABELS[step]}
|
||||
</Badge>
|
||||
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
|
||||
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
|
||||
<span className="text-[var(--color-text-muted)]">→</span>
|
||||
) : null}
|
||||
|
|
@ -190,13 +237,13 @@ export const ProvisioningProgressBlock = ({
|
|||
isError && 'border-red-500/40'
|
||||
)}
|
||||
>
|
||||
{assistantOutput ? (
|
||||
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" />
|
||||
{displayAssistantOutput ? (
|
||||
<MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
'text-[11px]',
|
||||
isError ? 'text-red-200/80' : 'text-[var(--color-text-muted)]'
|
||||
isError ? 'text-[var(--step-error-text-dim)]' : 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
>
|
||||
No output captured yet.
|
||||
|
|
|
|||
149
src/renderer/components/team/RoleSelect.tsx
Normal file
149
src/renderer/components/team/RoleSelect.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { Blocks, BookOpen, Bug, Check, Code2, FileText, Pencil, Shield, Zap } from 'lucide-react';
|
||||
|
||||
import type { ComboboxOption } from '@renderer/components/ui/combobox';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
/** Icon mapping for preset roles. */
|
||||
const ROLE_ICONS: Record<string, LucideIcon> = {
|
||||
architect: Blocks,
|
||||
reviewer: BookOpen,
|
||||
developer: Code2,
|
||||
qa: Bug,
|
||||
researcher: BookOpen,
|
||||
docs: FileText,
|
||||
auditor: Shield,
|
||||
optimizer: Zap,
|
||||
};
|
||||
|
||||
const CUSTOM_ICON = Pencil;
|
||||
|
||||
interface RoleSelectProps {
|
||||
/** Current role selection value (preset role name, CUSTOM_ROLE, or NO_ROLE). */
|
||||
value: string;
|
||||
/** Called when the user picks a preset role, NO_ROLE, or CUSTOM_ROLE. */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Current custom role text (only relevant when value === CUSTOM_ROLE). */
|
||||
customRole?: string;
|
||||
/** Called when the user types a custom role. */
|
||||
onCustomRoleChange?: (customRole: string) => void;
|
||||
/** Trigger height class, e.g. "h-7" or "h-8". */
|
||||
triggerClassName?: string;
|
||||
/** Custom input height class. */
|
||||
inputClassName?: string;
|
||||
/** Show validation error for custom role. */
|
||||
customRoleError?: string | null;
|
||||
/** Validate custom role on change and return error or null. */
|
||||
onCustomRoleValidate?: (role: string) => string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const roleOptions: ComboboxOption[] = [
|
||||
{ value: NO_ROLE, label: 'No role' },
|
||||
...PRESET_ROLES.map((role) => ({
|
||||
value: role,
|
||||
label: 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 Icon =
|
||||
option.value === CUSTOM_ROLE
|
||||
? CUSTOM_ICON
|
||||
: option.value === NO_ROLE
|
||||
? null
|
||||
: (ROLE_ICONS[option.value] ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="mr-2 flex size-4 shrink-0 items-center justify-center">
|
||||
{isSelected ? (
|
||||
<Check className="size-3.5" />
|
||||
) : Icon ? (
|
||||
<Icon className="size-3.5 text-[var(--color-text-muted)]" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-medium text-[var(--color-text)]">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoleSelect = ({
|
||||
value,
|
||||
onValueChange,
|
||||
customRole = '',
|
||||
onCustomRoleChange,
|
||||
triggerClassName,
|
||||
inputClassName,
|
||||
customRoleError: externalError,
|
||||
onCustomRoleValidate,
|
||||
disabled,
|
||||
}: RoleSelectProps): React.JSX.Element => {
|
||||
const [internalError, setInternalError] = useState<string | null>(null);
|
||||
const error = externalError ?? internalError;
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string) => {
|
||||
onValueChange(newValue);
|
||||
if (newValue !== CUSTOM_ROLE) {
|
||||
setInternalError(null);
|
||||
}
|
||||
},
|
||||
[onValueChange]
|
||||
);
|
||||
|
||||
const handleCustomChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
onCustomRoleChange?.(val);
|
||||
|
||||
if (onCustomRoleValidate) {
|
||||
setInternalError(onCustomRoleValidate(val));
|
||||
} else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) {
|
||||
setInternalError('This role is reserved');
|
||||
} else {
|
||||
setInternalError(null);
|
||||
}
|
||||
},
|
||||
[onCustomRoleChange, onCustomRoleValidate]
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const opt = roleOptions.find((o) => o.value === value);
|
||||
return opt?.label;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Combobox
|
||||
options={roleOptions}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
placeholder={selectedLabel ?? 'No role'}
|
||||
searchPlaceholder="Search roles..."
|
||||
emptyMessage="No roles found."
|
||||
disabled={disabled}
|
||||
className={triggerClassName}
|
||||
renderOption={renderRoleOption}
|
||||
/>
|
||||
{value === CUSTOM_ROLE && onCustomRoleChange ? (
|
||||
<div>
|
||||
<Input
|
||||
className={inputClassName ?? 'h-8 text-xs'}
|
||||
value={customRole}
|
||||
onChange={handleCustomChange}
|
||||
placeholder="Enter custom role..."
|
||||
autoFocus
|
||||
/>
|
||||
{error ? <span className="mt-0.5 block text-[10px] text-red-400">{error}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
src/renderer/components/team/TaskTooltip.tsx
Normal file
127
src/renderer/components/team/TaskTooltip.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import type { TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Status/kanban-column display colors.
|
||||
* Matches the kanban column palette from KanbanBoard.tsx.
|
||||
*/
|
||||
const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
|
||||
pending: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' }, // blue
|
||||
todo: { text: '#60a5fa', bg: 'rgba(59, 130, 246, 0.15)' },
|
||||
in_progress: { text: '#facc15', bg: 'rgba(234, 179, 8, 0.15)' }, // yellow
|
||||
completed: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' }, // green
|
||||
done: { text: '#4ade80', bg: 'rgba(34, 197, 94, 0.15)' },
|
||||
review: { text: '#a78bfa', bg: 'rgba(139, 92, 246, 0.15)' }, // purple
|
||||
approved: { text: '#34d399', bg: 'rgba(34, 197, 94, 0.25)' }, // bright green
|
||||
deleted: { text: '#f87171', bg: 'rgba(239, 68, 68, 0.15)' }, // red
|
||||
};
|
||||
|
||||
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
||||
if (task.kanbanColumn) return task.kanbanColumn;
|
||||
if (task.status === 'pending') return 'todo';
|
||||
if (task.status === 'completed') return 'done';
|
||||
return task.status;
|
||||
}
|
||||
|
||||
function getStatusLabel(column: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
todo: 'To Do',
|
||||
pending: 'To Do',
|
||||
in_progress: 'In Progress',
|
||||
done: 'Done',
|
||||
completed: 'Done',
|
||||
review: 'Review',
|
||||
approved: 'Approved',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
return labels[column] ?? column;
|
||||
}
|
||||
|
||||
interface TaskTooltipProps {
|
||||
/** The task ID (number string, e.g. "10"). */
|
||||
taskId: string;
|
||||
/** Rendered trigger element. */
|
||||
children: React.ReactElement;
|
||||
/** Tooltip placement. */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip that shows task summary on hover over any #taskId link.
|
||||
* Reads task data from the current team in the store.
|
||||
*/
|
||||
export const TaskTooltip = ({
|
||||
taskId,
|
||||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
const tasks = useStore((s) => s.selectedTeamData?.tasks);
|
||||
const members = useStore((s) => s.selectedTeamData?.members);
|
||||
|
||||
const task = useMemo(
|
||||
() => tasks?.find((t) => t.id === taskId),
|
||||
[tasks, taskId]
|
||||
);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
);
|
||||
|
||||
// If task not found, render children without tooltip
|
||||
if (!task) return children;
|
||||
|
||||
const column = getEffectiveColumn(task);
|
||||
const statusColor = STATUS_COLORS[column] ?? STATUS_COLORS.pending;
|
||||
const label = getStatusLabel(column);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className="max-w-xs space-y-1.5 p-2.5"
|
||||
>
|
||||
{/* Subject */}
|
||||
<div className="text-xs font-medium text-[var(--color-text)]">
|
||||
<span className="text-[var(--color-text-muted)]">#{taskId}</span>{' '}
|
||||
{task.subject}
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ color: statusColor.text, backgroundColor: statusColor.bg }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Owner */}
|
||||
{task.owner ? (
|
||||
<MemberBadge
|
||||
name={task.owner}
|
||||
color={colorMap.get(task.owner)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description — full markdown with scroll */}
|
||||
{task.description ? (
|
||||
<div className="max-h-[200px] overflow-y-auto text-[10px]">
|
||||
<MarkdownViewer content={task.description} maxHeight="max-h-none" bare />
|
||||
</div>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
};
|
||||
}, [electronMode, teams]);
|
||||
|
||||
// Refresh alive teams when opening the create dialog so conflict warning is accurate.
|
||||
useEffect(() => {
|
||||
if (!electronMode || !showCreateDialog) return;
|
||||
let cancelled = false;
|
||||
void api.teams
|
||||
.aliveList()
|
||||
.then((list) => {
|
||||
if (!cancelled) setAliveTeams(list);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronMode, showCreateDialog]);
|
||||
|
||||
const currentProjectPath = useMemo(() => {
|
||||
if (viewMode === 'grouped') {
|
||||
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||
|
|
@ -367,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
void deleteTeam(teamName);
|
||||
try {
|
||||
await deleteTeam(teamName);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
|
@ -377,7 +396,13 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const handleRestoreTeam = useCallback(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void restoreTeam(teamName);
|
||||
void (async () => {
|
||||
try {
|
||||
await restoreTeam(teamName);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
})();
|
||||
},
|
||||
[restoreTeam]
|
||||
);
|
||||
|
|
@ -394,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
variant: 'danger',
|
||||
});
|
||||
if (confirmed) {
|
||||
void permanentlyDeleteTeam(teamName);
|
||||
try {
|
||||
await permanentlyDeleteTeam(teamName);
|
||||
} catch {
|
||||
// error via store
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
|
@ -527,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={teamsLoading}
|
||||
onClick={() => {
|
||||
void fetchTeams();
|
||||
}}
|
||||
>
|
||||
{teamsLoading ? (
|
||||
<RotateCcw className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({
|
|||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
|
||||
<p className="flex-1 text-xs text-red-200">{progress.message}</p>
|
||||
<p className="flex-1 text-xs text-[var(--step-error-text)]">{progress.message}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-[var(--step-error-text)] hover:bg-red-500/10"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
|
@ -108,13 +108,13 @@ export const TeamProvisioningBanner = ({
|
|||
if (isReady) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
|
||||
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
|
||||
<p className="flex-1 text-xs text-emerald-200">Team launched — process alive</p>
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2">
|
||||
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">Team launched — process alive</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 border-emerald-500/40 px-2 text-xs text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200"
|
||||
className="h-6 shrink-0 border-[var(--step-done-border)] px-2 text-xs text-[var(--step-done-text)] hover:bg-[var(--step-done-bg)]"
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
|
|||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
CARD_BG,
|
||||
|
|
@ -174,25 +176,25 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
|
|||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render `#<digits>` in plain text as clickable inline elements. */
|
||||
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
|
||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||
return text.split(/(#\d+)/g).map((part, i) => {
|
||||
const match = /^#(\d+)$/.exec(part);
|
||||
if (!match) return <span key={i}>{part}</span>;
|
||||
const taskId = match[1];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="cursor-pointer font-medium text-blue-400 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(taskId);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
<TaskTooltip key={i} taskId={taskId}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer font-medium text-blue-400 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(taskId);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</TaskTooltip>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -233,25 +235,30 @@ export const ActivityItem = ({
|
|||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs + @mentions
|
||||
const displayText = useMemo(() => {
|
||||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||
const strippedText = useMemo(() => {
|
||||
if (structured) return null;
|
||||
const stripped = stripAgentBlocks(message.text).trim();
|
||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||
// Normalize literal \n from CLI tools (teamctl.js) to real newlines
|
||||
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
let result = normalized;
|
||||
if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result);
|
||||
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}, [structured, message.text]);
|
||||
|
||||
// Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name
|
||||
// into markdown links which breaks the reply regex matcher
|
||||
const parsedReply = useMemo(
|
||||
() => (strippedText ? parseMessageReply(strippedText) : null),
|
||||
[strippedText]
|
||||
);
|
||||
|
||||
// Linkify task IDs (always, for TaskTooltip) + @mentions for display
|
||||
const displayText = useMemo(() => {
|
||||
if (!strippedText) return null;
|
||||
let result = linkifyTaskIdsInMarkdown(strippedText);
|
||||
if (memberColorMap && memberColorMap.size > 0)
|
||||
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||
return result;
|
||||
}, [structured, message.text, onTaskIdClick, memberColorMap]);
|
||||
|
||||
// Check if this is a reply message
|
||||
const parsedReply = useMemo(
|
||||
() => (displayText ? parseMessageReply(displayText) : null),
|
||||
[displayText]
|
||||
);
|
||||
}, [strippedText, memberColorMap]);
|
||||
|
||||
const rawSummary =
|
||||
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
|
||||
|
|
@ -276,25 +283,34 @@ export const ActivityItem = ({
|
|||
};
|
||||
|
||||
const isHeaderClickable = Boolean(systemLabel);
|
||||
const isUserSent = message.source === 'user_sent';
|
||||
const isSystemMessage = message.from === 'system';
|
||||
|
||||
return (
|
||||
<article
|
||||
className="group overflow-hidden rounded-md"
|
||||
className="group rounded-md [overflow:clip]"
|
||||
style={{
|
||||
marginLeft: isUserSent ? 15 : undefined,
|
||||
backgroundColor:
|
||||
rateLimited || isApiError
|
||||
? 'var(--tool-result-error-bg)'
|
||||
: zebraShade
|
||||
? CARD_BG_ZEBRA
|
||||
: CARD_BG,
|
||||
: isSystemMessage
|
||||
? 'var(--system-activity-bg)'
|
||||
: zebraShade
|
||||
? CARD_BG_ZEBRA
|
||||
: CARD_BG,
|
||||
border:
|
||||
rateLimited || isApiError
|
||||
? '1px solid var(--tool-result-error-border)'
|
||||
: CARD_BORDER_STYLE,
|
||||
: isSystemMessage
|
||||
? '1px solid var(--system-activity-border)'
|
||||
: CARD_BORDER_STYLE,
|
||||
borderLeft:
|
||||
rateLimited || isApiError
|
||||
? '3px solid var(--tool-result-error-text)'
|
||||
: `3px solid ${colors.border}`,
|
||||
: isSystemMessage
|
||||
? '3px solid var(--system-activity-accent)'
|
||||
: `3px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||
|
|
@ -336,7 +352,7 @@ export const ActivityItem = ({
|
|||
<MemberBadge
|
||||
name={message.from}
|
||||
color={memberColor ?? message.color}
|
||||
hideAvatar={message.from === 'user'}
|
||||
hideAvatar={message.from === 'user' || message.from === 'system'}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
|
||||
|
|
@ -467,27 +483,29 @@ export const ActivityItem = ({
|
|||
</details>
|
||||
</div>
|
||||
) : parsedReply ? (
|
||||
<ReplyQuoteBlock reply={parsedReply} />
|
||||
<ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} />
|
||||
) : displayText ? (
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
<ExpandableContent>
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare />
|
||||
</span>
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayText} maxHeight="max-h-none" copyable bare />
|
||||
</span>
|
||||
</ExpandableContent>
|
||||
) : summaryText ? (
|
||||
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{summaryText}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||
|
||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
messages: InboxMessage[];
|
||||
|
|
@ -158,7 +159,7 @@ export const ActivityTimeline = ({
|
|||
if (leadMember) {
|
||||
const leadInfo = memberInfo.get(leadMember.name);
|
||||
if (leadInfo) {
|
||||
memberInfo.set('user', { role: leadInfo.role, color: colorMap.get('user') });
|
||||
memberInfo.set('user', { role: undefined, color: colorMap.get('user') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -185,29 +186,50 @@ export const ActivityTimeline = ({
|
|||
[messages, visibleCount, hiddenCount]
|
||||
);
|
||||
|
||||
// Zebra striping: alternate shade on non-noise (full card) messages only.
|
||||
// Group consecutive lead thoughts into collapsible blocks.
|
||||
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
|
||||
|
||||
// Zebra striping: alternate shade on non-noise (full card) items only.
|
||||
const zebraShadeSet = useMemo(() => {
|
||||
const result = new Set<number>();
|
||||
let cardCount = 0;
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
if (isNoiseMessage(visibleMessages[i].text)) continue;
|
||||
if (cardCount % 2 === 1) result.add(i);
|
||||
cardCount++;
|
||||
for (let i = 0; i < timelineItems.length; i++) {
|
||||
const item = timelineItems[i];
|
||||
if (item.type === 'lead-thoughts') {
|
||||
// Thought groups count as one card for striping
|
||||
if (cardCount % 2 === 1) result.add(i);
|
||||
cardCount++;
|
||||
} else {
|
||||
if (isNoiseMessage(item.message.text)) continue;
|
||||
if (cardCount % 2 === 1) result.add(i);
|
||||
cardCount++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [visibleMessages]);
|
||||
}, [timelineItems]);
|
||||
|
||||
// Determine which messages are "new" (should animate).
|
||||
// Determine which items are "new" (should animate).
|
||||
|
||||
const newMessageKeys = useMemo(() => {
|
||||
const getKey = (msg: InboxMessage, idx: number): string =>
|
||||
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`;
|
||||
const newItemKeys = useMemo(() => {
|
||||
const getItemKey = (item: TimelineItem): string => {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
// Stable key: identify group by its first thought, not by count (which changes)
|
||||
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`;
|
||||
}
|
||||
const msg = item.message;
|
||||
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
||||
};
|
||||
|
||||
const allKeys: string[] = [];
|
||||
for (const item of timelineItems) {
|
||||
allKeys.push(getItemKey(item));
|
||||
}
|
||||
|
||||
// First render: seed known keys, no animations
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
return new Set<string>();
|
||||
|
|
@ -218,23 +240,22 @@ export const ActivityTimeline = ({
|
|||
prevVisibleCountRef.current = visibleCount;
|
||||
|
||||
if (isPaginationExpansion) {
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Normal update: unknown keys are new messages
|
||||
// Normal update: unknown keys are new items
|
||||
const newKeys = new Set<string>();
|
||||
for (let i = 0; i < visibleMessages.length; i++) {
|
||||
const key = getKey(visibleMessages[i], i);
|
||||
for (const key of allKeys) {
|
||||
if (!knownKeysRef.current.has(key)) {
|
||||
newKeys.add(key);
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
}
|
||||
return newKeys;
|
||||
}, [visibleMessages, visibleCount]);
|
||||
}, [timelineItems, visibleCount]);
|
||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
|
|
@ -254,37 +275,83 @@ export const ActivityTimeline = ({
|
|||
);
|
||||
}
|
||||
|
||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
||||
item.type === 'lead-thoughts'
|
||||
? item.group.thoughts[0].leadSessionId
|
||||
: item.message.leadSessionId;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{visibleMessages.map((message, index) => {
|
||||
{timelineItems.map((item, index) => {
|
||||
// Session boundary separator (messages sorted desc — new on top)
|
||||
let sessionSeparator: React.JSX.Element | null = null;
|
||||
if (index > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[index - 1]);
|
||||
const currSessionId = getItemSessionId(item);
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
sessionSeparator = (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 30, paddingBottom: 30 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
<span className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'lead-thoughts') {
|
||||
const { group } = item;
|
||||
const firstThought = group.thoughts[0];
|
||||
const info = memberInfo.get(firstThought.from);
|
||||
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
||||
return (
|
||||
<React.Fragment key={itemKey}>
|
||||
{sessionSeparator}
|
||||
<LeadThoughtsGroupRow
|
||||
group={group}
|
||||
memberColor={info?.color}
|
||||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const { message } = item;
|
||||
const info = memberInfo.get(message.from);
|
||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||
const recipientColor =
|
||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||
const globalIndex = index;
|
||||
const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`;
|
||||
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
||||
const isUnread = readState
|
||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||
: !message.read;
|
||||
return (
|
||||
<MessageRowWithObserver
|
||||
key={messageKey}
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
memberRole={info?.role}
|
||||
memberColor={info?.color}
|
||||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
onVisible={onMessageVisible}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
/>
|
||||
<React.Fragment key={messageKey}>
|
||||
{sessionSeparator}
|
||||
<MessageRowWithObserver
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
memberRole={info?.role}
|
||||
memberColor={info?.color}
|
||||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newItemKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
onVisible={onMessageVisible}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
|
|
|
|||
233
src/renderer/components/team/activity/LeadThoughtsGroup.tsx
Normal file
233
src/renderer/components/team/activity/LeadThoughtsGroup.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import {
|
||||
CARD_BG,
|
||||
CARD_BORDER_STYLE,
|
||||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
export interface LeadThoughtGroup {
|
||||
type: 'lead-thoughts';
|
||||
thoughts: InboxMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is an intermediate lead "thought" (assistant text) rather than
|
||||
* an official message (SendMessage, direct reply, inbox, etc.).
|
||||
*/
|
||||
export function isLeadThought(msg: InboxMessage): boolean {
|
||||
if (msg.source === 'lead_session') return true;
|
||||
if (msg.source === 'lead_process' && msg.messageId?.startsWith('lead-text-')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export type TimelineItem =
|
||||
| { type: 'message'; message: InboxMessage; originalIndex: number }
|
||||
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] };
|
||||
|
||||
/**
|
||||
* Group consecutive lead thoughts into collapsible blocks.
|
||||
* Single thoughts remain as regular messages.
|
||||
*/
|
||||
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||
const result: TimelineItem[] = [];
|
||||
let pendingThoughts: InboxMessage[] = [];
|
||||
let pendingIndices: number[] = [];
|
||||
|
||||
const flushThoughts = (): void => {
|
||||
if (pendingThoughts.length === 0) return;
|
||||
if (pendingThoughts.length === 1) {
|
||||
result.push({
|
||||
type: 'message',
|
||||
message: pendingThoughts[0],
|
||||
originalIndex: pendingIndices[0],
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: 'lead-thoughts',
|
||||
group: { type: 'lead-thoughts', thoughts: pendingThoughts },
|
||||
originalIndices: pendingIndices,
|
||||
});
|
||||
}
|
||||
pendingThoughts = [];
|
||||
pendingIndices = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (isLeadThought(msg)) {
|
||||
pendingThoughts.push(msg);
|
||||
pendingIndices.push(i);
|
||||
} else {
|
||||
flushThoughts();
|
||||
result.push({ type: 'message', message: msg, originalIndex: i });
|
||||
}
|
||||
}
|
||||
flushThoughts();
|
||||
return result;
|
||||
}
|
||||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
const LIVE_WINDOW_MS = 10_000;
|
||||
const AUTO_SCROLL_THRESHOLD = 30;
|
||||
|
||||
interface LeadThoughtsGroupRowProps {
|
||||
group: LeadThoughtGroup;
|
||||
memberColor?: string;
|
||||
isNew?: boolean;
|
||||
onVisible?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (Number.isNaN(d.getTime())) return timestamp;
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatTimeWithSec(timestamp: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (Number.isNaN(d.getTime())) return timestamp;
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function isRecentTimestamp(timestamp: string): boolean {
|
||||
const t = Date.parse(timestamp);
|
||||
if (Number.isNaN(t)) return false;
|
||||
return Date.now() - t <= LIVE_WINDOW_MS;
|
||||
}
|
||||
|
||||
export const LeadThoughtsGroupRow = ({
|
||||
group,
|
||||
memberColor,
|
||||
isNew,
|
||||
onVisible,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrolledUpRef = useRef(false);
|
||||
|
||||
const colors = getTeamColorSet(memberColor ?? '');
|
||||
const { thoughts } = group;
|
||||
// thoughts is newest-first; first=newest, last=oldest
|
||||
const newest = thoughts[0];
|
||||
const oldest = thoughts[thoughts.length - 1];
|
||||
const leadName = newest.from;
|
||||
|
||||
// Chronological order for rendering (oldest at top, newest at bottom)
|
||||
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||
|
||||
// Live indicator: newest thought is from lead_process and recent
|
||||
const computeIsLive = useCallback(
|
||||
() => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp),
|
||||
[newest.source, newest.timestamp]
|
||||
);
|
||||
const [isLive, setIsLive] = useState(computeIsLive);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLive(computeIsLive());
|
||||
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [computeIsLive]);
|
||||
|
||||
// Track how many thoughts have been reported as visible so far.
|
||||
const reportedCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onVisible) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting) return;
|
||||
const alreadyReported = reportedCountRef.current;
|
||||
if (alreadyReported >= thoughts.length) return;
|
||||
for (let i = alreadyReported; i < thoughts.length; i++) {
|
||||
onVisible(thoughts[i]);
|
||||
}
|
||||
reportedCountRef.current = thoughts.length;
|
||||
},
|
||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [onVisible, thoughts]);
|
||||
|
||||
// Auto-scroll to bottom when new thoughts arrive
|
||||
useEffect(() => {
|
||||
if (isUserScrolledUpRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [thoughts.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<article
|
||||
className="group rounded-md [overflow:clip]"
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
opacity: isLive ? undefined : 0.75,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex select-none items-center gap-2 px-3 py-1.5">
|
||||
{/* Live / offline indicator */}
|
||||
{isLive ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
|
||||
)}
|
||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body — fixed height, always visible */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="space-y-px border-t px-3 py-1.5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--scrollbar-thumb) transparent',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{chronologicalThoughts.map((thought, idx) => (
|
||||
<div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]">
|
||||
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,29 +1,65 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
|
||||
import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
|
||||
interface ReplyQuoteBlockProps {
|
||||
reply: ParsedMessageReply;
|
||||
/** Color name for the quoted agent (resolved from memberColorMap). */
|
||||
memberColor?: string;
|
||||
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
|
||||
bodyMaxHeight?: string;
|
||||
}
|
||||
|
||||
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
||||
const LONG_QUOTE_THRESHOLD = 200;
|
||||
|
||||
export const ReplyQuoteBlock = ({
|
||||
reply,
|
||||
memberColor,
|
||||
bodyMaxHeight = 'max-h-56',
|
||||
}: ReplyQuoteBlockProps): React.JSX.Element => (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
|
||||
style={{ opacity: 0.7 }}
|
||||
>
|
||||
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
@{reply.agentName}
|
||||
</span>
|
||||
<div className="line-clamp-3 text-xs text-[var(--color-text-muted)]">
|
||||
<MarkdownViewer content={reply.originalText} maxHeight="max-h-[60px]" bare />
|
||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Quote block — styled like SendMessageDialog */}
|
||||
<div className="relative overflow-hidden rounded-md border border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||
{/* Decorative quotation mark */}
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-400/[0.08]">
|
||||
“
|
||||
</span>
|
||||
|
||||
{/* "Replying to" + MemberBadge */}
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-blue-300/60">Replying to</span>
|
||||
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Quote text */}
|
||||
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
|
||||
<MarkdownViewer content={reply.originalText} bare maxHeight={quoteMaxHeight} />
|
||||
</div>
|
||||
|
||||
{/* More/less toggle */}
|
||||
{isLong ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'less' : 'more'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Reply text */}
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
|
||||
</div>
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
|
|||
</div>
|
||||
{lightboxIndex !== null && items[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
src={items[lightboxIndex].dataUrl}
|
||||
alt={items[lightboxIndex].meta.filename}
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { formatFileSize } from '@renderer/utils/attachmentUtils';
|
||||
import { X } from 'lucide-react';
|
||||
import { Ban, X } from 'lucide-react';
|
||||
|
||||
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
||||
|
||||
|
|
@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types';
|
|||
interface AttachmentPreviewItemProps {
|
||||
attachment: AttachmentPayload;
|
||||
onRemove: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AttachmentPreviewItem = ({
|
||||
attachment,
|
||||
onRemove,
|
||||
disabled,
|
||||
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
||||
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
||||
|
||||
return (
|
||||
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
|
||||
{disabled ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/50">
|
||||
<Ban size={18} className="text-red-400" />
|
||||
</div>
|
||||
) : null}
|
||||
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ interface AttachmentPreviewListProps {
|
|||
attachments: AttachmentPayload[];
|
||||
onRemove: (id: string) => void;
|
||||
error?: string | null;
|
||||
/** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */
|
||||
disabled?: boolean;
|
||||
/** Hint text shown when disabled and attachments are present. */
|
||||
disabledHint?: string;
|
||||
}
|
||||
|
||||
export const AttachmentPreviewList = ({
|
||||
attachments,
|
||||
onRemove,
|
||||
error,
|
||||
disabled,
|
||||
disabledHint,
|
||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||
if (attachments.length === 0 && !error) return null;
|
||||
|
||||
|
|
@ -22,10 +28,24 @@ export const AttachmentPreviewList = ({
|
|||
{attachments.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto py-1">
|
||||
{attachments.map((att) => (
|
||||
<AttachmentPreviewItem key={att.id} attachment={att} onRemove={onRemove} />
|
||||
<AttachmentPreviewItem
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
onRemove={onRemove}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{disabled && disabledHint && attachments.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
||||
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
||||
>
|
||||
<AlertCircle size={13} className="shrink-0" />
|
||||
<p className="text-[11px]">{disabledHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5">
|
||||
<AlertCircle size={13} className="shrink-0 text-red-400" />
|
||||
|
|
|
|||
|
|
@ -1,58 +1,86 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import 'yet-another-react-lightbox/styles.css';
|
||||
import 'yet-another-react-lightbox/plugins/counter.css';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
import Counter from 'yet-another-react-lightbox/plugins/counter';
|
||||
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
||||
|
||||
import type { Plugin, Slide } from 'yet-another-react-lightbox';
|
||||
|
||||
export interface ImageLightboxSlide {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ImageLightboxProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Array of slides for gallery mode. */
|
||||
slides?: ImageLightboxSlide[];
|
||||
/** Starting slide index (default: 0). */
|
||||
index?: number;
|
||||
/** Single image src — convenience shorthand for `slides={[{ src }]}`. */
|
||||
src?: string;
|
||||
/** Alt text for single-image mode. */
|
||||
alt?: string;
|
||||
enableZoom?: boolean;
|
||||
enableFullscreen?: boolean;
|
||||
showCounter?: boolean;
|
||||
}
|
||||
|
||||
export const ImageLightbox = ({
|
||||
src,
|
||||
alt = 'Image',
|
||||
open,
|
||||
onClose,
|
||||
slides: slidesProp,
|
||||
index = 0,
|
||||
src,
|
||||
alt,
|
||||
enableZoom = true,
|
||||
enableFullscreen = true,
|
||||
showCounter,
|
||||
}: ImageLightboxProps): React.JSX.Element | null => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const slides = useMemo<Slide[]>(() => {
|
||||
if (slidesProp && slidesProp.length > 0) {
|
||||
return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title }));
|
||||
}
|
||||
if (src) {
|
||||
return [{ src, alt }];
|
||||
}
|
||||
return [];
|
||||
}, [slidesProp, src, alt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, handleKeyDown]);
|
||||
const plugins = useMemo<Plugin[]>(() => {
|
||||
const list: Plugin[] = [];
|
||||
if (enableZoom) list.push(Zoom);
|
||||
if (enableFullscreen) list.push(Fullscreen);
|
||||
// Show counter only when multiple slides (unless explicitly set)
|
||||
const shouldShowCounter = showCounter ?? slides.length > 1;
|
||||
if (shouldShowCounter) list.push(Counter);
|
||||
return list;
|
||||
}, [enableZoom, enableFullscreen, showCounter, slides.length]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!open || slides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 border-0 bg-transparent p-0"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-10 max-h-[85vh] max-w-[90vw] border-0 bg-transparent p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded-lg object-contain shadow-2xl"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<Lightbox
|
||||
open={open}
|
||||
close={onClose}
|
||||
slides={slides}
|
||||
index={index}
|
||||
plugins={plugins}
|
||||
carousel={{ finite: slides.length <= 1 }}
|
||||
animation={{ fade: 200 }}
|
||||
zoom={{
|
||||
maxZoomPixelRatio: 5,
|
||||
scrollToZoom: true,
|
||||
}}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,16 +12,15 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
|
|
@ -28,8 +28,12 @@ interface AddMemberDialogProps {
|
|||
teamName: string;
|
||||
existingNames: string[];
|
||||
onClose: () => void;
|
||||
onAdd: (name: string, role?: string) => void;
|
||||
onAdd: (name: string, role?: string, workflow?: string) => void;
|
||||
adding?: boolean;
|
||||
/** Project path for @file mentions in workflow field. */
|
||||
projectPath?: string | null;
|
||||
/** Existing team members for @mention suggestions. */
|
||||
existingMembers?: ResolvedTeamMember[];
|
||||
}
|
||||
|
||||
export const AddMemberDialog = ({
|
||||
|
|
@ -39,12 +43,36 @@ export const AddMemberDialog = ({
|
|||
onClose,
|
||||
onAdd,
|
||||
adding,
|
||||
projectPath,
|
||||
existingMembers = [],
|
||||
}: AddMemberDialogProps): React.JSX.Element => {
|
||||
const [name, setName] = useState('');
|
||||
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
|
||||
const [customRole, setCustomRole] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const draftKey = `addMember:${teamName}:workflow`;
|
||||
const workflowDraft = useDraftPersistence({
|
||||
key: draftKey,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Pre-warm file list cache for @file mentions
|
||||
useFileListCacheWarmer(open && projectPath ? projectPath : null);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
existingMembers
|
||||
.filter((m) => !m.removedAt)
|
||||
.map((m) => ({
|
||||
id: m.name,
|
||||
name: m.name,
|
||||
subtitle: m.role ?? undefined,
|
||||
color: m.color,
|
||||
})),
|
||||
[existingMembers]
|
||||
);
|
||||
|
||||
const effectiveRole =
|
||||
roleSelect === CUSTOM_ROLE
|
||||
? customRole.trim()
|
||||
|
|
@ -72,7 +100,9 @@ export const AddMemberDialog = ({
|
|||
return;
|
||||
}
|
||||
setError(null);
|
||||
onAdd(name.trim().toLowerCase(), effectiveRole);
|
||||
const wf = workflowDraft.value.trim() || undefined;
|
||||
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
|
||||
workflowDraft.clearDraft();
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
|
|
@ -80,11 +110,20 @@ export const AddMemberDialog = ({
|
|||
setName('');
|
||||
setRoleSelect(NO_ROLE);
|
||||
setCustomRole('');
|
||||
workflowDraft.setValue('');
|
||||
workflowDraft.clearDraft();
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowChange = useCallback(
|
||||
(v: string) => {
|
||||
workflowDraft.setValue(v);
|
||||
},
|
||||
[workflowDraft]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
|
@ -113,27 +152,31 @@ export const AddMemberDialog = ({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label className="label-optional">Role (optional)</Label>
|
||||
<Select value={roleSelect} onValueChange={setRoleSelect}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{PRESET_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{roleSelect === CUSTOM_ROLE && (
|
||||
<Input
|
||||
placeholder="Custom role"
|
||||
value={customRole}
|
||||
onChange={(e) => setCustomRole(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<RoleSelect
|
||||
value={roleSelect}
|
||||
onValueChange={setRoleSelect}
|
||||
customRole={customRole}
|
||||
onCustomRoleChange={setCustomRole}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="label-optional">Workflow (optional)</Label>
|
||||
<MentionableTextarea
|
||||
className="text-xs"
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
value={workflowDraft.value}
|
||||
onValueChange={handleWorkflowChange}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={projectPath ?? undefined}
|
||||
placeholder="How this agent should behave, what tasks it handles..."
|
||||
footerRight={
|
||||
workflowDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -89,28 +89,45 @@ export const CreateTaskDialog = ({
|
|||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||
const [blockedBySearch, setBlockedBySearch] = useState('');
|
||||
const [relatedSearch, setRelatedSearch] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
const prevOpenRef = useRef(false);
|
||||
|
||||
if (open && !prevOpen) {
|
||||
setSubject(defaultSubject);
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
descriptionDraft.setValue(token + '\n');
|
||||
descChipDraft.setChips([defaultChip]);
|
||||
} else if (defaultDescription) {
|
||||
descriptionDraft.setValue(defaultDescription);
|
||||
// Reset form when dialog opens (avoid setState during render)
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setSubject(defaultSubject);
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
descriptionDraft.setValue(token + '\n');
|
||||
descChipDraft.setChips([defaultChip]);
|
||||
} else if (defaultDescription) {
|
||||
descriptionDraft.setValue(defaultDescription);
|
||||
descChipDraft.clearChipDraft();
|
||||
} else {
|
||||
descriptionDraft.clearDraft();
|
||||
descChipDraft.clearChipDraft();
|
||||
}
|
||||
setOwner(defaultOwner);
|
||||
setBlockedBy([]);
|
||||
setRelated([]);
|
||||
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
|
||||
promptDraft.clearDraft();
|
||||
setBlockedBySearch('');
|
||||
setRelatedSearch('');
|
||||
}
|
||||
setOwner(defaultOwner);
|
||||
setBlockedBy([]);
|
||||
setRelated([]);
|
||||
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
|
||||
promptDraft.clearDraft();
|
||||
setBlockedBySearch('');
|
||||
setRelatedSearch('');
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [
|
||||
open,
|
||||
defaultSubject,
|
||||
defaultDescription,
|
||||
defaultOwner,
|
||||
defaultStartImmediately,
|
||||
defaultChip,
|
||||
isTeamAlive,
|
||||
descriptionDraft,
|
||||
descChipDraft,
|
||||
promptDraft,
|
||||
]);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
|
|
@ -229,9 +246,16 @@ export const CreateTaskDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
|
||||
<p className="text-xs leading-relaxed text-amber-300">
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<p className="text-xs leading-relaxed">
|
||||
Team is offline. The task will be added to <strong>TODO</strong> — launch the
|
||||
team to start execution.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
|
|||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||
|
||||
const conflictingTeam = useMemo(() => {
|
||||
if (!launchTeam) return null;
|
||||
if (!activeTeams?.length || !effectiveCwd) return null;
|
||||
const norm = normalizePath(effectiveCwd);
|
||||
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
||||
}, [activeTeams, effectiveCwd]);
|
||||
}, [activeTeams, effectiveCwd, launchTeam]);
|
||||
|
||||
// Reset dismiss when conflict target changes
|
||||
useEffect(() => {
|
||||
|
|
@ -554,6 +555,19 @@ export const CreateTeamDialog = ({
|
|||
})();
|
||||
};
|
||||
|
||||
const handleTeamNameChange = (value: string): void => {
|
||||
setTeamName(value);
|
||||
setFieldErrors((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;
|
||||
if (!rest.members && !rest.cwd && localError === 'Check form fields') {
|
||||
setLocalError(null);
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
|
@ -575,22 +589,32 @@ export const CreateTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{conflictingTeam && !conflictDismissed ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<div
|
||||
className="rounded-md border p-3 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-amber-300">
|
||||
Team “{conflictingTeam.displayName}” is already running in this
|
||||
project
|
||||
<p className="font-medium">
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</p>
|
||||
<p className="text-amber-300/80">
|
||||
<p className="opacity-80">
|
||||
Running two teams in the same directory is risky — they may conflict editing the
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] opacity-70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-amber-400/60 transition-colors hover:text-amber-300"
|
||||
className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
|
||||
onClick={() => setConflictDismissed(true)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -613,7 +637,11 @@ export const CreateTeamDialog = ({
|
|||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -629,7 +657,14 @@ export const CreateTeamDialog = ({
|
|||
) : null}
|
||||
|
||||
{!canCreate ? (
|
||||
<p className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-300">
|
||||
<p
|
||||
className="rounded border p-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
Available only in local Electron mode.
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -641,7 +676,7 @@ export const CreateTeamDialog = ({
|
|||
id="team-name"
|
||||
className="h-8 text-xs"
|
||||
value={teamName}
|
||||
onChange={(event) => setTeamName(event.target.value)}
|
||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||
placeholder="team-alpha"
|
||||
/>
|
||||
{existingTeamNames.includes(sanitizedTeamName) ? (
|
||||
|
|
|
|||
|
|
@ -36,10 +36,17 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
</Label>
|
||||
</div>
|
||||
{checked && (
|
||||
<div className="mt-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<div
|
||||
className="mt-1.5 rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<div className="space-y-1 text-amber-300/90">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
|
||||
subscribers, extra usage is billed separately.
|
||||
|
|
@ -48,7 +55,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
Requires API tier 4+ or extra usage enabled.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-amber-200"
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
onClick={() =>
|
||||
window.electronAPI.openExternal(
|
||||
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'
|
||||
|
|
|
|||
|
|
@ -314,22 +314,32 @@ export const LaunchTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{conflictingTeam && !conflictDismissed ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<div
|
||||
className="rounded-md border p-3 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium text-amber-300">
|
||||
Team “{conflictingTeam.displayName}” is already running in this
|
||||
project
|
||||
<p className="font-medium">
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</p>
|
||||
<p className="text-amber-300/80">
|
||||
<p className="opacity-80">
|
||||
Running two teams in the same directory is risky — they may conflict editing the
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] opacity-70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-amber-400/60 transition-colors hover:text-amber-300"
|
||||
className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
|
||||
onClick={() => setConflictDismissed(true)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -352,7 +362,11 @@ export const LaunchTeamDialog = ({
|
|||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
<p
|
||||
key={warning}
|
||||
className="text-[11px]"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -396,7 +410,7 @@ export const LaunchTeamDialog = ({
|
|||
chips={chipDraft.chips}
|
||||
onChipRemove={chipDraft.removeChip}
|
||||
onFileChipInsert={chipDraft.addChip}
|
||||
placeholder="Instructions for team lead... Use @ to mention team members."
|
||||
placeholder="Instructions for team lead..."
|
||||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
|
|
@ -435,10 +449,17 @@ export const LaunchTeamDialog = ({
|
|||
</Label>
|
||||
</div>
|
||||
{clearContext && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<div
|
||||
className="rounded-md border px-3 py-2 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<p className="text-amber-300/90">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
The team lead will start a new session without resuming previous context. All
|
||||
accumulated session memory and conversation history will not be available.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const ProjectPathSelector = ({
|
|||
) : null}
|
||||
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
||||
{!projectsLoading && projects.length === 0 ? (
|
||||
<p className="text-[11px] text-amber-300">No projects found, switch to custom path.</p>
|
||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>No projects found, switch to custom path.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -155,9 +155,13 @@ export const ProjectPathSelector = ({
|
|||
size="sm"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
const paths = await api.config.selectFolders();
|
||||
if (paths.length > 0) {
|
||||
onCustomCwdChange(paths[0]);
|
||||
try {
|
||||
const paths = await api.config.selectFolders();
|
||||
if (paths.length > 0) {
|
||||
onCustomCwdChange(paths[0]);
|
||||
}
|
||||
} catch {
|
||||
// IPC error — dialog may have been cancelled or failed
|
||||
}
|
||||
})();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -14,16 +14,9 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
|
|
@ -32,8 +25,9 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { ImagePlus, X } from 'lucide-react';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
||||
|
|
@ -46,6 +40,8 @@ interface QuotedMessage {
|
|||
text: string;
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
teamName: string;
|
||||
|
|
@ -69,8 +65,6 @@ interface SendMessageDialogProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NO_MEMBER = '__none__';
|
||||
|
||||
export const SendMessageDialog = ({
|
||||
open,
|
||||
teamName,
|
||||
|
|
@ -91,11 +85,11 @@ export const SendMessageDialog = ({
|
|||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
const [member, setMember] = useState('');
|
||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
||||
const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
|
||||
const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
|
||||
const prevOpenRef = useRef(false);
|
||||
const prevResultRef = useRef<SendMessageResult | null>(null);
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
|
@ -114,35 +108,48 @@ export const SendMessageDialog = ({
|
|||
|
||||
const selectedMember = members.find((m) => m.name === member);
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
|
||||
// Reset form when dialog opens
|
||||
if (open && !prevOpen) {
|
||||
setMember(defaultRecipient ?? '');
|
||||
setSummary('');
|
||||
setQuote(quotedMessage);
|
||||
setQuoteExpanded(false);
|
||||
setPrevResult(lastResult);
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
textDraft.setValue(token + '\n');
|
||||
chipDraft.setChips([defaultChip]);
|
||||
} else if (defaultText) {
|
||||
textDraft.setValue(defaultText);
|
||||
}
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
}
|
||||
|
||||
// Track whether auto-close is needed (setState in render phase is fine)
|
||||
const [pendingAutoClose, setPendingAutoClose] = useState(false);
|
||||
if (open && lastResult && lastResult !== prevResult) {
|
||||
setPrevResult(lastResult);
|
||||
setMember('');
|
||||
setSummary('');
|
||||
setPendingAutoClose(true);
|
||||
}
|
||||
// Reset form on open transition (avoid setState in render)
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) {
|
||||
setMember(defaultRecipient ?? '');
|
||||
setSummary('');
|
||||
setQuote(quotedMessage);
|
||||
setQuoteExpanded(false);
|
||||
prevResultRef.current = lastResult;
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
textDraft.setValue(token + '\n');
|
||||
chipDraft.setChips([defaultChip]);
|
||||
} else if (defaultText) {
|
||||
textDraft.setValue(defaultText);
|
||||
}
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [
|
||||
open,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
defaultChip,
|
||||
quotedMessage,
|
||||
lastResult,
|
||||
textDraft,
|
||||
chipDraft,
|
||||
]);
|
||||
|
||||
// Track whether auto-close is needed (avoid setState in render)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (lastResult && lastResult !== prevResultRef.current) {
|
||||
prevResultRef.current = lastResult;
|
||||
setMember('');
|
||||
setSummary('');
|
||||
setPendingAutoClose(true);
|
||||
}
|
||||
}, [open, lastResult]);
|
||||
|
||||
// Side effects (onClose mutates parent state) must run in useEffect, not render phase
|
||||
useEffect(() => {
|
||||
|
|
@ -170,11 +177,20 @@ export const SendMessageDialog = ({
|
|||
[members, colorMap]
|
||||
);
|
||||
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
|
||||
const trimmedText = textDraft.value.trim();
|
||||
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
||||
const remaining = MAX_MESSAGE_LENGTH - finalText.length;
|
||||
|
||||
const canSend =
|
||||
member.trim().length > 0 &&
|
||||
textDraft.value.trim().length > 0 &&
|
||||
finalText.length > 0 &&
|
||||
finalText.length <= MAX_MESSAGE_LENGTH &&
|
||||
summary.trim().length > 0 &&
|
||||
!sending;
|
||||
!sending &&
|
||||
!attachmentsBlocked;
|
||||
|
||||
const handleChipRemove = (chipId: string): void => {
|
||||
const chip = chipDraft.chips.find((c) => c.id === chipId);
|
||||
|
|
@ -186,8 +202,6 @@ export const SendMessageDialog = ({
|
|||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSend) return;
|
||||
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
||||
onSend(
|
||||
member.trim(),
|
||||
finalText,
|
||||
|
|
@ -254,7 +268,7 @@ export const SendMessageDialog = ({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[560px]"
|
||||
className="sm:max-w-[720px]"
|
||||
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||
onDragOver={canAttach ? handleDragOver : undefined}
|
||||
|
|
@ -271,40 +285,13 @@ export const SendMessageDialog = ({
|
|||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="smd-recipient">Recipient</Label>
|
||||
<Select
|
||||
value={member || NO_MEMBER}
|
||||
onValueChange={(v) => setMember(v === NO_MEMBER ? '' : v)}
|
||||
>
|
||||
<SelectTrigger id="smd-recipient">
|
||||
<SelectValue placeholder="Select member..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MemberSelect
|
||||
members={members}
|
||||
value={member || null}
|
||||
onChange={(v) => setMember(v ?? '')}
|
||||
placeholder="Select member..."
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -351,6 +338,8 @@ export const SendMessageDialog = ({
|
|||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
|
|
@ -401,7 +390,7 @@ export const SendMessageDialog = ({
|
|||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
className={quote ? 'rounded-t-none' : undefined}
|
||||
placeholder="Write your message..."
|
||||
placeholder="Write your message... (Enter to send)"
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
@ -409,12 +398,43 @@ export const SendMessageDialog = ({
|
|||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
|
||||
onModEnter={handleSubmit}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
disabled={sending}
|
||||
cornerAction={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSend}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<Send size={12} />
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
<div className="flex items-center gap-2">
|
||||
{sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
{sendError}
|
||||
</span>
|
||||
) : null}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
Draft saved
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -433,17 +453,12 @@ export const SendMessageDialog = ({
|
|||
Shown as notification preview. Team lead also sees this for peer messages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canSend}>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useCallback, useEffect, 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 { useStore } from '@renderer/store';
|
||||
import { ImagePlus, Loader2, Trash2, X } from 'lucide-react';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
|
||||
import type { TaskAttachmentMeta } from '@shared/types';
|
||||
|
||||
const ACCEPTED_TYPES = new Set<string>(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
|
|
@ -28,14 +30,21 @@ export const TaskAttachments = ({
|
|||
const [uploading, setUploading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{
|
||||
id: string;
|
||||
mimeType: AttachmentMediaType;
|
||||
dataUrl: string | null;
|
||||
loading: boolean;
|
||||
} | null>(null);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType));
|
||||
|
||||
const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => {
|
||||
setThumbCache((prev) => {
|
||||
if (prev.get(attachmentId) === dataUrl) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(attachmentId, dataUrl);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
|
@ -73,48 +82,74 @@ export const TaskAttachments = ({
|
|||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (attachmentId: string, mimeType: AttachmentMediaType) => {
|
||||
async (attachmentId: string, mimeType: string) => {
|
||||
setDeletingId(attachmentId);
|
||||
try {
|
||||
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
||||
if (previewAttachment?.id === attachmentId) {
|
||||
setPreviewAttachment(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[teamName, taskId, deleteTaskAttachment, previewAttachment]
|
||||
[teamName, taskId, deleteTaskAttachment]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (att: TaskAttachmentMeta) => {
|
||||
setError(null);
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType);
|
||||
if (!base64) {
|
||||
setError('Attachment file not found');
|
||||
return;
|
||||
}
|
||||
const mime =
|
||||
att.mimeType && typeof att.mimeType === 'string'
|
||||
? att.mimeType
|
||||
: 'application/octet-stream';
|
||||
const dataUrl = `data:${mime};base64,${base64}`;
|
||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = att.filename || 'attachment';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to download');
|
||||
}
|
||||
},
|
||||
[getTaskAttachmentData, teamName, taskId]
|
||||
);
|
||||
|
||||
// 1x1 transparent PNG placeholder for slides where thumb is not yet loaded
|
||||
const PLACEHOLDER_SRC =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==';
|
||||
|
||||
const lightboxSlides = useMemo(
|
||||
() =>
|
||||
imageAttachments.map((a) => ({
|
||||
src: thumbCache.get(a.id) ?? PLACEHOLDER_SRC,
|
||||
alt: a.filename,
|
||||
})),
|
||||
[imageAttachments, thumbCache]
|
||||
);
|
||||
|
||||
const handlePreview = useCallback(
|
||||
async (att: TaskAttachmentMeta) => {
|
||||
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
|
||||
setPreviewAttachment(null);
|
||||
(att: TaskAttachmentMeta) => {
|
||||
if (!isImageMimeType(att.mimeType)) {
|
||||
void handleDownload(att);
|
||||
return;
|
||||
}
|
||||
setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true });
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType);
|
||||
if (base64) {
|
||||
setPreviewAttachment({
|
||||
id: att.id,
|
||||
mimeType: att.mimeType,
|
||||
dataUrl: `data:${att.mimeType};base64,${base64}`,
|
||||
loading: false,
|
||||
});
|
||||
} else {
|
||||
setPreviewAttachment(null);
|
||||
setError('Attachment file not found');
|
||||
}
|
||||
} catch {
|
||||
setPreviewAttachment(null);
|
||||
setError('Failed to load attachment');
|
||||
const idx = imageAttachments.findIndex((a) => a.id === att.id);
|
||||
if (idx >= 0) {
|
||||
setLightboxIndex(idx);
|
||||
}
|
||||
},
|
||||
[teamName, taskId, getTaskAttachmentData, previewAttachment]
|
||||
[imageAttachments, handleDownload]
|
||||
);
|
||||
|
||||
// Handle paste events for quick image attachment
|
||||
|
|
@ -179,38 +214,31 @@ export const TaskAttachments = ({
|
|||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
isDeleting={deletingId === att.id}
|
||||
isPreviewActive={previewAttachment?.id === att.id}
|
||||
onPreview={() => void handlePreview(att)}
|
||||
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
||||
onPreview={() => {
|
||||
// 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Preview panel */}
|
||||
{previewAttachment ? (
|
||||
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setPreviewAttachment(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{previewAttachment.loading ? (
|
||||
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading image...
|
||||
</div>
|
||||
) : previewAttachment.dataUrl ? (
|
||||
<img
|
||||
src={previewAttachment.dataUrl}
|
||||
alt="Attachment preview"
|
||||
className="max-h-[400px] max-w-full rounded object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Image lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => {
|
||||
setLightboxIndex(null);
|
||||
}}
|
||||
slides={lightboxSlides}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drop zone indicator */}
|
||||
{dragOver ? (
|
||||
|
|
@ -256,9 +284,9 @@ interface AttachmentThumbnailProps {
|
|||
teamName: string;
|
||||
taskId: string;
|
||||
isDeleting: boolean;
|
||||
isPreviewActive: boolean;
|
||||
onPreview: () => void;
|
||||
onDelete: () => void;
|
||||
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentThumbnail = ({
|
||||
|
|
@ -266,9 +294,9 @@ const AttachmentThumbnail = ({
|
|||
teamName,
|
||||
taskId,
|
||||
isDeleting,
|
||||
isPreviewActive,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onDataLoaded,
|
||||
}: AttachmentThumbnailProps): React.JSX.Element => {
|
||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
|
|
@ -277,6 +305,7 @@ const AttachmentThumbnail = ({
|
|||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isImageMimeType(attachment.mimeType)) return;
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -284,7 +313,9 @@ const AttachmentThumbnail = ({
|
|||
attachment.mimeType
|
||||
);
|
||||
if (!cancelled && base64) {
|
||||
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`);
|
||||
const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
|
||||
setThumbUrl(dataUrl);
|
||||
onDataLoaded?.(attachment.id, dataUrl);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
@ -293,7 +324,7 @@ const AttachmentThumbnail = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
|
||||
|
||||
const sizeLabel =
|
||||
attachment.size < 1024
|
||||
|
|
@ -304,17 +335,22 @@ const AttachmentThumbnail = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${
|
||||
isPreviewActive
|
||||
? 'border-blue-500/60 ring-1 ring-blue-500/30'
|
||||
: '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}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
{isImageMimeType(attachment.mimeType) ? (
|
||||
thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
) : (
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
)
|
||||
) : (
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
<div className="flex flex-col items-center gap-1 px-1 text-center">
|
||||
<File size={18} className="text-[var(--color-text-muted)]" />
|
||||
<div className="max-w-full truncate text-[9px] text-[var(--color-text-muted)]">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete button overlay */}
|
||||
<button
|
||||
|
|
@ -353,7 +389,7 @@ function fileToBase64(file: File): Promise<string> {
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { ImagePlus, Send, Trash2, X } from 'lucide-react';
|
||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -71,51 +70,45 @@ export const TaskCommentInput = ({
|
|||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
||||
!addingComment;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setAttachError(
|
||||
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
|
||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||
break;
|
||||
}
|
||||
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) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
base64Data: base64,
|
||||
previewUrl: result,
|
||||
size: file.size,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
const addFiles = useCallback((files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
[pendingAttachments.length]
|
||||
);
|
||||
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) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
|
|
@ -132,7 +125,7 @@ export const TaskCommentInput = ({
|
|||
? pendingAttachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType as CommentAttachmentPayload['mimeType'],
|
||||
mimeType: a.mimeType,
|
||||
base64Data: a.base64Data,
|
||||
}))
|
||||
: undefined;
|
||||
|
|
@ -246,16 +239,18 @@ export const TaskCommentInput = ({
|
|||
className="hidden"
|
||||
onChange={(e) => {
|
||||
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 = '';
|
||||
}}
|
||||
/>
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Add a comment... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
projectPath={projectPath}
|
||||
onModEnter={() => void handleSubmit()}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
|
|
@ -275,6 +270,18 @@ export const TaskCommentInput = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => void window.electronAPI.openExternal('https://voicetext.site')}
|
||||
>
|
||||
<Mic size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Voice to text</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
|
|
|||
|
|
@ -2,37 +2,25 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Reply,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
AttachmentMediaType,
|
||||
ResolvedTeamMember,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
} from '@shared/types';
|
||||
import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
|
||||
|
||||
/**
|
||||
* Convert literal backslash-n sequences to real newlines.
|
||||
|
|
@ -61,6 +49,8 @@ interface TaskCommentsSectionProps {
|
|||
onReply?: (author: string, text: string) => void;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in comment text. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
|
|
@ -74,7 +64,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
|
|||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
||||
return text.replace(pattern, (match, prefix: string, name: string) => {
|
||||
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
|
|
@ -90,35 +80,23 @@ export const TaskCommentsSection = ({
|
|||
hideInput = false,
|
||||
onReply,
|
||||
onTaskIdClick,
|
||||
containerClassName,
|
||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
const addingComment = useStore((s) => s.addingComment);
|
||||
const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
|
||||
|
||||
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Reset local state when team/task changes (React-recommended pattern for
|
||||
// adjusting state based on props without using effects or refs during render)
|
||||
const currentKey = teamIdKey(teamName, taskId);
|
||||
const [prevKey, setPrevKey] = useState(currentKey);
|
||||
if (prevKey !== currentKey) {
|
||||
setPrevKey(currentKey);
|
||||
// Reset local UI state when team/task changes.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setVisibleCount(INITIAL_VISIBLE_COMMENTS);
|
||||
setExpandedCommentIds(new Set());
|
||||
setReplyTo(null);
|
||||
}
|
||||
|
||||
const toggleCommentExpanded = useCallback((commentId: string) => {
|
||||
setExpandedCommentIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(commentId)) next.delete(commentId);
|
||||
else next.add(commentId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setPreviewImageUrl(null);
|
||||
}, [teamName, taskId]);
|
||||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
|
@ -183,91 +161,85 @@ export const TaskCommentsSection = ({
|
|||
) : null}
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="mb-3">
|
||||
{comments.length > MAX_COMMENTS_TO_RENDER ? (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]">
|
||||
<div className="mb-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2 text-[11px] text-[var(--color-text-muted)]">
|
||||
Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
|
||||
UI responsive.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
'group rounded-md p-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type && index % 2 === 1
|
||||
? { backgroundColor: 'var(--card-bg-zebra)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<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">
|
||||
<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>
|
||||
<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));
|
||||
const needsExpandCollapse = displayText.includes('\n');
|
||||
const expanded = expandedCommentIds.has(comment.id);
|
||||
const collapsedHeight = 'max-h-[120px]';
|
||||
const showCollapsed = needsExpandCollapse && !expanded;
|
||||
const showExpandedButton = needsExpandCollapse && expanded;
|
||||
return (
|
||||
<div className="relative text-xs">
|
||||
<div
|
||||
className={
|
||||
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
|
||||
<div className={containerClassName ?? ''}>
|
||||
{visibleComments.map((comment, index) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={[
|
||||
'group px-4 py-2.5',
|
||||
comment.type === 'review_approved'
|
||||
? 'border-y border-emerald-500/20 bg-emerald-500/5'
|
||||
: comment.type === 'review_request'
|
||||
? 'border-y border-blue-500/20 bg-blue-500/5'
|
||||
: '',
|
||||
].join(' ')}
|
||||
style={
|
||||
!comment.type || comment.type === 'regular'
|
||||
? {
|
||||
backgroundColor:
|
||||
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
|
||||
}
|
||||
>
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
<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">
|
||||
<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>
|
||||
<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={{
|
||||
|
|
@ -275,9 +247,8 @@ export const TaskCommentsSection = ({
|
|||
originalText: stripAgentBlocks(reply.originalText),
|
||||
replyText: stripAgentBlocks(reply.replyText),
|
||||
}}
|
||||
bodyMaxHeight={
|
||||
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none'
|
||||
}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
|
@ -299,68 +270,29 @@ export const TaskCommentsSection = ({
|
|||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = displayText;
|
||||
if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t);
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{showCollapsed && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => toggleCommentExpanded(comment.id)}
|
||||
title="Expand"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showExpandedButton && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => toggleCommentExpanded(comment.id)}
|
||||
title="Collapse"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</ExpandableContent>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sortedComments.length > visibleComments.length ? (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
|
|
@ -378,22 +310,14 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Full-size image preview overlay */}
|
||||
{/* Image lightbox */}
|
||||
{previewImageUrl ? (
|
||||
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setPreviewImageUrl(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
className="max-h-[400px] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setPreviewImageUrl(null)}
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!hideInput && (
|
||||
|
|
@ -427,10 +351,11 @@ export const TaskCommentsSection = ({
|
|||
<div className="relative">
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Add a comment... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
onModEnter={() => void handleSubmit()}
|
||||
minRows={2}
|
||||
maxRows={8}
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
|
|
@ -487,11 +412,14 @@ const CommentAttachmentThumbnail = ({
|
|||
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isImageMimeType(attachment.mimeType)) return;
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -511,19 +439,76 @@ const CommentAttachmentThumbnail = ({
|
|||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex size-14 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={() => thumbUrl && onPreview(thumbUrl)}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
|
||||
downloadError
|
||||
? 'border-red-500/60'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isImageMimeType(attachment.mimeType)) {
|
||||
if (thumbUrl) onPreview(thumbUrl);
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
setDownloading(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
attachment.id,
|
||||
attachment.mimeType
|
||||
);
|
||||
if (!base64) return;
|
||||
const mime =
|
||||
attachment.mimeType && typeof attachment.mimeType === 'string'
|
||||
? attachment.mimeType
|
||||
: 'application/octet-stream';
|
||||
const dataUrl = `data:${mime};base64,${base64}`;
|
||||
const blob = await fetch(dataUrl).then((r) => r.blob());
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = attachment.filename || 'attachment';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setDownloadError(err instanceof Error ? err.message : 'Download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{isImageMimeType(attachment.mimeType) ? (
|
||||
thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
) : (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
)
|
||||
) : downloading ? (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<File size={14} className="text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{downloadError ? (
|
||||
<TooltipContent side="top" className="text-red-400">
|
||||
{downloadError}
|
||||
</TooltipContent>
|
||||
) : (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
<TooltipContent side="top">{attachment.filename}</TooltipContent>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -556,7 +541,3 @@ const CommentAttachments = ({
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function teamIdKey(teamName: string, taskId: string): string {
|
||||
return `${teamName}::${taskId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,13 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||
import { Textarea } from '@renderer/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
buildMemberColorMap,
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
|
|
@ -107,6 +100,7 @@ export const TaskDetailDialog = ({
|
|||
const updateTaskFields = useStore((s) => s.updateTaskFields);
|
||||
|
||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
||||
|
||||
// Inline editing: subject
|
||||
const [editingSubject, setEditingSubject] = useState(false);
|
||||
|
|
@ -296,6 +290,12 @@ export const TaskDetailDialog = ({
|
|||
.map((t) => t.id);
|
||||
const isTodo = status === 'pending' && !kanbanColumn;
|
||||
const canReassign = isTodo && onOwnerChange;
|
||||
const leadName =
|
||||
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
|
||||
const isLeadOwnedTask =
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
|
||||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
|
||||
const allowLeadExecutionPreview = true;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
|
|
@ -349,42 +349,14 @@ export const TaskDetailDialog = ({
|
|||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{canReassign ? (
|
||||
<Select
|
||||
value={currentTask.owner ?? '__unassigned__'}
|
||||
onValueChange={(v) => {
|
||||
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MemberSelect
|
||||
members={members}
|
||||
value={currentTask.owner ?? null}
|
||||
onChange={(v) => onOwnerChange(currentTask.id, v)}
|
||||
allowUnassigned
|
||||
size="sm"
|
||||
className="min-w-[160px]"
|
||||
/>
|
||||
) : currentTask.owner ? (
|
||||
<MemberBadge
|
||||
name={currentTask.owner}
|
||||
|
|
@ -392,7 +364,7 @@ export const TaskDetailDialog = ({
|
|||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||
<span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTask.createdBy ? (
|
||||
|
|
@ -545,7 +517,7 @@ export const TaskDetailDialog = ({
|
|||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group max-h-[200px] cursor-pointer overflow-y-auto"
|
||||
className="group cursor-pointer"
|
||||
onClick={startEditDescription}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
|
@ -554,7 +526,9 @@ export const TaskDetailDialog = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" bare />
|
||||
<ExpandableContent collapsedHeight={200}>
|
||||
<MarkdownViewer content={currentTask.description} maxHeight="max-h-none" bare />
|
||||
</ExpandableContent>
|
||||
<Pencil
|
||||
size={12}
|
||||
className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
|
|
@ -680,10 +654,23 @@ export const TaskDetailDialog = ({
|
|||
title="Execution Logs"
|
||||
icon={<ScrollText size={14} />}
|
||||
headerExtra={
|
||||
logsRefreshing ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
Updating...
|
||||
logsRefreshing || executionPreviewOnline ? (
|
||||
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{executionPreviewOnline ? (
|
||||
<span
|
||||
className="pointer-events-none relative inline-flex size-2 shrink-0"
|
||||
title="Online"
|
||||
>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
{logsRefreshing ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
Updating...
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
|
@ -700,6 +687,13 @@ export const TaskDetailDialog = ({
|
|||
taskStatus={currentTask.status}
|
||||
taskWorkIntervals={currentTask.workIntervals}
|
||||
onRefreshingChange={setLogsRefreshing}
|
||||
// Only show a "latest messages" preview when this task is owned by a subagent.
|
||||
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
|
||||
// so filtering to "just the member messages" is unreliable and easy to mislead.
|
||||
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
||||
// Temporary debug option: for lead-owned tasks, show quick preview from lead session.
|
||||
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
@ -858,18 +852,20 @@ export const TaskDetailDialog = ({
|
|||
? (currentTask.comments?.length ?? 0)
|
||||
: undefined
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
contentClassName="overflow-x-visible pl-0"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen
|
||||
>
|
||||
<TaskCommentInput
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
members={members}
|
||||
replyTo={effectiveReplyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
<div className="pl-2.5">
|
||||
<TaskCommentInput
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
members={members}
|
||||
replyTo={effectiveReplyTo}
|
||||
onClearReply={clearReply}
|
||||
/>
|
||||
</div>
|
||||
<TaskCommentsSection
|
||||
teamName={teamName}
|
||||
taskId={currentTask.id}
|
||||
|
|
@ -879,6 +875,7 @@ export const TaskDetailDialog = ({
|
|||
hideInput
|
||||
onReply={handleReply}
|
||||
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
|
||||
containerClassName="-mx-6"
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -33,15 +33,15 @@ export const EditorImagePreview = ({
|
|||
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Reset state when filePath changes (setState-during-render, React-approved pattern)
|
||||
const [prevFilePath, setPrevFilePath] = useState(filePath);
|
||||
if (prevFilePath !== filePath) {
|
||||
setPrevFilePath(filePath);
|
||||
// Reset state when filePath changes
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDataUrl(null);
|
||||
setDimensions(null);
|
||||
}
|
||||
setLightboxOpen(false);
|
||||
}, [filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -127,10 +127,10 @@ export const EditorImagePreview = ({
|
|||
</div>
|
||||
|
||||
<ImageLightbox
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
open={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
src={dataUrl}
|
||||
alt={fileName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -678,7 +678,14 @@ export const ProjectEditorOverlay = ({
|
|||
|
||||
{/* Draft recovery banner */}
|
||||
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300">
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--warning-bg)',
|
||||
borderColor: 'var(--warning-border)',
|
||||
color: 'var(--warning-text)',
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5 shrink-0" />
|
||||
<span>Recovered unsaved changes from a previous session.</span>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -37,18 +37,12 @@ export const QuickOpenDialog = ({
|
|||
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Reset loading state when projectPath changes (React-recommended
|
||||
// "adjusting state when props change" pattern without effects or refs)
|
||||
const [prevProjectPath, setPrevProjectPath] = useState(projectPath);
|
||||
if (prevProjectPath !== projectPath) {
|
||||
setPrevProjectPath(projectPath);
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
// Load all project files on mount via backend API
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setLoading(true);
|
||||
window.electronAPI.editor
|
||||
.listFiles()
|
||||
.then((files) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-
|
|||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import {
|
||||
CheckCircle2,
|
||||
|
|
@ -402,6 +403,17 @@ export const KanbanBoard = ({
|
|||
);
|
||||
};
|
||||
|
||||
const visibleColumns = useMemo(
|
||||
() => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS),
|
||||
[filter.columns]
|
||||
);
|
||||
|
||||
const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]);
|
||||
const { widths: columnWidths, getHandleProps } = useResizableColumns({
|
||||
storageKey: teamName,
|
||||
columnIds: resizableColumnIds,
|
||||
});
|
||||
|
||||
const boardContent = (
|
||||
<>
|
||||
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
|
||||
|
|
@ -475,7 +487,7 @@ export const KanbanBoard = ({
|
|||
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{COLUMNS.map((column) => {
|
||||
{visibleColumns.map((column) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
return (
|
||||
|
|
@ -493,21 +505,32 @@ export const KanbanBoard = ({
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{COLUMNS.map((column) => {
|
||||
<div className="flex overflow-x-auto pb-2">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
const width = columnWidths.get(column.id) ?? 256;
|
||||
return (
|
||||
<div key={column.id} className="w-64 shrink-0">
|
||||
<KanbanColumn
|
||||
title={column.title}
|
||||
count={columnTasks.length}
|
||||
icon={accent.icon}
|
||||
headerBg={accent.headerBg}
|
||||
bodyBg={accent.bodyBg}
|
||||
>
|
||||
{renderCards(column.id, columnTasks, true)}
|
||||
</KanbanColumn>
|
||||
<div key={column.id} className="flex shrink-0">
|
||||
<div style={{ width }}>
|
||||
<KanbanColumn
|
||||
title={column.title}
|
||||
count={columnTasks.length}
|
||||
icon={accent.icon}
|
||||
headerBg={accent.headerBg}
|
||||
bodyBg={accent.bodyBg}
|
||||
>
|
||||
{renderCards(column.id, columnTasks, true)}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
{index < visibleColumns.length - 1 ? (
|
||||
<div
|
||||
className="group relative mx-0.5 flex items-center"
|
||||
{...getHandleProps(column.id)}
|
||||
>
|
||||
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { Crown, Filter } from 'lucide-react';
|
||||
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
export const UNASSIGNED_OWNER = '__unassigned__';
|
||||
|
||||
export interface KanbanFilterState {
|
||||
sessionId: string | null;
|
||||
selectedOwners: Set<string>;
|
||||
/** When non-empty, only these columns are visible on the kanban board. Empty = all columns. */
|
||||
columns: Set<KanbanColumnId>;
|
||||
}
|
||||
|
||||
/** Column definitions with display labels and accent colors for filter UI. */
|
||||
export const KANBAN_COLUMNS: { id: KanbanColumnId; label: string; color: string }[] = [
|
||||
{ id: 'todo', label: 'TODO', color: 'rgb(59, 130, 246)' },
|
||||
{ id: 'in_progress', label: 'IN PROGRESS', color: 'rgb(234, 179, 8)' },
|
||||
{ id: 'done', label: 'DONE', color: 'rgb(34, 197, 94)' },
|
||||
{ id: 'review', label: 'REVIEW', color: 'rgb(139, 92, 246)' },
|
||||
{ id: 'approved', label: 'APPROVED', color: 'rgb(22, 163, 74)' },
|
||||
];
|
||||
|
||||
interface KanbanFilterPopoverProps {
|
||||
filter: KanbanFilterState;
|
||||
sessions: Session[];
|
||||
|
|
@ -35,8 +46,9 @@ export const KanbanFilterPopover = ({
|
|||
let count = 0;
|
||||
if (filter.sessionId !== null) count += 1;
|
||||
if (filter.selectedOwners.size > 0) count += 1;
|
||||
if (filter.columns.size > 0) count += 1;
|
||||
return count;
|
||||
}, [filter.sessionId, filter.selectedOwners]);
|
||||
}, [filter.sessionId, filter.selectedOwners, filter.columns]);
|
||||
|
||||
const handleSessionSelect = (sessionId: string | null): void => {
|
||||
onFilterChange({ ...filter, sessionId });
|
||||
|
|
@ -52,8 +64,18 @@ export const KanbanFilterPopover = ({
|
|||
onFilterChange({ ...filter, selectedOwners: next });
|
||||
};
|
||||
|
||||
const handleColumnToggle = (columnId: KanbanColumnId): void => {
|
||||
const next = new Set(filter.columns);
|
||||
if (next.has(columnId)) {
|
||||
next.delete(columnId);
|
||||
} else {
|
||||
next.add(columnId);
|
||||
}
|
||||
onFilterChange({ ...filter, columns: next });
|
||||
};
|
||||
|
||||
const handleClearAll = (): void => {
|
||||
onFilterChange({ sessionId: null, selectedOwners: new Set() });
|
||||
onFilterChange({ sessionId: null, selectedOwners: new Set(), columns: new Set() });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -148,6 +170,28 @@ export const KanbanFilterPopover = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column section */}
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Column
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{KANBAN_COLUMNS.map((col) => (
|
||||
<label
|
||||
key={col.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
style={{ color: col.color }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={filter.columns.has(col.id)}
|
||||
onCheckedChange={() => handleColumnToggle(col.id)}
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -207,6 +206,12 @@ export const KanbanTaskCard = ({
|
|||
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
|
||||
|
||||
const isReviewManual = columnId === 'review' && !hasReviewers;
|
||||
const multiButton =
|
||||
compact ||
|
||||
columnId === 'todo' ||
|
||||
columnId === 'in_progress' ||
|
||||
columnId === 'done' ||
|
||||
columnId === 'review';
|
||||
|
||||
const metaActions = (
|
||||
<>
|
||||
|
|
@ -243,7 +248,7 @@ export const KanbanTaskCard = ({
|
|||
return (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
className={`relative cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
|
||||
|
|
@ -258,12 +263,14 @@ export const KanbanTaskCard = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
#{task.id}
|
||||
</span>
|
||||
<div className="mb-2 pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||
{task.owner ? (
|
||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
|
||||
) : null}
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
</div>
|
||||
{task.needsClarification ? (
|
||||
|
|
@ -315,7 +322,7 @@ export const KanbanTaskCard = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className={multiButton ? 'space-y-2' : 'flex items-end gap-2'}>
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
{columnId === 'todo' ? (
|
||||
<>
|
||||
|
|
@ -448,7 +455,11 @@ export const KanbanTaskCard = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{!isReviewManual ? <div className="flex items-center gap-1.5">{metaActions}</div> : null}
|
||||
{!isReviewManual ? (
|
||||
<div className={`flex items-center gap-1.5 ${multiButton ? 'justify-end' : ''}`}>
|
||||
{metaActions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export const TrashDialog = ({
|
|||
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
|
||||
{task.owner ?? '—'}
|
||||
{task.owner ?? 'Unassigned'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
|
||||
{task.deletedAt
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
|
@ -40,18 +39,13 @@ export const MemberCard = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
// const leadContext = useStore((s) =>
|
||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
const pending = taskCounts?.pending ?? 0;
|
||||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
|
|
@ -151,19 +145,26 @@ export const MemberCard = ({
|
|||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
title={
|
||||
isRemoved
|
||||
? 'This member has been removed'
|
||||
: member.currentTaskId
|
||||
? `Current task: ${member.currentTaskId}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isRemoved ? 'removed' : presenceLabel}
|
||||
</Badge>
|
||||
{presenceLabel === 'connecting' && !isRemoved ? (
|
||||
<Loader2
|
||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||
aria-label="connecting"
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
title={
|
||||
isRemoved
|
||||
? 'This member has been removed'
|
||||
: member.currentTaskId
|
||||
? `Current task: ${member.currentTaskId}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isRemoved ? 'removed' : presenceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
<div
|
||||
className="shrink-0"
|
||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||
|
|
@ -182,29 +183,7 @@ export const MemberCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
leadContext.percent > 90
|
||||
? 'bg-red-500'
|
||||
: leadContext.percent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(leadContext.percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Context: {Math.round(leadContext.percent)}% (
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k tokens)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* NOTE: lead context bar disabled — usage formula is inaccurate */}
|
||||
</div>
|
||||
{!isRemoved && (
|
||||
<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 { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
|
@ -31,20 +30,15 @@ export const MemberDetailHeader = ({
|
|||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
// const leadContext = useStore((s) =>
|
||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const role = member.role || formatAgentRole(member.agentType);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
|
|
@ -107,12 +101,7 @@ export const MemberDetailHeader = ({
|
|||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
{/* NOTE: lead context token display disabled — usage formula is inaccurate */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
|
|
@ -143,33 +136,15 @@ export const MemberDraftRow = ({
|
|||
/>
|
||||
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Select
|
||||
value={member.roleSelection || NO_ROLE}
|
||||
<div>
|
||||
<RoleSelect
|
||||
value={member.roleSelection || '__none__'}
|
||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs" aria-label={`Member ${index + 1} role`}>
|
||||
<SelectValue placeholder="No role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{PRESET_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{member.roleSelection === CUSTOM_ROLE ? (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={member.customRole}
|
||||
aria-label={`Member ${index + 1} custom role`}
|
||||
onChange={(event) => onCustomRoleChange(member.id, event.target.value)}
|
||||
placeholder="e.g. architect"
|
||||
/>
|
||||
) : null}
|
||||
customRole={member.customRole}
|
||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||
triggerClassName="h-8 text-xs"
|
||||
inputClassName="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{showWorkflow && onWorkflowChange ? (
|
||||
|
|
@ -216,7 +191,7 @@ export const MemberDraftRow = ({
|
|||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath ?? undefined}
|
||||
onFileChipInsert={handleFileChipInsert}
|
||||
placeholder="How this agent should behave, interact with others. Use @ to mention teammates or add files."
|
||||
placeholder="How this agent should behave, interact with others..."
|
||||
footerRight={
|
||||
workflowDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ const AIExecutionGroup = ({
|
|||
<div className="py-1 pl-2">
|
||||
<DisplayItemList
|
||||
items={enhanced.displayItems}
|
||||
order="newest-first"
|
||||
onItemClick={onToggleItem}
|
||||
expandedItemIds={expandedItemIds}
|
||||
aiGroupId={group.id}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
import {
|
||||
SubagentRecentMessagesPreview,
|
||||
type SubagentPreviewMessage,
|
||||
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
import {
|
||||
|
|
@ -14,6 +18,9 @@ import {
|
|||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
|
||||
import type { EnhancedChunk } from '@renderer/types/data';
|
||||
import type { MemberLogSummary } from '@shared/types';
|
||||
|
||||
|
|
@ -28,6 +35,15 @@ interface MemberLogsTabProps {
|
|||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Notifies parent when a background refresh starts/ends. */
|
||||
onRefreshingChange?: (isRefreshing: boolean) => void;
|
||||
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
||||
showSubagentPreview?: boolean;
|
||||
/**
|
||||
* Optional: for lead-owned tasks, show a quick preview from the lead session.
|
||||
* (This is lead activity, not "member-only" activity.)
|
||||
*/
|
||||
showLeadPreview?: boolean;
|
||||
/** Notifies parent when preview looks "online" (recent output). */
|
||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||
}
|
||||
|
||||
export const MemberLogsTab = ({
|
||||
|
|
@ -38,6 +54,9 @@ export const MemberLogsTab = ({
|
|||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
onRefreshingChange,
|
||||
showSubagentPreview = false,
|
||||
showLeadPreview = false,
|
||||
onPreviewOnlineChange,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const intervalsKey = useMemo(
|
||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||
|
|
@ -52,12 +71,104 @@ export const MemberLogsTab = ({
|
|||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
|
||||
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||
return log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
: `lead:${log.sessionId}`;
|
||||
}, []);
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
const nowMs = Date.now();
|
||||
const getLastActivityMs = (log: MemberLogSummary): number => {
|
||||
const startMs = new Date(log.startTime).getTime();
|
||||
if (!Number.isFinite(startMs)) return Number.NaN;
|
||||
const durationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0;
|
||||
const endMs = startMs + durationMs;
|
||||
// Keep actively-updating logs at the top even if duration lags slightly.
|
||||
return log.isOngoing ? Math.max(endMs, nowMs) : endMs;
|
||||
};
|
||||
|
||||
const withIndex = logs.map((log, index) => ({ log, index }));
|
||||
withIndex.sort((a, b) => {
|
||||
const aTime = getLastActivityMs(a.log);
|
||||
const bTime = getLastActivityMs(b.log);
|
||||
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
|
||||
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
||||
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
||||
return a.index - b.index;
|
||||
});
|
||||
return withIndex.map((x) => x.log);
|
||||
}, [logs]);
|
||||
|
||||
const shouldShowPreview = useMemo(() => {
|
||||
return taskId != null && (showSubagentPreview || showLeadPreview);
|
||||
}, [showLeadPreview, showSubagentPreview, taskId]);
|
||||
|
||||
const previewLog = useMemo((): MemberLogSummary | null => {
|
||||
if (!shouldShowPreview) return null;
|
||||
|
||||
if (showSubagentPreview) {
|
||||
const candidates = sortedLogs.filter((l) => l.kind === 'subagent');
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (taskOwner) {
|
||||
const target = taskOwner.trim().toLowerCase();
|
||||
const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target);
|
||||
// When viewing task logs, this preview is intended to show the assigned owner's progress.
|
||||
// If we can't confidently match a subagent log to the owner, don't show anything
|
||||
// rather than risk showing a different member's activity.
|
||||
return match ?? null;
|
||||
}
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
if (showLeadPreview) {
|
||||
return sortedLogs.find((l) => l.kind === 'lead_session') ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]);
|
||||
|
||||
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
||||
if (!previewChunks || previewChunks.length === 0) return [];
|
||||
return extractSubagentPreviewMessages(previewChunks, 4);
|
||||
}, [previewChunks]);
|
||||
|
||||
const previewOnline = useMemo((): boolean => {
|
||||
const newest = previewMessages[0];
|
||||
if (!newest) return false;
|
||||
return Date.now() - newest.timestamp.getTime() <= 10_000;
|
||||
}, [previewMessages]);
|
||||
|
||||
const expandedLogSummary = useMemo(() => {
|
||||
if (!expandedId) return null;
|
||||
return logs.find((log) => getRowId(log) === expandedId) ?? null;
|
||||
}, [expandedId, getRowId, logs]);
|
||||
|
||||
useEffect(() => {
|
||||
onRefreshingChange?.(refreshing);
|
||||
return () => onRefreshingChange?.(false);
|
||||
}, [refreshing, onRefreshingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewOnlineChange?.(previewOnline);
|
||||
}, [onPreviewOnlineChange, previewOnline]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onPreviewOnlineChange?.(false);
|
||||
}, [onPreviewOnlineChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandedId) return;
|
||||
if (expandedLogSummary) return;
|
||||
setExpandedId(null);
|
||||
setDetailChunks(null);
|
||||
setDetailLoading(false);
|
||||
}, [expandedId, expandedLogSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
||||
|
|
@ -84,7 +195,7 @@ export const MemberLogsTab = ({
|
|||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
if (!cancelled) {
|
||||
setLogs(result);
|
||||
setLogs(Array.isArray(result) ? [...result] : []);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -110,12 +221,96 @@ export const MemberLogsTab = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||
if (log.kind === 'subagent') {
|
||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
||||
}
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowPreview) {
|
||||
setPreviewChunks(null);
|
||||
return;
|
||||
}
|
||||
if (!previewLog) {
|
||||
setPreviewChunks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(null);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, shouldShowPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowPreview) return;
|
||||
if (!previewLog) return;
|
||||
|
||||
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
|
||||
if (!shouldAutoRefreshPreview) return;
|
||||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// keep last successful preview
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const next = await fetchDetailForLog(expandedLogSummary);
|
||||
if (cancelled) return;
|
||||
// Ensure new reference so memoized transforms update.
|
||||
setDetailChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// Keep last successful data; avoid flicker during transient errors.
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
const rowId =
|
||||
log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
: `lead:${log.sessionId}`;
|
||||
const rowId = getRowId(log);
|
||||
|
||||
if (expandedId === rowId) {
|
||||
setExpandedId(null);
|
||||
|
|
@ -126,20 +321,15 @@ export const MemberLogsTab = ({
|
|||
setDetailChunks(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
if (log.kind === 'subagent') {
|
||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||
setDetailChunks(d?.chunks ?? null);
|
||||
} else {
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||
setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null);
|
||||
}
|
||||
const chunks = await fetchDetailForLog(log);
|
||||
setDetailChunks(chunks ? [...chunks] : null);
|
||||
} catch {
|
||||
setDetailChunks(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[expandedId]
|
||||
[expandedId, fetchDetailForLog, getRowId]
|
||||
);
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
|
|
@ -178,32 +368,19 @@ export const MemberLogsTab = ({
|
|||
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-1.5">
|
||||
{logs.map((log) => (
|
||||
{shouldShowPreview && previewLog && previewMessages.length > 0 ? (
|
||||
<SubagentRecentMessagesPreview
|
||||
messages={previewMessages}
|
||||
memberName={previewLog.memberName ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
{sortedLogs.map((log) => (
|
||||
<LogCard
|
||||
key={
|
||||
log.kind === 'subagent' ? `${log.sessionId}-${log.subagentId}` : `lead-${log.sessionId}`
|
||||
}
|
||||
key={getRowId(log)}
|
||||
log={log}
|
||||
expanded={
|
||||
expandedId ===
|
||||
(log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
: `lead:${log.sessionId}`)
|
||||
}
|
||||
detailChunks={
|
||||
expandedId ===
|
||||
(log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
: `lead:${log.sessionId}`)
|
||||
? detailChunks
|
||||
: null
|
||||
}
|
||||
detailLoading={
|
||||
expandedId ===
|
||||
(log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
: `lead:${log.sessionId}`) && detailLoading
|
||||
}
|
||||
expanded={expandedId === getRowId(log)}
|
||||
detailChunks={expandedId === getRowId(log) ? detailChunks : null}
|
||||
detailLoading={expandedId === getRowId(log) && detailLoading}
|
||||
onToggle={() => void handleExpand(log)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -306,3 +483,54 @@ function formatRelativeTime(isoString: string): string {
|
|||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function extractSubagentPreviewMessages(
|
||||
chunks: EnhancedChunk[],
|
||||
limit: number
|
||||
): SubagentPreviewMessage[] {
|
||||
const conversation = transformChunksToConversation(chunks, [], false);
|
||||
|
||||
const out: SubagentPreviewMessage[] = [];
|
||||
|
||||
// Collect newest-first and stop as soon as we have enough.
|
||||
for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) {
|
||||
const item = conversation.items[i];
|
||||
if (item.type === 'ai') {
|
||||
const enhanced = enhanceAIGroup(item.group);
|
||||
const items = enhanced.displayItems ?? [];
|
||||
for (let j = items.length - 1; j >= 0 && out.length < limit; j--) {
|
||||
const di = items[j];
|
||||
if (di.type === 'output' && di.content.trim()) {
|
||||
out.push({
|
||||
id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`,
|
||||
timestamp: di.timestamp,
|
||||
kind: 'output',
|
||||
label: 'Output',
|
||||
content: di.content,
|
||||
});
|
||||
} else if (di.type === 'teammate_message') {
|
||||
out.push({
|
||||
id: `${item.group.id}:teammate:${di.teammateMessage.id}`,
|
||||
timestamp: di.teammateMessage.timestamp,
|
||||
kind: 'teammate_message',
|
||||
label: `Message — ${di.teammateMessage.teammateId}`,
|
||||
content: di.teammateMessage.content || di.teammateMessage.summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (item.type === 'user') {
|
||||
const text = item.group.content.rawText ?? item.group.content.text ?? '';
|
||||
if (text.trim()) {
|
||||
out.push({
|
||||
id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`,
|
||||
timestamp: item.group.timestamp,
|
||||
kind: 'user',
|
||||
label: 'User',
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -32,8 +25,6 @@ export const MemberRoleEditor = ({
|
|||
const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? ''));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const showCustomInput = selectValue === CUSTOM_ROLE;
|
||||
|
||||
const handleSelectChange = (value: string): void => {
|
||||
setSelectValue(value);
|
||||
setError(null);
|
||||
|
|
@ -65,40 +56,22 @@ export const MemberRoleEditor = ({
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={selectValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{PRESET_ROLES.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showCustomInput && (
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
value={customInput}
|
||||
onChange={(e) => {
|
||||
setCustomInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
placeholder="Enter role..."
|
||||
className="h-7 w-28 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <span className="mt-0.5 text-[10px] text-red-400">{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
<RoleSelect
|
||||
value={selectValue}
|
||||
onValueChange={handleSelectChange}
|
||||
customRole={customInput}
|
||||
onCustomRoleChange={(val) => {
|
||||
setCustomInput(val);
|
||||
setError(null);
|
||||
}}
|
||||
triggerClassName="h-7 w-32 text-xs"
|
||||
inputClassName="h-7 w-28 text-xs"
|
||||
customRoleError={error}
|
||||
onCustomRoleValidate={(val) => {
|
||||
if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return 'This role is reserved';
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export type SubagentPreviewMessageKind =
|
||||
| 'output'
|
||||
| 'text'
|
||||
| 'tool_result'
|
||||
| 'interruption'
|
||||
| 'plan_exit'
|
||||
| 'teammate_message'
|
||||
| 'user'
|
||||
| 'unknown';
|
||||
|
||||
export interface SubagentPreviewMessage {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
kind: SubagentPreviewMessageKind;
|
||||
/** Optional short label (e.g. tool name). */
|
||||
label?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SubagentRecentMessagesPreviewProps {
|
||||
messages: SubagentPreviewMessage[];
|
||||
memberName?: string;
|
||||
}
|
||||
|
||||
export const SubagentRecentMessagesPreview = ({
|
||||
messages,
|
||||
memberName,
|
||||
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
|
||||
if (!messages.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
Latest messages{memberName ? ` — ${memberName}` : ''}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(messages[0].timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{m.label ? (
|
||||
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
||||
{m.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">{m.kind}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(m.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.kind === 'tool_result' ? (
|
||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
|
||||
{m.content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
|
||||
<MarkdownViewer content={m.content} copyable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
|
|
@ -13,12 +13,11 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Send } from 'lucide-react';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface MessageComposerProps {
|
||||
teamName: string;
|
||||
|
|
@ -36,6 +35,58 @@ interface MessageComposerProps {
|
|||
|
||||
const MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
/** Circular progress indicator for lead context usage. */
|
||||
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
||||
const size = 26;
|
||||
const stroke = 2.5;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const pct = Math.min(ctx.percent, 100);
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#3b82f6';
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--color-border)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[8px] font-medium" style={{ color }}>
|
||||
{Math.round(pct)}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Context: {Math.round(pct)}% ({(ctx.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(ctx.contextWindow / 1000).toFixed(0)}k tokens)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const MessageComposer = ({
|
||||
teamName,
|
||||
members,
|
||||
|
|
@ -49,6 +100,8 @@ export const MessageComposer = ({
|
|||
return lead?.name ?? members[0]?.name ?? '';
|
||||
});
|
||||
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||
const [recipientSearch, setRecipientSearch] = useState('');
|
||||
const recipientSearchRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -93,14 +146,24 @@ export const MessageComposer = ({
|
|||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
const canSend =
|
||||
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
|
||||
|
||||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null;
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||
// NOTE: lead context ring disabled — usage formula is inaccurate
|
||||
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
|
||||
// const leadContext = useStore((s) =>
|
||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
const canSend =
|
||||
recipient.length > 0 &&
|
||||
trimmed.length > 0 &&
|
||||
trimmed.length <= MAX_MESSAGE_LENGTH &&
|
||||
!sending &&
|
||||
!attachmentsBlocked;
|
||||
|
||||
// Track whether we initiated a send — clear draft only on confirmed success
|
||||
const pendingSendRef = useRef(false);
|
||||
|
|
@ -109,7 +172,8 @@ export const MessageComposer = ({
|
|||
if (!canSend) return;
|
||||
pendingSendRef.current = true;
|
||||
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
|
||||
onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined);
|
||||
// Summary should stay compact (no expanded chip markdown)
|
||||
onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined);
|
||||
}, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
|
||||
|
||||
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||
|
|
@ -127,7 +191,7 @@ export const MessageComposer = ({
|
|||
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSend();
|
||||
|
|
@ -204,68 +268,94 @@ export const MessageComposer = ({
|
|||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
|
||||
>
|
||||
{selectedColorSet ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: selectedColorSet.border }}
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<span
|
||||
className="max-w-[120px] truncate font-medium"
|
||||
style={selectedColorSet ? { color: selectedColorSet.text } : undefined}
|
||||
>
|
||||
{recipient || 'Select...'}
|
||||
</span>
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-1.5">
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-56 p-1.5"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
setRecipientSearch('');
|
||||
setTimeout(() => recipientSearchRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
placeholder="Search..."
|
||||
value={recipientSearch}
|
||||
onChange={(e) => setRecipientSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{members.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const isSelected = m.name === recipient;
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
|
||||
isSelected && 'bg-[var(--color-surface-raised)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setRecipient(m.name);
|
||||
setRecipientOpen(false);
|
||||
}}
|
||||
>
|
||||
{colorSet ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: colorSet.border }}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<span
|
||||
className="min-w-0 truncate font-medium"
|
||||
style={colorSet ? { color: colorSet.text } : undefined}
|
||||
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||
{(() => {
|
||||
const query = recipientSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? members.filter((m) => m.name.toLowerCase().includes(query))
|
||||
: members;
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filtered.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const isSelected = m.name === recipient;
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
|
||||
isSelected && 'bg-[var(--color-surface-raised)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setRecipient(m.name);
|
||||
setRecipientOpen(false);
|
||||
setRecipientSearch('');
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{role}
|
||||
</span>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<MemberBadge
|
||||
name={m.name}
|
||||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
/>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{role}
|
||||
</span>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -308,7 +398,9 @@ export const MessageComposer = ({
|
|||
) : null}
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<span className="ml-auto text-[10px] text-amber-400">Team offline</span>
|
||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
|
|
@ -316,11 +408,13 @@ export const MessageComposer = ({
|
|||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
||||
<MentionableTextarea
|
||||
id={`compose-${teamName}`}
|
||||
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`}
|
||||
placeholder="Write a message... (Enter to send)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
@ -328,23 +422,42 @@ export const MessageComposer = ({
|
|||
onChipRemove={chipDraft.removeChip}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={chipDraft.addChip}
|
||||
onModEnter={handleSend}
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
disabled={sending}
|
||||
cornerAction={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSend}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send size={12} />
|
||||
Send
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => void window.electronAPI.openExternal('https://voicetext.site')}
|
||||
>
|
||||
<Mic size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Voice to text</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSend}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send size={12} />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Mention "create a task" to add it to the board
|
||||
</span>
|
||||
{sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
|
@ -11,6 +14,8 @@ import type { InboxMessage } from '@shared/types';
|
|||
export interface MessagesFilterState {
|
||||
from: Set<string>;
|
||||
to: Set<string>;
|
||||
/** When true, include internal coordination noise (idle/shutdown/etc.) */
|
||||
showNoise: boolean;
|
||||
}
|
||||
|
||||
interface MessagesFilterPopoverProps {
|
||||
|
|
@ -44,18 +49,26 @@ export const MessagesFilterPopover = ({
|
|||
onOpenChange,
|
||||
onApply,
|
||||
}: MessagesFilterPopoverProps): React.JSX.Element => {
|
||||
const [draft, setDraft] = useState<MessagesFilterState>({ from: new Set(), to: new Set() });
|
||||
const [draft, setDraft] = useState<MessagesFilterState>({
|
||||
from: new Set(),
|
||||
to: new Set(),
|
||||
showNoise: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const next = {
|
||||
from: new Set(filter.from),
|
||||
to: new Set(filter.to),
|
||||
showNoise: !!filter.showNoise,
|
||||
};
|
||||
const schedule = (): void => setDraft(next);
|
||||
queueMicrotask(schedule);
|
||||
}
|
||||
}, [open, filter.from, filter.to]);
|
||||
}, [open, filter.from, filter.to, filter.showNoise]);
|
||||
|
||||
const members = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
|
||||
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
|
||||
const toOptions = useMemo(() => collectToOptions(messages), [messages]);
|
||||
|
|
@ -87,7 +100,7 @@ export const MessagesFilterPopover = ({
|
|||
};
|
||||
|
||||
const handleReset = (): void => {
|
||||
const empty = { from: new Set<string>(), to: new Set<string>() };
|
||||
const empty = { from: new Set<string>(), to: new Set<string>(), showNoise: false };
|
||||
setDraft(empty);
|
||||
onApply(empty);
|
||||
};
|
||||
|
|
@ -124,6 +137,7 @@ export const MessagesFilterPopover = ({
|
|||
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
||||
) : (
|
||||
fromOptions.map((name) => (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
|
||||
<label
|
||||
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)]"
|
||||
|
|
@ -132,7 +146,12 @@ export const MessagesFilterPopover = ({
|
|||
checked={draft.from.has(name)}
|
||||
onCheckedChange={() => toggleFrom(name)}
|
||||
/>
|
||||
{name}
|
||||
<MemberBadge
|
||||
name={name}
|
||||
color={colorMap.get(name)}
|
||||
size="sm"
|
||||
hideAvatar={name === 'user'}
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
|
|
@ -147,23 +166,39 @@ export const MessagesFilterPopover = ({
|
|||
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
|
||||
) : (
|
||||
toOptions.map((name) => (
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
|
||||
<label
|
||||
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)]"
|
||||
>
|
||||
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
|
||||
{name}
|
||||
<MemberBadge
|
||||
name={name}
|
||||
color={colorMap.get(name)}
|
||||
size="sm"
|
||||
hideAvatar={name === 'user'}
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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)]">
|
||||
<Checkbox
|
||||
checked={draft.showNoise}
|
||||
onCheckedChange={() => setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))}
|
||||
/>
|
||||
<span>Show status updates (idle/shutdown)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
disabled={draftCount === 0}
|
||||
disabled={draftCount === 0 && !draft.showNoise}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
|
|
|
|||
|
|
@ -724,14 +724,12 @@ export const ChangeReviewDialog = ({
|
|||
});
|
||||
}, [activeChangeSet, initialFilePath, scrollToFile]);
|
||||
|
||||
// Clear selection state on close (React-approved setState-during-render pattern)
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
// Clear selection state on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectionInfo(null);
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Cleanup refs/timers on close
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
|||
<tr className="border-t border-[var(--color-border)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Unassigned'}</td>
|
||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
||||
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
||||
|
|
|
|||
106
src/renderer/components/ui/ExpandableContent.tsx
Normal file
106
src/renderer/components/ui/ExpandableContent.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const DEFAULT_COLLAPSED_HEIGHT = 200; // px
|
||||
|
||||
interface ExpandableContentProps {
|
||||
/** Content to render inside the expandable container. */
|
||||
children: React.ReactNode;
|
||||
/** Maximum height (px) before truncation kicks in. Default: 200. */
|
||||
collapsedHeight?: number;
|
||||
/** Extra className applied to the outermost wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic expand/collapse wrapper with:
|
||||
* - Collapsed: content clipped at `collapsedHeight`, mask-image fade, "Show more" button
|
||||
* - Expanded: full content, sticky "Show less" button at viewport bottom
|
||||
*
|
||||
* Uses CSS `mask-image` for the fade so it works on any background color
|
||||
* (zebra stripes, card backgrounds, etc.) without needing to know the bg color.
|
||||
*/
|
||||
export const ExpandableContent = ({
|
||||
children,
|
||||
collapsedHeight = DEFAULT_COLLAPSED_HEIGHT,
|
||||
className,
|
||||
}: ExpandableContentProps): React.JSX.Element => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||
|
||||
// Measure content height via callback ref — re-runs when children change
|
||||
const measureRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node) {
|
||||
requestAnimationFrame(() => {
|
||||
setNeedsTruncation(node.scrollHeight > collapsedHeight);
|
||||
});
|
||||
}
|
||||
},
|
||||
// Re-measure when children identity changes (content prop in callers)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- children identity triggers re-measure
|
||||
[children, collapsedHeight]
|
||||
);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
setExpanded(false);
|
||||
anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={anchorRef} className={className}>
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="relative"
|
||||
style={
|
||||
!expanded && needsTruncation
|
||||
? {
|
||||
maxHeight: collapsedHeight,
|
||||
overflow: 'hidden',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||
maskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Show more */}
|
||||
{!expanded && needsTruncation ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sticky Show less */}
|
||||
{expanded && needsTruncation ? (
|
||||
<div className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCollapse();
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
201
src/renderer/components/ui/MemberSelect.tsx
Normal file
201
src/renderer/components/ui/MemberSelect.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface MemberSelectProps {
|
||||
members: ResolvedTeamMember[];
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
placeholder?: string;
|
||||
/** Show "Unassigned" option at the top of the list */
|
||||
allowUnassigned?: boolean;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md';
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UNASSIGNED_VALUE = '__unassigned__';
|
||||
|
||||
export const MemberSelect = ({
|
||||
members,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select member...',
|
||||
allowUnassigned = false,
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
className,
|
||||
}: MemberSelectProps): React.JSX.Element => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const listboxId = React.useId();
|
||||
|
||||
const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const selectedMember = React.useMemo(
|
||||
() => (value ? members.find((m) => m.name === value) : null),
|
||||
[members, value]
|
||||
);
|
||||
|
||||
const avatarSize = size === 'md' ? 32 : 24;
|
||||
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
||||
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
|
||||
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 resolvedColor = colorMap.get(member.name);
|
||||
const colors = getTeamColorSet(resolvedColor ?? '');
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, avatarSize)}
|
||||
alt=""
|
||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 ${textSize} font-medium tracking-wide`}
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
>
|
||||
{member.name === 'team-lead' ? 'lead' : member.name}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
disabled={disabled}
|
||||
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`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-left">
|
||||
{selectedMember ? (
|
||||
renderMemberInline(selectedMember)
|
||||
) : value === null && allowUnassigned ? (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
avoidCollisions
|
||||
>
|
||||
<CommandPrimitive
|
||||
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="flex items-center border-b border-[var(--color-border)]">
|
||||
<CommandPrimitive.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search members..."
|
||||
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<CommandPrimitive.List
|
||||
id={listboxId}
|
||||
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No members found.
|
||||
</CommandPrimitive.Empty>
|
||||
{allowUnassigned && !search.trim() ? (
|
||||
<CommandPrimitive.Item
|
||||
value={UNASSIGNED_VALUE}
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
|
||||
>
|
||||
<span className="text-[var(--color-text-muted)]">Unassigned</span>
|
||||
{value === null ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</CommandPrimitive.Item>
|
||||
) : null}
|
||||
{members
|
||||
.filter((m) => {
|
||||
if (!search.trim()) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
(m.role?.toLowerCase().includes(q) ?? false) ||
|
||||
(m.agentType?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
})
|
||||
.map((m) => {
|
||||
const isSelected = m.name === value;
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const colors = getTeamColorSet(resolvedColor ?? '');
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
key={m.name}
|
||||
value={m.name}
|
||||
onSelect={() => {
|
||||
onChange(m.name);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}
|
||||
className="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(m.name, avatarSize)}
|
||||
alt=""
|
||||
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium" style={{ color: colors.text }}>
|
||||
{m.name === 'team-lead' ? 'lead' : m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{role}
|
||||
</span>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
})}
|
||||
</CommandPrimitive.List>
|
||||
</CommandPrimitive>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue