Merge pull request #16 from 777genius/dev

Dev - Main
This commit is contained in:
Илия 2026-03-05 22:17:25 +02:00 committed by GitHub
commit ea6fefe7cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
123 changed files with 7926 additions and 2454 deletions

View file

@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our
## Approach 1: Filesystem + SQLite Metadata (Recommended) ## 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 ### Pros
- Best I/O performance — direct filesystem reads, no serialization overhead. - Best I/O performance — direct filesystem reads, no serialization overhead.

View file

@ -1,7 +1,7 @@
{ {
"name": "claude-agent-teams-ui", "name": "claude-agent-teams-ui",
"type": "module", "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", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -80,6 +80,7 @@
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/merge": "^6.12.0", "@codemirror/merge": "^6.12.0",
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",
@ -134,6 +135,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5", "unified": "^11.0.5",
"yet-another-react-lightbox": "^3.29.1",
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -68,6 +68,9 @@ importers:
'@codemirror/language-data': '@codemirror/language-data':
specifier: ^6.5.2 specifier: ^6.5.2
version: 6.5.2 version: 6.5.2
'@codemirror/lint':
specifier: ^6.9.5
version: 6.9.5
'@codemirror/merge': '@codemirror/merge':
specifier: ^6.12.0 specifier: ^6.12.0
version: 6.12.0 version: 6.12.0
@ -230,6 +233,9 @@ importers:
unified: unified:
specifier: ^11.0.5 specifier: ^11.0.5
version: 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: zustand:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.7(@types/react@18.3.27)(react@18.3.1) version: 4.5.7(@types/react@18.3.27)(react@18.3.1)
@ -573,8 +579,8 @@ packages:
'@codemirror/legacy-modes@6.5.2': '@codemirror/legacy-modes@6.5.2':
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.5':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
'@codemirror/merge@6.12.0': '@codemirror/merge@6.12.0':
resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
@ -6164,6 +6170,20 @@ packages:
yauzl@2.10.0: yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} 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: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6432,7 +6452,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.4 '@codemirror/lint': 6.9.5
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
@ -6592,7 +6612,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.5':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15 '@codemirror/view': 6.39.15
@ -13183,6 +13203,14 @@ snapshots:
buffer-crc32: 0.2.13 buffer-crc32: 0.2.13
fd-slicer: 1.1.0 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: {} yocto-queue@0.1.0: {}
zip-stream@4.1.1: zip-stream@4.1.1:

View file

@ -34,6 +34,7 @@ import {
getTrafficLightPositionForZoom, getTrafficLightPositionForZoom,
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
} from '@shared/constants'; } from '@shared/constants';
import { isInboxNoiseMessage, parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@ -132,42 +133,6 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
return resolved; 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. * Extracts human-readable summary and body from an inbox message.
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}). * 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 --- // --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') { if (row.type === 'inbox') {
// Auto-relay direct messages to live team lead process (no UI dependency). // Auto-relay ONLY lead-inbox changes into the live lead process.
if (teamProvisioningService.isTeamAlive(teamName)) { // (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
void teamProvisioningService if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
.relayLeadInboxMessages(teamName) const match = /^inboxes\/(.+)\.json$/.exec(detail);
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)); 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). // Show native OS notification for new inbox messages (debounced per inbox).
@ -490,7 +466,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
void teamDataService void teamDataService
.notifyLeadOnTeammateTaskStart(teamName, taskId) .notifyLeadOnTeammateTaskStart(teamName, taskId)
.catch((e: unknown) => .catch((e: unknown) =>
logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`) logger.warn(
`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}`
)
); );
} }
} catch { } catch {

View file

@ -11,15 +11,18 @@ import {
TEAM_CREATE, TEAM_CREATE,
TEAM_CREATE_CONFIG, TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_TASK_ATTACHMENT,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH, TEAM_GET_PROJECT_BRANCH,
TEAM_GET_TASK_ATTACHMENT,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -37,6 +40,7 @@ import {
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTORE, TEAM_RESTORE,
TEAM_RESTORE_TASK, TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -50,11 +54,9 @@ import {
TEAM_UPDATE_TASK_FIELDS, TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS, TEAM_UPDATE_TASK_STATUS,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_GET_TASK_ATTACHMENT,
TEAM_DELETE_TASK_ATTACHMENT,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
@ -89,7 +91,6 @@ import type {
} from '../services'; } from '../services';
import type { import type {
AttachmentFileData, AttachmentFileData,
AttachmentMediaType,
AttachmentMeta, AttachmentMeta,
AttachmentPayload, AttachmentPayload,
CreateTaskRequest, CreateTaskRequest,
@ -103,6 +104,8 @@ import type {
SendMessageResult, SendMessageResult,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
@ -195,6 +198,7 @@ export function initializeTeamHandlers(
export function registerTeamHandlers(ipcMain: IpcMain): void { export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_LIST, handleListTeams);
ipcMain.handle(TEAM_GET_DATA, handleGetData); ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
export function removeTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_LIST);
ipcMain.removeHandler(TEAM_GET_DATA); ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH); ipcMain.removeHandler(TEAM_LAUNCH);
@ -387,12 +392,6 @@ async function handleGetData(
const provisioning = getTeamProvisioningService(); const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn); 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 displayName = data.config.name || tn;
const projectPath = data.config.projectPath; 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( async function handleCreateTeam(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
request: unknown request: unknown
@ -937,6 +969,13 @@ async function handleSendMessage(
} }
if (stdinSent) { 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 // Persistence is best-effort — stdin already delivered the message
let result: SendMessageResult; let result: SendMessageResult;
try { try {
@ -944,20 +983,14 @@ async function handleSendMessage(
tn, tn,
leadName, leadName,
payload.text!, payload.text!,
payload.summary payload.summary,
attachmentMeta
); );
} catch (persistError) { } catch (persistError) {
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; 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) // Save attachment binary data to disk (best-effort)
if (validatedAttachments?.length && result.messageId) { if (validatedAttachments?.length && result.messageId) {
void attachmentStore void attachmentStore
@ -982,18 +1015,42 @@ async function handleSendMessage(
} }
// Inbox path: offline lead or regular members (no attachment support) // 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, { const result = await getTeamDataService().sendMessage(tn, {
member: memberName, member: memberName,
text: payload.text!, text: memberDeliveryText,
summary: payload.summary, summary: payload.summary,
from: payload.from, 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 // Best-effort relay for lead via inbox
if (isLeadRecipient && isAlive) { if (isLeadRecipient && isAlive) {
void provisioning void provisioning
.relayLeadInboxMessages(tn) .relayLeadInboxMessages(tn)
.catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`)); .catch((e: unknown) =>
logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`)
);
} }
return result; return result;
@ -1982,7 +2039,7 @@ async function handleAddTaskComment(
vTask.value!, vTask.value!,
safeId, safeId,
a.filename, a.filename,
a.mimeType as AttachmentMediaType, a.mimeType,
a.base64Data a.base64Data
); );
savedAttachments.push(meta); savedAttachments.push(meta);
@ -2104,9 +2161,9 @@ async function handleSaveTaskAttachment(
vTeam.value!, vTeam.value!,
vTask.value!, vTask.value!,
safeAttId, safeAttId,
filename as string, filename,
mimeType as AttachmentMediaType, mimeType,
base64Data as string base64Data
); );
// Write metadata into the task JSON // Write metadata into the task JSON
await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta); await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta);
@ -2137,12 +2194,7 @@ async function handleGetTaskAttachment(
} }
return wrapTeamHandler('getTaskAttachment', () => return wrapTeamHandler('getTaskAttachment', () =>
taskAttachmentStore.getAttachment( taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType)
vTeam.value!,
vTask.value!,
safeAttId,
mimeType as AttachmentMediaType
)
); );
} }
@ -2169,12 +2221,7 @@ async function handleDeleteTaskAttachment(
} }
return wrapTeamHandler('deleteTaskAttachment', async () => { return wrapTeamHandler('deleteTaskAttachment', async () => {
await taskAttachmentStore.deleteAttachment( await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType);
vTeam.value!,
vTask.value!,
safeAttId,
mimeType as AttachmentMediaType
);
// Remove metadata from task JSON // Remove metadata from task JSON
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
}); });

View file

@ -162,7 +162,7 @@ export class SubagentResolver {
if (!firstUserMessage) return undefined; if (!firstUserMessage) return undefined;
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
const match = /<teammate-message\s+[^>]*\bteammate_id="([^"]+)"/.exec(text); const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
return match?.[1]; return match?.[1];
} }

View file

@ -97,8 +97,11 @@ export class FileWatcher extends EventEmitter {
private pendingReprocess = new Set<string>(); private pendingReprocess = new Set<string>();
/** Flag to prevent reuse after disposal */ /** Flag to prevent reuse after disposal */
private disposed = false; private disposed = false;
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */ /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files).
private readonly instanceCreatedAt = Date.now(); * Floored to second granularity because filesystem birthtimeMs may have lower resolution
* than Date.now() without this, a file created in the same millisecond-window could
* appear older than the watcher on some platforms (e.g. ext4 on Linux). */
private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000;
constructor( constructor(
dataCache: DataCache, dataCache: DataCache,

View file

@ -41,6 +41,7 @@ export class UpdaterService {
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
} catch (error) { } catch (error) {
logger.error('Check for updates failed:', getErrorMessage(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(); await autoUpdater.downloadUpdate();
} catch (error) { } catch (error) {
logger.error('Download update failed:', getErrorMessage(error)); logger.error('Download update failed:', getErrorMessage(error));
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
} }
} }

View file

@ -449,16 +449,16 @@ export class ChangeExtractorService {
const isError = erroredIds.has(toolUseId); const isError = erroredIds.has(toolUseId);
if (toolName === 'Edit') { if (toolName === 'Edit') {
const path = typeof input.file_path === 'string' ? input.file_path : ''; const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : ''; const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : ''; const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true; const replaceAll = input.replace_all === true;
if (path) { if (targetPath) {
seenFiles.add(path); seenFiles.add(targetPath);
snippets.push({ snippets.push({
toolUseId, toolUseId,
filePath: path, filePath: targetPath,
toolName: 'Edit', toolName: 'Edit',
type: 'edit', type: 'edit',
oldString, oldString,
@ -470,15 +470,15 @@ export class ChangeExtractorService {
}); });
} }
} else if (toolName === 'Write') { } else if (toolName === 'Write') {
const path = typeof input.file_path === 'string' ? input.file_path : ''; const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const writeContent = typeof input.content === 'string' ? input.content : ''; const writeContent = typeof input.content === 'string' ? input.content : '';
if (path) { if (targetPath) {
const isNew = !seenFiles.has(path); const isNew = !seenFiles.has(targetPath);
seenFiles.add(path); seenFiles.add(targetPath);
snippets.push({ snippets.push({
toolUseId, toolUseId,
filePath: path, filePath: targetPath,
toolName: 'Write', toolName: 'Write',
type: isNew ? 'write-new' : 'write-update', type: isNew ? 'write-new' : 'write-update',
oldString: '', oldString: '',
@ -490,11 +490,11 @@ export class ChangeExtractorService {
}); });
} }
} else if (toolName === 'MultiEdit') { } else if (toolName === 'MultiEdit') {
const path = typeof input.file_path === 'string' ? input.file_path : ''; const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const edits = Array.isArray(input.edits) ? input.edits : []; const edits = Array.isArray(input.edits) ? input.edits : [];
if (path) { if (targetPath) {
seenFiles.add(path); seenFiles.add(targetPath);
for (const edit of edits) { for (const edit of edits) {
if (!edit || typeof edit !== 'object') continue; if (!edit || typeof edit !== 'object') continue;
const editObj = edit as Record<string, unknown>; const editObj = edit as Record<string, unknown>;
@ -502,7 +502,7 @@ export class ChangeExtractorService {
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({ snippets.push({
toolUseId, toolUseId,
filePath: path, filePath: targetPath,
toolName: 'MultiEdit', toolName: 'MultiEdit',
type: 'multi-edit', type: 'multi-edit',
oldString, oldString,

View file

@ -26,6 +26,10 @@ function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
} }
function makeId() {
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
}
function formatError(err) { function formatError(err) {
if (!err) return 'Unknown error'; if (!err) return 'Unknown error';
if (typeof err === 'string') return err; if (typeof err === 'string') return err;
@ -42,6 +46,29 @@ function die(message, code = 1) {
process.exit(code); 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) { function parseArgs(argv) {
const out = { _: [], flags: {} }; const out = { _: [], flags: {} };
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
@ -124,7 +151,7 @@ function getTeamName(flags) {
(typeof flags.team === 'string' && flags.team.trim()) || (typeof flags.team === 'string' && flags.team.trim()) ||
(typeof flags['teamName'] === 'string' && flags['teamName'].trim()) || (typeof flags['teamName'] === 'string' && flags['teamName'].trim()) ||
''; '';
if (explicit) return explicit; if (explicit) return assertSafePathSegment('team', explicit);
const inferred = inferTeamNameFromScriptPath(); const inferred = inferTeamNameFromScriptPath();
if (inferred) return inferred; if (inferred) return inferred;
die('Missing --team (and could not infer team name from script path)'); 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) { function normalizeStatus(value) {
const v = String(value || '').trim(); const v = String(value || '').trim();
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v; if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
@ -189,8 +445,9 @@ function normalizeColumn(value) {
function getPaths(flags, teamName) { function getPaths(flags, teamName) {
const claudeDir = getClaudeDir(flags); const claudeDir = getClaudeDir(flags);
const teamDir = path.join(claudeDir, 'teams', teamName); const safeTeam = assertSafePathSegment('team', teamName);
const tasksDir = path.join(claudeDir, 'tasks', teamName); const teamDir = path.join(claudeDir, 'teams', safeTeam);
const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
const kanbanPath = path.join(teamDir, 'kanban-state.json'); const kanbanPath = path.join(teamDir, 'kanban-state.json');
const processesPath = path.join(teamDir, 'processes.json'); const processesPath = path.join(teamDir, 'processes.json');
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
@ -206,7 +463,7 @@ function inferLeadName(paths) {
} }
function readTask(paths, taskId) { function readTask(paths, taskId) {
const taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); const taskPath = getTaskJsonPath(paths, taskId);
const task = readJson(taskPath, null); const task = readJson(taskPath, null);
if (!task) die('Task not found: ' + String(taskId)); if (!task) die('Task not found: ' + String(taskId));
return { taskPath, task }; return { taskPath, task };
@ -297,9 +554,7 @@ function addTaskComment(paths, taskId, flags) {
} }
existing = Array.isArray(task.comments) ? task.comments : []; existing = Array.isArray(task.comments) ? task.comments : [];
commentId = crypto.randomUUID commentId = makeId();
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
comment = { comment = {
id: commentId, id: commentId,
author: from, author: from,
@ -377,13 +632,13 @@ function parseIdList(value) {
function taskExists(paths, taskId) { function taskExists(paths, taskId) {
try { 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; return true;
} catch (e) { return false; } } catch (e) { return false; }
} }
function readTaskObject(paths, taskId) { function readTaskObject(paths, taskId) {
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json'); var taskPath = getTaskJsonPath(paths, taskId);
var t = readJson(taskPath, null); var t = readJson(taskPath, null);
if (!t) die('Task not found: #' + taskId); if (!t) die('Task not found: #' + taskId);
return { task: t, taskPath: taskPath }; return { task: t, taskPath: taskPath };
@ -398,7 +653,8 @@ function wouldCreateBlockCycle(paths, sourceId, targetId) {
if (visited[current]) continue; if (visited[current]) continue;
visited[current] = true; visited[current] = true;
try { 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)) { if (t && Array.isArray(t.blockedBy)) {
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i])); 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; let taskPath;
while (true) { while (true) {
nextId = getNextTaskId(paths); nextId = getNextTaskId(paths);
taskPath = path.join(paths.tasksDir, String(nextId) + '.json'); taskPath = getTaskJsonPath(paths, nextId);
var createdAt = nowIso(); var createdAt = nowIso();
task = { task = {
id: nextId, id: nextId,
@ -595,12 +851,11 @@ function sendInboxMessage(paths, teamName, flags) {
const summary = typeof flags.summary === 'string' ? flags.summary : undefined; const summary = typeof flags.summary === 'string' ? flags.summary : undefined;
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); 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)); ensureDir(path.dirname(inboxPath));
const messageId = crypto.randomUUID const messageId = makeId();
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
const payload = { const payload = {
from, from,
to, to,
@ -640,9 +895,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
// Record review comment in task.comments // Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : []; var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID var reviewCommentId = makeId();
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{ task.comments = existing.concat([{
id: reviewCommentId, id: reviewCommentId,
author: from, author: from,
@ -681,9 +934,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
// Record review comment in task.comments // Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : []; var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID var reviewCommentId = makeId();
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{ task.comments = existing.concat([{
id: reviewCommentId, id: reviewCommentId,
author: from, author: from,
@ -747,7 +998,7 @@ function processRegister(paths, flags) {
const existingIdx = list.findIndex(function (p) { return p.pid === pid; }); const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
const entry = { 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, port: port,
url: url, url: url,
label: label, label: label,
@ -802,7 +1053,8 @@ function taskBriefing(paths, teamName, flags) {
var allTasks = []; var allTasks = [];
for (var i = 0; i < ids.length; i++) { for (var i = 0; i < ids.length; i++) {
try { 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); var t = readJson(taskPath, null);
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) { 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 = ''; } 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 unlink <id> --related <targetId> [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--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 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 set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
' node teamctl.js task briefing --for <member-name> [--team <team>]', ' node teamctl.js task briefing --for <member-name> [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]', ' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
@ -951,6 +1205,8 @@ function printHelp() {
'Options:', 'Options:',
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)', ' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
' --claude-dir <path> Override ~/.claude location', ' --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') ].join('\n')
); );
@ -1044,7 +1300,7 @@ async function main() {
const tasks = []; const tasks = [];
for (const id of ids) { for (const id of ids) {
try { try {
tasks.push(readJson(path.join(paths.tasksDir, String(id) + '.json'), null)); tasks.push(readJson(getTaskJsonPath(paths, id), null));
} catch {} } catch {}
} }
process.stdout.write(JSON.stringify(tasks.filter(Boolean), null, 2) + '\n'); 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'); process.stdout.write('OK comment added to task #' + String(id) + '\n');
return; 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') { if (action === 'set-clarification') {
const id = rest[0] || args.flags.id; const id = rest[0] || args.flags.id;
const val = rest[1] || args.flags.value; const val = rest[1] || args.flags.value;
@ -1252,7 +1544,11 @@ export class TeamAgentToolsInstaller {
} }
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) { 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); await atomicWriteAsync(toolPath, desired);

View file

@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore'); const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments'; const ATTACHMENTS_DIR = 'attachments';
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
export class TeamAttachmentStore { export class TeamAttachmentStore {
private assertSafePathSegment(label: string, value: string): void { private assertSafePathSegment(label: string, value: string): void {
@ -58,6 +59,10 @@ export class TeamAttachmentStore {
let raw: string; let raw: string;
try { 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'); raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') { if ((error as NodeJS.ErrnoException).code === 'ENOENT') {

View file

@ -1,6 +1,7 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -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 members = Array.from(memberMap.values());
const summary: TeamSummary = { const summary: TeamSummary = {
teamName, teamName,

View file

@ -12,6 +12,7 @@ import { killProcessByPid } from '@main/utils/processKill';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -32,6 +33,7 @@ import { TeamTaskWriter } from './TeamTaskWriter';
import type { import type {
AddMemberRequest, AddMemberRequest,
AttachmentMeta,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
InboxMessage, InboxMessage,
@ -41,6 +43,7 @@ import type {
ResolvedTeamMember, ResolvedTeamMember,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
TaskAttachmentMeta,
TaskComment, TaskComment,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
@ -256,8 +259,9 @@ export class TeamDataService {
} }
mark('messages'); mark('messages');
let leadTexts: InboxMessage[] = [];
try { try {
const leadTexts = await this.extractLeadSessionTexts(config); leadTexts = await this.extractLeadSessionTexts(config);
if (leadTexts.length > 0) { if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts]; messages = [...messages, ...leadTexts];
} }
@ -266,8 +270,9 @@ export class TeamDataService {
} }
mark('leadTexts'); mark('leadTexts');
let sentMessages: InboxMessage[] = [];
try { try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName); sentMessages = await this.sentMessagesStore.readMessages(teamName);
if (sentMessages.length > 0) { if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages]; messages = [...messages, ...sentMessages];
} }
@ -276,6 +281,33 @@ export class TeamDataService {
} }
mark('sentMessages'); 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)); messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = []; let metaMembers: TeamConfig['members'] = [];
@ -618,19 +650,31 @@ export class TeamDataService {
} }
async addMember(teamName: string, request: AddMemberRequest): Promise<void> { 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 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) {
if (existing.removedAt) { 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 = { const newMember: TeamMember = {
name: request.name, name,
role: request.role?.trim() || undefined, role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(members.filter((m) => !m.removedAt).length), color: getMemberColor(members.filter((m) => !m.removedAt).length),
joinedAt: Date.now(), joinedAt: Date.now(),
@ -678,6 +722,12 @@ export class TeamDataService {
if (name.toLowerCase() === 'team-lead') { if (name.toLowerCase() === 'team-lead') {
throw new Error('Member name "team-lead" is reserved'); 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()); nextByName.add(name.toLowerCase());
const prev = existingByName.get(name.toLowerCase()); const prev = existingByName.get(name.toLowerCase());
return { return {
@ -812,6 +862,7 @@ export class TeamDataService {
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `New task #${task.id} assigned`, summary: `New task #${task.id} assigned`,
source: 'system_notification',
}); });
} }
} catch { } catch {
@ -856,6 +907,7 @@ export class TeamDataService {
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Task #${task.id} started`, summary: `Task #${task.id} started`,
source: 'system_notification',
}); });
} }
} catch { } catch {
@ -911,9 +963,10 @@ export class TeamDataService {
from: last.actor, from: last.actor,
text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`,
summary: `Task #${task.id} started`, summary: `Task #${task.id} started`,
source: 'system_notification',
}); });
} catch (error) { } catch (error) {
logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`); logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`);
} }
} }
@ -944,7 +997,7 @@ export class TeamDataService {
async addTaskAttachment( async addTaskAttachment(
teamName: string, teamName: string,
taskId: string, taskId: string,
meta: import('@shared/types').TaskAttachmentMeta meta: TaskAttachmentMeta
): Promise<void> { ): Promise<void> {
await this.taskWriter.addAttachment(teamName, taskId, meta); await this.taskWriter.addAttachment(teamName, taskId, meta);
} }
@ -987,7 +1040,7 @@ export class TeamDataService {
teamName: string, teamName: string,
taskId: string, taskId: string,
text: string, text: string,
attachments?: import('@shared/types').TaskAttachmentMeta[] attachments?: TaskAttachmentMeta[]
): Promise<TaskComment> { ): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text, { const comment = await this.taskWriter.addComment(teamName, taskId, text, {
attachments, attachments,
@ -1001,14 +1054,14 @@ export class TeamDataService {
]); ]);
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
const leadName = this.resolveLeadNameFromConfig(config); const leadName = this.resolveLeadNameFromConfig(config);
const owner = task?.owner?.trim() || null;
// Auto-clear needsClarification: "user" on UI comment // Auto-clear needsClarification: "user" on UI comment
// UI comments always have author "user" (TeamTaskWriter default) // UI comments always have author "user" (TeamTaskWriter default)
if (task?.needsClarification === 'user') { if (task?.needsClarification === 'user') {
await this.taskWriter.setNeedsClarification(teamName, taskId, null); 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) // Notify non-lead task owner via inbox (lead → member message)
const parts = [ const parts = [
`Comment on task #${taskId} "${task.subject}":\n\n${text}`, `Comment on task #${taskId} "${task.subject}":\n\n${text}`,
@ -1018,12 +1071,13 @@ export class TeamDataService {
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
]; ];
await this.sendMessage(teamName, { await this.sendMessage(teamName, {
member: task.owner, member: owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, 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. // Notify lead about user's comment on their own task.
// Write to lead's inbox — relay delivers to stdin when process is alive. // Write to lead's inbox — relay delivers to stdin when process is alive.
const parts = [ const parts = [
@ -1038,6 +1092,7 @@ export class TeamDataService {
from: 'user', from: 'user',
text: parts.join('\n'), text: parts.join('\n'),
summary: `Comment on #${taskId}`, summary: `Comment on #${taskId}`,
source: 'system_notification',
}); });
} }
} catch { } catch {
@ -1076,9 +1131,19 @@ export class TeamDataService {
teamName: string, teamName: string,
leadName: string, leadName: string,
text: string, text: string,
summary?: string summary?: string,
attachments?: AttachmentMeta[]
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
const messageId = randomUUID(); 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 = { const msg: InboxMessage = {
from: 'user', from: 'user',
to: leadName, to: leadName,
@ -1088,6 +1153,8 @@ export class TeamDataService {
summary, summary,
messageId, messageId,
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined,
leadSessionId,
}; };
await this.sentMessagesStore.appendMessage(teamName, msg); await this.sentMessagesStore.appendMessage(teamName, msg);
return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
@ -1146,6 +1213,7 @@ export class TeamDataService {
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` + `node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` +
AGENT_BLOCK_CLOSE, AGENT_BLOCK_CLOSE,
summary: `Review request for #${taskId}`, summary: `Review request for #${taskId}`,
source: 'system_notification',
}); });
} catch (error) { } catch (error) {
await this.kanbanManager await this.kanbanManager
@ -1187,7 +1255,19 @@ export class TeamDataService {
await this.membersMetaStore.writeMembers( await this.membersMetaStore.writeMembers(
request.teamName, request.teamName,
request.members.map((member, index) => ({ 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, role: member.role?.trim() || undefined,
agentType: 'general-purpose', agentType: 'general-purpose',
color: getMemberColor(index), color: getMemberColor(index),
@ -1217,9 +1297,24 @@ export class TeamDataService {
// Dedup broadcasts: same sender + same text → process only once // Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>(); 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) { for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
if (msg.source === 'lead_session' || msg.source === 'lead_process') 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}`; const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue; if (processedTexts.has(textKey)) continue;
@ -1325,6 +1420,7 @@ export class TeamDataService {
timestamp, timestamp,
read: true, read: true,
source: 'lead_session', source: 'lead_session',
leadSessionId: config.leadSessionId,
}); });
if (textsReversed.length >= MAX_LEAD_TEXTS) break; if (textsReversed.length >= MAX_LEAD_TEXTS) break;
} }
@ -1347,7 +1443,34 @@ export class TeamDataService {
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> { async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
if (patch.op !== 'request_changes') { 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); 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; return;
} }
@ -1374,6 +1497,7 @@ export class TeamDataService {
`${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` +
`Please fix and mark it as completed when ready.`, `Please fix and mark it as completed when ready.`,
summary: `Fix request for #${taskId}`, summary: `Fix request for #${taskId}`,
source: 'system_notification',
}); });
} catch (error) { } catch (error) {
await this.taskWriter await this.taskWriter

View file

@ -13,6 +13,13 @@ export class TeamInboxWriter {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
const messageId = randomUUID(); const messageId = randomUUID();
const attachmentMeta = request.attachments?.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
const payload: InboxMessage = { const payload: InboxMessage = {
from: request.from ?? 'user', from: request.from ?? 'user',
to: request.member, to: request.member,
@ -21,6 +28,8 @@ export class TeamInboxWriter {
read: false, read: false,
summary: request.summary, summary: request.summary,
messageId, messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
...(request.source && { source: request.source }),
}; };
await withInboxLock(inboxPath, async () => { await withInboxLock(inboxPath, async () => {

View file

@ -1,3 +1,5 @@
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import type { import type {
InboxMessage, InboxMessage,
MemberStatus, MemberStatus,
@ -78,6 +80,14 @@ export class TeamMemberResolver {
// (recipient of SendMessage to "user"). It's not a real AI teammate. // (recipient of SendMessage to "user"). It's not a real AI teammate.
names.delete('user'); 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[] = []; const members: ResolvedTeamMember[] = [];
for (const name of names) { for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -90,6 +91,15 @@ export class TeamMembersMetaStore {
deduped.set(normalized.name, normalized); 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)); 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); 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 = { const payload: TeamMembersMetaFile = {
version: 1, version: 1,
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,7 @@ export class TeamSentMessagesStore {
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,
attachments: Array.isArray(row.attachments) ? row.attachments : undefined, attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined, source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
}); });
} }

View file

@ -10,7 +10,7 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
const TASK_ATTACHMENTS_DIR = 'task-attachments'; const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB 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/png',
'image/jpeg', 'image/jpeg',
'image/gif', 'image/gif',
@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore {
private assertSafePathSegment(label: string, value: string): void { private assertSafePathSegment(label: string, value: string): void {
if ( if (
value.length === 0 || value.length === 0 ||
value.trim().length === 0 ||
value === '.' ||
value === '..' ||
value.includes('/') || value.includes('/') ||
value.includes('\\') || value.includes('\\') ||
value.includes('..') || value.includes('..') ||
@ -37,14 +40,33 @@ export class TeamTaskAttachmentStore {
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId); return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
} }
/** Returns the file path for a specific attachment. */ private sanitizeStoredFilename(original: string): string {
private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string { const raw = String(original ?? '').trim();
this.assertSafePathSegment('attachmentId', attachmentId); const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`); 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. */ /** Returns the file path for a stored attachment (new format). */
private mimeToExt(mimeType: AttachmentMediaType): string { 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) { switch (mimeType) {
case 'image/png': case 'image/png':
return '.png'; return '.png';
@ -54,9 +76,54 @@ export class TeamTaskAttachmentStore {
return '.gif'; return '.gif';
case 'image/webp': case 'image/webp':
return '.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. * Save an attachment to disk. Data is expected as a base64-encoded string.
* Returns metadata for the saved attachment. * Returns metadata for the saved attachment.
@ -69,10 +136,6 @@ export class TeamTaskAttachmentStore {
mimeType: AttachmentMediaType, mimeType: AttachmentMediaType,
base64Data: string base64Data: string
): Promise<TaskAttachmentMeta> { ): Promise<TaskAttachmentMeta> {
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
throw new Error(`Unsupported MIME type: ${mimeType}`);
}
const trimmed = base64Data.trim(); const trimmed = base64Data.trim();
// Avoid allocating huge Buffers for obviously too-large payloads. // Avoid allocating huge Buffers for obviously too-large payloads.
// Base64 decoded size is roughly 3/4 of the string length minus padding. // 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); const dir = this.getTaskDir(teamName, taskId);
await fs.promises.mkdir(dir, { recursive: true }); await fs.promises.mkdir(dir, { recursive: true });
const ext = this.mimeToExt(mimeType); const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename);
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
await fs.promises.writeFile(filePath, buffer); await fs.promises.writeFile(filePath, buffer);
const meta: TaskAttachmentMeta = { const meta: TaskAttachmentMeta = {
@ -119,8 +181,8 @@ export class TeamTaskAttachmentStore {
attachmentId: string, attachmentId: string,
mimeType: AttachmentMediaType mimeType: AttachmentMediaType
): Promise<string | null> { ): Promise<string | null> {
const ext = this.mimeToExt(mimeType); const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); if (!filePath) return null;
try { try {
const buffer = await fs.promises.readFile(filePath); const buffer = await fs.promises.readFile(filePath);
@ -142,8 +204,8 @@ export class TeamTaskAttachmentStore {
attachmentId: string, attachmentId: string,
mimeType: AttachmentMediaType mimeType: AttachmentMediaType
): Promise<void> { ): Promise<void> {
const ext = this.mimeToExt(mimeType); const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); if (!filePath) return;
try { try {
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);

View file

@ -8,7 +8,6 @@ import * as path from 'path';
import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import type { import type {
AttachmentMediaType,
StatusTransition, StatusTransition,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
@ -20,12 +19,18 @@ import type {
const logger = createLogger('Service:TeamTaskReader'); const logger = createLogger('Service:TeamTaskReader');
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([ function isValidMimeTypeString(value: unknown): value is string {
'image/png', if (typeof value !== 'string') return false;
'image/jpeg', const v = value.trim();
'image/gif', if (!v) return false;
'image/webp', // 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 { export class TeamTaskReader {
/** /**
@ -172,8 +177,12 @@ export class TeamTaskReader {
: 'pending', : 'pending',
workIntervals, workIntervals,
statusHistory, statusHistory,
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, blocks: Array.isArray(parsed.blocks)
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, ? (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) related: Array.isArray(parsed.related)
? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string')
: undefined, : undefined,
@ -197,19 +206,32 @@ export class TeamTaskReader {
? c.type ? c.type
: ('regular' as const), : ('regular' as const),
attachments: Array.isArray(c.attachments) attachments: Array.isArray(c.attachments)
? (c.attachments as unknown[]).filter( ? (() => {
(a): a is TaskAttachmentMeta => const filtered = (c.attachments as unknown[])
Boolean(a) && .filter((a): a is TaskAttachmentMeta => {
typeof a === 'object' && if (!a || typeof a !== 'object') return false;
typeof (a as Record<string, unknown>).id === 'string' && const row = a as Record<string, unknown>;
typeof (a as Record<string, unknown>).filename === 'string' && const size = row.size;
typeof (a as Record<string, unknown>).mimeType === 'string' && return (
VALID_ATTACHMENT_MIME_TYPES.has( typeof row.id === 'string' &&
(a as Record<string, unknown>).mimeType as string typeof row.filename === 'string' &&
) && typeof row.mimeType === 'string' &&
typeof (a as Record<string, unknown>).size === 'number' && isValidMimeTypeString(row.mimeType) &&
typeof (a as Record<string, unknown>).addedAt === 'string' 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,
})) }))
: undefined, : undefined,
@ -221,23 +243,25 @@ export class TeamTaskReader {
deletedAt: undefined, // deleted tasks are filtered out below deletedAt: undefined, // deleted tasks are filtered out below
attachments: Array.isArray(parsed.attachments) attachments: Array.isArray(parsed.attachments)
? (parsed.attachments as unknown[]) ? (parsed.attachments as unknown[])
.filter( .filter((a): a is TaskAttachmentMeta => {
(a): a is TaskAttachmentMeta => if (!a || typeof a !== 'object') return false;
Boolean(a) && const row = a as Record<string, unknown>;
typeof a === 'object' && const size = row.size;
typeof (a as Record<string, unknown>).id === 'string' && return (
typeof (a as Record<string, unknown>).filename === 'string' && typeof row.id === 'string' &&
typeof (a as Record<string, unknown>).mimeType === 'string' && typeof row.filename === 'string' &&
VALID_ATTACHMENT_MIME_TYPES.has( typeof row.mimeType === 'string' &&
(a as Record<string, unknown>).mimeType as string isValidMimeTypeString(row.mimeType) &&
) && typeof size === 'number' &&
typeof (a as Record<string, unknown>).size === 'number' && Number.isFinite(size) &&
typeof (a as Record<string, unknown>).addedAt === 'string' size >= 0 &&
) typeof row.addedAt === 'string'
);
})
.map((a) => ({ .map((a) => ({
id: a.id, id: a.id,
filename: a.filename, filename: a.filename,
mimeType: a.mimeType, mimeType: String(a.mimeType).trim(),
size: a.size, size: a.size,
addedAt: a.addedAt, 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; return tasks;
} }

View file

@ -319,6 +319,9 @@ export class TeamTaskWriter {
const task = JSON.parse(raw) as TeamTask; const task = JSON.parse(raw) as TeamTask;
const prevStatus = task.status; const prevStatus = task.status;
if (prevStatus === status) {
return;
}
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
// Maintain workIntervals as periods of time where status === 'in_progress'. // Maintain workIntervals as periods of time where status === 'in_progress'.

View file

@ -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( async function listTeams(
payload: ListTeamsPayload payload: ListTeamsPayload
): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> { ): Promise<{ teams: unknown[]; diag: ListTeamsDiag }> {
@ -392,6 +411,8 @@ async function listTeams(
} }
} }
dropCliAutoSuffixedMembers(memberMap);
const members = Array.from(memberMap.values()); const members = Array.from(memberMap.values());
const summary = { const summary = {
teamName, teamName,

View file

@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list';
/** Get detailed team data */ /** Get detailed team data */
export const TEAM_GET_DATA = 'team:getData'; 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 */ /** Update team kanban state */
export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; export const TEAM_UPDATE_KANBAN = 'team:updateKanban';

View file

@ -67,15 +67,18 @@ import {
TEAM_CREATE, TEAM_CREATE,
TEAM_CREATE_CONFIG, TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_TASK_ATTACHMENT,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH, TEAM_GET_PROJECT_BRANCH,
TEAM_GET_TASK_ATTACHMENT,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
@ -90,12 +93,10 @@ import {
TEAM_REMOVE_MEMBER, TEAM_REMOVE_MEMBER,
TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS, TEAM_REPLACE_MEMBERS,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_GET_TASK_ATTACHMENT,
TEAM_DELETE_TASK_ATTACHMENT,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTORE, TEAM_RESTORE,
TEAM_RESTORE_TASK, TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SET_TASK_CLARIFICATION, TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -167,6 +168,7 @@ import type {
ClaudeRootInfo, ClaudeRootInfo,
CliInstallationStatus, CliInstallationStatus,
CliInstallerProgress, CliInstallerProgress,
CommentAttachmentPayload,
ConflictCheckResult, ConflictCheckResult,
ContextInfo, ContextInfo,
CreateTaskRequest, CreateTaskRequest,
@ -192,11 +194,12 @@ import type {
SshConnectionConfig, SshConnectionConfig,
SshConnectionStatus, SshConnectionStatus,
SshLastConnection, SshLastConnection,
CommentAttachmentPayload,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskChangeSetV2, TaskChangeSetV2,
TaskComment, TaskComment,
TeamChangeEvent, TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = {
getData: async (teamName: string) => { getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName); 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) => { deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName); return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
}, },

View file

@ -45,6 +45,8 @@ import type {
SshLastConnection, SshLastConnection,
SubagentDetail, SubagentDetail,
TeamChangeEvent, TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData, TeamData,
@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI {
getData: async (_teamName: string): Promise<TeamData> => { getData: async (_teamName: string): Promise<TeamData> => {
throw new Error('Teams detail is not available in browser mode'); 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> => { deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode'); throw new Error('Team deletion is not available in browser mode');
}, },

View file

@ -29,6 +29,10 @@ interface DisplayItemListProps {
onItemClick: (itemId: string) => void; onItemClick: (itemId: string) => void;
expandedItemIds: Set<string>; expandedItemIds: Set<string>;
aiGroupId: 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 */ /** Tool use ID to highlight for error deep linking */
highlightToolUseId?: string; highlightToolUseId?: string;
/** Custom highlight color from trigger */ /** Custom highlight color from trigger */
@ -66,6 +70,8 @@ export const DisplayItemList = ({
onItemClick, onItemClick,
expandedItemIds, expandedItemIds,
aiGroupId, aiGroupId,
order = 'chronological',
searchQueryOverride,
highlightToolUseId, highlightToolUseId,
highlightColor, highlightColor,
notificationColorMap, notificationColorMap,
@ -96,7 +102,13 @@ export const DisplayItemList = ({
} }
return ( 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) => { {items.map((item, index) => {
let itemKey = ''; let itemKey = '';
let element: React.ReactNode = null; let element: React.ReactNode = null;
@ -120,6 +132,8 @@ export const DisplayItemList = ({
preview={truncateText(item.content, 150)} preview={truncateText(item.content, 150)}
onClick={() => onItemClick(itemKey)} onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)} isExpanded={expandedItemIds.has(itemKey)}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/> />
); );
break; break;
@ -143,6 +157,8 @@ export const DisplayItemList = ({
preview={truncateText(item.content, 150)} preview={truncateText(item.content, 150)}
onClick={() => onItemClick(itemKey)} onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)} isExpanded={expandedItemIds.has(itemKey)}
markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/> />
); );
break; break;
@ -155,6 +171,7 @@ export const DisplayItemList = ({
linkedTool={item.tool} linkedTool={item.tool}
onClick={() => onItemClick(itemKey)} onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)} isExpanded={expandedItemIds.has(itemKey)}
searchQueryOverride={searchQueryOverride}
isHighlighted={highlightToolUseId === item.tool.id} isHighlighted={highlightToolUseId === item.tool.id}
highlightColor={highlightColor} highlightColor={highlightColor}
notificationDotColor={notificationColorMap?.get(item.tool.id)} notificationDotColor={notificationColorMap?.get(item.tool.id)}
@ -235,7 +252,12 @@ export const DisplayItemList = ({
onClick={() => onItemClick(itemKey)} onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)} isExpanded={expandedItemIds.has(itemKey)}
> >
<MarkdownViewer content={inputContent} copyable /> <MarkdownViewer
content={inputContent}
copyable
itemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem> </BaseItem>
); );
break; break;

View file

@ -12,6 +12,7 @@ import {
COLOR_TEXT_MUTED, COLOR_TEXT_MUTED,
COLOR_TEXT_SECONDARY, COLOR_TEXT_SECONDARY,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { formatPercentOfTotal } from '@renderer/utils/contextMath';
import { formatCostUsd } from '@shared/utils/costFormatting'; import { formatCostUsd } from '@shared/utils/costFormatting';
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
@ -110,7 +111,7 @@ export const SessionContextHeader = ({
)} )}
</div> </div>
{/* Percentage of total */} {/* Percentage of total */}
{totalSessionTokens !== undefined && totalSessionTokens > 0 && ( {formatPercentOfTotal(totalTokens, totalSessionTokens) && (
<span <span
className="rounded px-1.5 py-0.5 tabular-nums" className="rounded px-1.5 py-0.5 tabular-nums"
style={{ style={{
@ -118,7 +119,7 @@ export const SessionContextHeader = ({
color: COLOR_TEXT_MUTED, color: COLOR_TEXT_MUTED,
}} }}
> >
{Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total {formatPercentOfTotal(totalTokens, totalSessionTokens)}
</span> </span>
)} )}
</div> </div>

View file

@ -11,6 +11,7 @@ import {
COLOR_SURFACE_OVERLAY, COLOR_SURFACE_OVERLAY,
COLOR_TEXT_MUTED, COLOR_TEXT_MUTED,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection'; import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
import { FlatInjectionList } from './components/FlatInjectionList'; import { FlatInjectionList } from './components/FlatInjectionList';
@ -132,10 +133,7 @@ export const SessionContextPanel = ({
}, [injections]); }, [injections]);
// Calculate total tokens // Calculate total tokens
const totalTokens = useMemo( const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]);
() => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0),
[injections]
);
// Section token counts // Section token counts
const claudeMdTokens = useMemo( const claudeMdTokens = useMemo(

View file

@ -18,7 +18,7 @@ interface BaseItemProps {
/** Primary label (e.g., "Thinking", "Output", tool name) */ /** Primary label (e.g., "Thinking", "Output", tool name) */
label: string; label: string;
/** Summary text shown after the label */ /** Summary text shown after the label */
summary?: string; summary?: React.ReactNode;
/** Token count to display */ /** Token count to display */
tokenCount?: number; tokenCount?: number;
/** Label for tokens (default: "tokens") */ /** Label for tokens (default: "tokens") */

View file

@ -28,6 +28,8 @@ import {
} from '@shared/constants/triggerColors'; } from '@shared/constants/triggerColors';
import { Wrench } from 'lucide-react'; import { Wrench } from 'lucide-react';
import { highlightQueryInText } from '../searchHighlightUtils';
import { BaseItem, StatusDot } from './BaseItem'; import { BaseItem, StatusDot } from './BaseItem';
import { formatDuration } from './baseItemHelpers'; import { formatDuration } from './baseItemHelpers';
import { import {
@ -45,6 +47,8 @@ interface LinkedToolItemProps {
linkedTool: LinkedToolItemType; linkedTool: LinkedToolItemType;
onClick: () => void; onClick: () => void;
isExpanded: boolean; isExpanded: boolean;
/** Optional local search query override for inline highlighting */
searchQueryOverride?: string;
/** Whether this item should be highlighted for error deep linking */ /** Whether this item should be highlighted for error deep linking */
isHighlighted?: boolean; isHighlighted?: boolean;
/** Custom highlight color from trigger */ /** Custom highlight color from trigger */
@ -59,6 +63,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
linkedTool, linkedTool,
onClick, onClick,
isExpanded, isExpanded,
searchQueryOverride,
isHighlighted, isHighlighted,
highlightColor, highlightColor,
notificationDotColor, notificationDotColor,
@ -66,6 +71,17 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
}) => { }) => {
const status = getToolStatus(linkedTool); const status = getToolStatus(linkedTool);
const summary = getToolSummary(linkedTool.name, linkedTool.input); 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); const elementRef = useRef<HTMLDivElement>(null);
// Combined ref callback - handles both internal ref and external registration // Combined ref callback - handles both internal ref and external registration
@ -155,7 +171,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
/> />
} }
label={linkedTool.name} label={linkedTool.name}
summary={summary} summary={summaryNode}
tokenCount={getToolContextTokens(linkedTool)} tokenCount={getToolContextTokens(linkedTool)}
status={status} status={status}
durationMs={linkedTool.durationMs} durationMs={linkedTool.durationMs}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { highlightQueryInText } from '../searchHighlightUtils';
import { MarkdownViewer } from '../viewers'; import { MarkdownViewer } from '../viewers';
import { BaseItem } from './BaseItem'; import { BaseItem } from './BaseItem';
@ -15,6 +16,10 @@ interface TextItemProps {
preview: string; preview: string;
onClick: () => void; onClick: () => void;
isExpanded: boolean; 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) */ /** Additional classes for highlighting (e.g., error deep linking) */
highlightClasses?: string; highlightClasses?: string;
/** Inline styles for highlighting (used by custom hex colors) */ /** Inline styles for highlighting (used by custom hex colors) */
@ -28,12 +33,24 @@ export const TextItem: React.FC<TextItemProps> = ({
preview, preview,
onClick, onClick,
isExpanded, isExpanded,
searchQueryOverride,
markdownItemId,
highlightClasses, highlightClasses,
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
}) => { }) => {
const fullContent = step.content.outputText ?? preview; const fullContent = step.content.outputText ?? preview;
const truncatedPreview = truncateText(preview, 60); const truncatedPreview = truncateText(preview, 60);
const summary = searchQueryOverride
? highlightQueryInText(
truncatedPreview,
searchQueryOverride,
`${markdownItemId ?? step.id}:summary`,
{
forceAllActive: true,
}
)
: truncatedPreview;
// Get token count from step.tokens.output or step.content.tokenCount // Get token count from step.tokens.output or step.content.tokenCount
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
@ -42,7 +59,7 @@ export const TextItem: React.FC<TextItemProps> = ({
<BaseItem <BaseItem
icon={<MessageSquare className="size-4" />} icon={<MessageSquare className="size-4" />}
label="Output" label="Output"
summary={truncatedPreview} summary={summary}
tokenCount={tokenCount} tokenCount={tokenCount}
onClick={onClick} onClick={onClick}
isExpanded={isExpanded} isExpanded={isExpanded}
@ -50,7 +67,13 @@ export const TextItem: React.FC<TextItemProps> = ({
highlightStyle={highlightStyle} highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor} notificationDotColor={notificationDotColor}
> >
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable /> <MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem> </BaseItem>
); );
}; };

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Brain } from 'lucide-react'; import { Brain } from 'lucide-react';
import { highlightQueryInText } from '../searchHighlightUtils';
import { MarkdownViewer } from '../viewers'; import { MarkdownViewer } from '../viewers';
import { BaseItem } from './BaseItem'; import { BaseItem } from './BaseItem';
@ -15,6 +16,10 @@ interface ThinkingItemProps {
preview: string; preview: string;
onClick: () => void; onClick: () => void;
isExpanded: boolean; 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) */ /** Additional classes for highlighting (e.g., error deep linking) */
highlightClasses?: string; highlightClasses?: string;
/** Inline styles for highlighting (used by custom hex colors) */ /** Inline styles for highlighting (used by custom hex colors) */
@ -28,12 +33,24 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
preview, preview,
onClick, onClick,
isExpanded, isExpanded,
searchQueryOverride,
markdownItemId,
highlightClasses, highlightClasses,
highlightStyle, highlightStyle,
notificationDotColor, notificationDotColor,
}) => { }) => {
const fullContent = step.content.thinkingText ?? preview; const fullContent = step.content.thinkingText ?? preview;
const truncatedPreview = truncateText(preview, 60); const truncatedPreview = truncateText(preview, 60);
const summary = searchQueryOverride
? highlightQueryInText(
truncatedPreview,
searchQueryOverride,
`${markdownItemId ?? step.id}:summary`,
{
forceAllActive: true,
}
)
: truncatedPreview;
// Get token count from step.tokens.output or step.content.tokenCount // Get token count from step.tokens.output or step.content.tokenCount
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
@ -42,7 +59,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
<BaseItem <BaseItem
icon={<Brain className="size-4" />} icon={<Brain className="size-4" />}
label="Thinking" label="Thinking"
summary={truncatedPreview} summary={summary}
tokenCount={tokenCount} tokenCount={tokenCount}
onClick={onClick} onClick={onClick}
isExpanded={isExpanded} isExpanded={isExpanded}
@ -50,7 +67,13 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
highlightStyle={highlightStyle} highlightStyle={highlightStyle}
notificationDotColor={notificationDotColor} notificationDotColor={notificationDotColor}
> >
<MarkdownViewer content={fullContent} maxHeight="max-h-96" copyable /> <MarkdownViewer
content={fullContent}
maxHeight="max-h-96"
copyable
itemId={markdownItemId}
searchQueryOverride={searchQueryOverride}
/>
</BaseItem> </BaseItem>
); );
}; };

View file

@ -35,6 +35,8 @@ export interface SearchContext {
matchCounter: { current: number }; matchCounter: { current: number };
isCurrentItem: boolean; isCurrentItem: boolean;
currentMatchIndexInItem: number | null; 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 = const isCurrentResult =
ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; ctx.forceAllActive === true ||
(ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current);
parts.push( parts.push(
React.createElement( React.createElement(
@ -109,6 +112,19 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode
return parts; return parts;
} }
// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types
export function highlightQueryInText(
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. * Recursively process React children to highlight search terms in text nodes.
* Preserves the React element tree structure (markdown components, etc.) * Preserves the React element tree structure (markdown components, etc.)

View file

@ -1,9 +1,9 @@
import React from 'react'; 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 { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton'; import { CopyButton } from '@renderer/components/common/CopyButton';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { import {
CODE_BG, CODE_BG,
CODE_BORDER, CODE_BORDER,
@ -23,6 +23,7 @@ import {
PROSE_TABLE_BORDER, PROSE_TABLE_BORDER,
PROSE_TABLE_HEADER_BG, PROSE_TABLE_HEADER_BG,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
@ -48,6 +49,8 @@ interface MarkdownViewerProps {
label?: string; // Optional label like "Thinking", "Output", etc. label?: string; // Optional label like "Thinking", "Output", etc.
/** When provided, enables search term highlighting within the markdown */ /** When provided, enables search term highlighting within the markdown */
itemId?: string; 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) */ /** When true, shows a copy button (overlay when no label, inline in header when label exists) */
copyable?: boolean; copyable?: boolean;
/** When true, renders without wrapper background/border (for embedding inside cards) */ /** When true, renders without wrapper background/border (for embedding inside cards) */
@ -60,6 +63,15 @@ interface MarkdownViewerProps {
// Helpers // 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) */ /** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
function isRelativeUrl(url: string): boolean { function isRelativeUrl(url: string): boolean {
return ( return (
@ -200,13 +212,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
), ),
// Links — inline element, no hl(); parent block element's hl() descends here // 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 // mention:// links render as colored inline badges
a: ({ href, children }) => { a: ({ href, children }) => {
if (href?.startsWith('mention://')) { if (href?.startsWith('mention://')) {
const path = href.slice('mention://'.length); const path = href.slice('mention://'.length);
const slashIdx = path.indexOf('/'); 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 colorSet = getTeamColorSet(color);
const bg = colorSet.badge; const bg = colorSet.badge;
return ( return (
@ -223,6 +240,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
</span> </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 ( return (
<a <a
href={href} href={href}
@ -230,7 +262,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
style={{ color: PROSE_LINK }} style={{ color: PROSE_LINK }}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (href && !href.startsWith('task://')) { if (href) {
void api.openExternal(href); void api.openExternal(href);
} }
}} }}
@ -418,6 +450,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
className = '', className = '',
label, label,
itemId, itemId,
searchQueryOverride,
copyable = false, copyable = false,
bare = false, bare = false,
baseDir, baseDir,
@ -560,10 +593,17 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
} }
// Create search context (fresh each render so counter starts at 0) // 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 = const searchCtx =
searchQuery && itemId effectiveQuery && itemId
? createSearchContext(searchQuery, itemId, searchMatches, currentSearchIndex) ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null; : 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 // Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0) // 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]} remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components} components={components}
urlTransform={allowCustomProtocols}
> >
{content} {content}
</ReactMarkdown> </ReactMarkdown>

View 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>
);

View file

@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'notifications' && <NotificationsView />} {tab.type === 'notifications' && <NotificationsView />}
{tab.type === 'settings' && <SettingsView />} {tab.type === 'settings' && <SettingsView />}
{tab.type === 'teams' && <TeamListView />} {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' && ( {tab.type === 'session' && (
<TabUIProvider tabId={tab.id}> <TabUIProvider tabId={tab.id}>
<SessionTabContent tab={tab} isActive={isActive} /> <SessionTabContent tab={tab} isActive={isActive} />

View file

@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => {
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> {isMacElectron && (
<AppLogo size={22} className="shrink-0" /> <div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
</div> <AppLogo size={22} className="shrink-0" />
</div>
)}
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}

View file

@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
Activity, 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({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id, id: tab.id,
data: { data: {
@ -81,13 +89,18 @@ export const SortableTab = ({
transition: isDragging ? 'none' : transition, transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1, opacity: isDragging ? 0.3 : 1,
backgroundColor: isActive backgroundColor: isActive
? 'var(--color-surface-raised)' ? teamColorSet
? teamColorSet.badge
: 'var(--color-surface-raised)'
: isHovered : isHovered
? 'var(--color-surface-overlay)' ? teamColorSet
? teamColorSet.badge
: 'var(--color-surface-overlay)'
: 'transparent', : 'transparent',
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)', color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
}; };
const Icon = TAB_ICONS[tab.type]; const Icon = TAB_ICONS[tab.type];
@ -112,11 +125,7 @@ export const SortableTab = ({
role="tab" role="tab"
tabIndex={0} tabIndex={0}
aria-selected={isActive} aria-selected={isActive}
className={ className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
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'
}
style={style} style={style}
onClick={(e) => onTabClick(tab.id, e)} onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(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" />
<Icon className="size-4 shrink-0" /> {tab.fromSearch && (
{tab.fromSearch && ( <span title="Opened from search">
<span title="Opened from search"> <Search className="size-3 shrink-0 text-amber-400" />
<Search className="size-3 shrink-0 text-amber-400" /> </span>
</span> )}
)} {isPinned && (
{isPinned && ( <span title="Pinned session">
<span title="Pinned session"> <Pin className="size-3 shrink-0 text-blue-400" />
<Pin className="size-3 shrink-0 text-blue-400" /> </span>
</span> )}
)} <span className="truncate text-sm">{tab.label}</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>
{isTeamTab && ( {isTeamTab && (
<TeamTabSectionNav <TeamTabSectionNav
teamName={tab.teamName!} 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> </div>
); );
}; };

View file

@ -302,13 +302,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
scrollContainerRef.current = el; scrollContainerRef.current = el;
setDroppableRef(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={ style={
{ {
maxWidth: '75%',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
overflowX: 'auto',
overflowY: 'hidden',
} as React.CSSProperties } 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. 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. */} Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
<div <div
className="flex-1 self-stretch" className="min-w-[48px] flex-1 self-stretch"
style={ style={
{ {
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined, WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; 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'; 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: 'team', label: 'Team', icon: Users },
{ id: 'sessions', label: 'Sessions', icon: History }, { id: 'sessions', label: 'Sessions', icon: History },
{ id: 'kanban', label: 'Kanban', icon: Columns3 }, { id: 'kanban', label: 'Kanban', icon: Columns3 },
{ id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
{ id: 'messages', label: 'Messages', icon: MessageSquare }, { id: 'messages', label: 'Messages', icon: MessageSquare },
]; ];
@ -70,11 +71,11 @@ export const TeamTabSectionNav = ({
}, [open]); }, [open]);
return ( return (
<div className="w-full" onPointerDown={(e) => e.stopPropagation()}> <div className="shrink-0" onPointerDown={(e) => e.stopPropagation()}>
<button <button
ref={buttonRef} ref={buttonRef}
type="button" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpen((prev) => !prev); setOpen((prev) => !prev);

View file

@ -63,7 +63,14 @@ export const TriggerPreview = ({
{/* Truncation warning - only shown when timeout or count limit hit */} {/* Truncation warning - only shown when timeout or count limit hit */}
{previewResult.truncated && ( {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" /> <AlertTriangle className="size-4 shrink-0" />
<span> <span>
Search stopped early (timeout or count limit). Actual matches may be higher. Search stopped early (timeout or count limit). Actual matches may be higher.

View file

@ -3,7 +3,7 @@
* Provides UI for managing notifications, display settings, and advanced options. * 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 { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
@ -23,15 +23,14 @@ export const SettingsView = (): React.JSX.Element | null => {
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
// Consume pending section during render (React-recommended pattern for adjusting state on prop change) // Consume pending section (avoid setState during render)
const [prevPending, setPrevPending] = useState<string | null>(null); useEffect(() => {
if (pendingSettingsSection !== prevPending) {
setPrevPending(pendingSettingsSection);
if (pendingSettingsSection) { if (pendingSettingsSection) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setActiveSection(pendingSettingsSection as SettingsSection); setActiveSection(pendingSettingsSection as SettingsSection);
clearPendingSettingsSection(); clearPendingSettingsSection();
} }
} }, [pendingSettingsSection, clearPendingSettingsSection]);
const { const {
config, config,

View file

@ -7,11 +7,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png'; import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store'; 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 { SettingsSectionHeader } from '../components';
import { CliStatusSection } from './CliStatusSection'; import { CliStatusSection } from './CliStatusSection';
import { ConfigEditorDialog } from './ConfigEditorDialog';
interface AdvancedSectionProps { interface AdvancedSectionProps {
readonly saving: boolean; readonly saving: boolean;
@ -30,6 +31,7 @@ export const AdvancedSection = ({
}: AdvancedSectionProps): React.JSX.Element => { }: AdvancedSectionProps): React.JSX.Element => {
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState<string>(''); const [version, setVersion] = useState<string>('');
const [configEditorOpen, setConfigEditorOpen] = useState(false);
const updateStatus = useStore((s) => s.updateStatus); const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion); const availableVersion = useStore((s) => s.availableVersion);
const checkForUpdates = useStore((s) => s.checkForUpdates); const checkForUpdates = useStore((s) => s.checkForUpdates);
@ -95,6 +97,17 @@ export const AdvancedSection = ({
<div> <div>
<SettingsSectionHeader title="Configuration" /> <SettingsSectionHeader title="Configuration" />
<div className="space-y-2 py-2"> <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 <button
onClick={onResetToDefaults} onClick={onResetToDefaults}
disabled={saving} disabled={saving}
@ -195,6 +208,14 @@ export const AdvancedSection = ({
</p> </p>
</div> </div>
</div> </div>
<ConfigEditorDialog
open={configEditorOpen}
onClose={() => setConfigEditorOpen(false)}
onConfigSaved={() => {
// Config saved via editor — settings page will pick up changes on next render
}}
/>
</div> </div>
); );
}; };

View file

@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
fetchCliStatus, fetchCliStatus,
installCli, installCli,
isBusy, isBusy,
cliStatusLoading,
} = useCliInstaller(); } = useCliInstaller();
useEffect(() => { useEffect(() => {
@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{cliStatus.installed && !cliStatus.updateAvailable && ( {cliStatus.installed && !cliStatus.updateAvailable && (
<button <button
onClick={handleRefresh} 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={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}} }}
> >
<RefreshCw className="size-3.5" /> {cliStatusLoading ? (
Check for Updates <>
<Loader2 className="size-3.5 animate-spin" />
Checking...
</>
) : (
<>
<RefreshCw className="size-3.5" />
Check for Updates
</>
)}
</button> </button>
)} )}
</div> </div>

View 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',
},
});

View file

@ -366,11 +366,16 @@ export const GeneralSection = ({
confirmLabel: 'Restart', confirmLabel: 'Restart',
}); });
if (shouldRelaunch) { if (shouldRelaunch) {
onGeneralToggle('useNativeTitleBar', v); // Await config write before relaunch to avoid race condition on Windows
// Small delay to let config persist before relaunch // (antivirus/NTFS can delay file writes beyond a fixed timeout)
setTimeout(() => { try {
void window.electronAPI?.windowControls?.relaunch(); await api.config.update('general', { useNativeTitleBar: v });
}, 200); } 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} disabled={saving}
@ -503,7 +508,7 @@ export const GeneralSection = ({
{candidate.path} {candidate.path}
</p> </p>
{!candidate.hasProjectsDir && ( {!candidate.hasProjectsDir && (
<p className="text-[11px] text-amber-400"> <p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects directory detected No projects directory detected
</p> </p>
)} )}

View file

@ -150,7 +150,7 @@ type VirtualItem =
* Mismatch causes items to overlap! * Mismatch causes items to overlap!
*/ */
const HEADER_HEIGHT = 28; 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 LOADER_HEIGHT = 36;
const OVERSCAN = 5; const OVERSCAN = 5;

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState'; import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -96,6 +97,7 @@ export const GlobalTaskList = ({
globalTasksLoading, globalTasksLoading,
globalTasksInitialized, globalTasksInitialized,
fetchAllTasks, fetchAllTasks,
softDeleteTask,
projects, projects,
viewMode, viewMode,
repositoryGroups, repositoryGroups,
@ -106,6 +108,7 @@ export const GlobalTaskList = ({
globalTasksLoading: s.globalTasksLoading, globalTasksLoading: s.globalTasksLoading,
globalTasksInitialized: s.globalTasksInitialized, globalTasksInitialized: s.globalTasksInitialized,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
softDeleteTask: s.softDeleteTask,
projects: s.projects, projects: s.projects,
viewMode: s.viewMode, viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
@ -145,6 +148,29 @@ export const GlobalTaskList = ({
setRenamingTaskKey(null); 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 // Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching. // duplicate IPC calls when the centralized init chain is already fetching.
useEffect(() => { useEffect(() => {
@ -224,6 +250,7 @@ export const GlobalTaskList = ({
// Reset showArchived when archive becomes empty // Reset showArchived when archive becomes empty
useEffect(() => { useEffect(() => {
if (showArchived && !hasArchivedTasks) { if (showArchived && !hasArchivedTasks) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setShowArchived(false); setShowArchived(false);
} }
}, [showArchived, hasArchivedTasks]); }, [showArchived, hasArchivedTasks]);
@ -329,6 +356,7 @@ export const GlobalTaskList = ({
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
@ -425,6 +453,7 @@ export const GlobalTaskList = ({
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
@ -472,6 +501,7 @@ export const GlobalTaskList = ({
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
@ -523,6 +553,7 @@ export const GlobalTaskList = ({
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)} onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
onDelete={() => handleDeleteTask(task.teamName, task.id)}
> >
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}

View file

@ -240,13 +240,13 @@ export const SessionItem = ({
} }
}, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); }, [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 ( return (
<> <>
<button <button
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} 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={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}), ...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
@ -268,7 +268,7 @@ export const SessionItem = ({
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />} {isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />} {isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
<span <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)' }} style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
> >
{session.firstMessage ?? 'Untitled'} {session.firstMessage ?? 'Untitled'}

View file

@ -96,6 +96,7 @@ export const SidebarTaskItem = ({
// Reset edit value when renaming starts // Reset edit value when renaming starts
useEffect(() => { useEffect(() => {
if (isRenaming) { if (isRenaming) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setEditValue(displaySubject); setEditValue(displaySubject);
} }
}, [isRenaming, displaySubject]); }, [isRenaming, displaySubject]);

View file

@ -5,7 +5,7 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from '@renderer/components/ui/context-menu'; } 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'; import type { GlobalTask } from '@shared/types';
@ -16,6 +16,7 @@ export interface TaskContextMenuProps {
onTogglePin: () => void; onTogglePin: () => void;
onToggleArchive: () => void; onToggleArchive: () => void;
onRename: () => void; onRename: () => void;
onDelete?: () => void;
children: React.ReactNode; children: React.ReactNode;
} }
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
onTogglePin, onTogglePin,
onToggleArchive, onToggleArchive,
onRename, onRename,
onDelete,
children, children,
}: TaskContextMenuProps): React.JSX.Element => { }: TaskContextMenuProps): React.JSX.Element => {
return ( return (
@ -68,6 +70,19 @@ export const TaskContextMenu = ({
</> </>
)} )}
</ContextMenuItem> </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> </ContextMenuContent>
</ContextMenu> </ContextMenu>
); );

View 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>
);
};

View 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>
);
};

View file

@ -10,6 +10,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
import { Bot, ChevronRight } from 'lucide-react'; import { Bot, ChevronRight } from 'lucide-react';
@ -18,6 +19,11 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
interface CliLogsRichViewProps { interface CliLogsRichViewProps {
cliLogsTail: string; 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; className?: string;
} }
@ -43,10 +49,12 @@ const FlatGroupItem = ({
group, group,
expandedItemIds, expandedItemIds,
onItemClick, onItemClick,
searchQueryOverride,
}: { }: {
group: StreamJsonGroup; group: StreamJsonGroup;
expandedItemIds: Set<string>; expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void; onItemClick: (itemId: string) => void;
searchQueryOverride?: string;
}): React.JSX.Element => { }): React.JSX.Element => {
const groupItemIds = useMemo( const groupItemIds = useMemo(
() => scopedItemIds(expandedItemIds, group.id), () => scopedItemIds(expandedItemIds, group.id),
@ -63,6 +71,7 @@ const FlatGroupItem = ({
onItemClick={handleItemClick} onItemClick={handleItemClick}
expandedItemIds={groupItemIds} expandedItemIds={groupItemIds}
aiGroupId={group.id} aiGroupId={group.id}
searchQueryOverride={searchQueryOverride}
/> />
); );
}; };
@ -76,12 +85,14 @@ const StreamGroup = ({
onToggle, onToggle,
expandedItemIds, expandedItemIds,
onItemClick, onItemClick,
searchQueryOverride,
}: { }: {
group: StreamJsonGroup; group: StreamJsonGroup;
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
expandedItemIds: Set<string>; expandedItemIds: Set<string>;
onItemClick: (itemId: string) => void; onItemClick: (itemId: string) => void;
searchQueryOverride?: string;
}): React.JSX.Element => { }): React.JSX.Element => {
// Scope item IDs to this group to avoid cross-group collisions // Scope item IDs to this group to avoid cross-group collisions
const groupItemIds = useMemo( const groupItemIds = useMemo(
@ -109,7 +120,11 @@ const StreamGroup = ({
/> />
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" /> <Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]"> <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> </span>
</button> </button>
{isExpanded && ( {isExpanded && (
@ -119,6 +134,7 @@ const StreamGroup = ({
onItemClick={handleItemClick} onItemClick={handleItemClick}
expandedItemIds={groupItemIds} expandedItemIds={groupItemIds}
aiGroupId={group.id} aiGroupId={group.id}
searchQueryOverride={searchQueryOverride}
/> />
</div> </div>
)} )}
@ -128,9 +144,13 @@ const StreamGroup = ({
export const CliLogsRichView = ({ export const CliLogsRichView = ({
cliLogsTail, cliLogsTail,
order = 'oldest-first',
onScroll,
containerRefCallback,
searchQueryOverride,
className, className,
}: CliLogsRichViewProps): React.JSX.Element => { }: 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) // Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set()); const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIds, setExpandedItemIds] = 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 // Auto-scroll to bottom on new content
useEffect(() => { useEffect(() => {
if (scrollRef.current) { 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) => { const handleGroupToggle = useCallback((groupId: string) => {
setCollapsedGroupIds((prev) => { setCollapsedGroupIds((prev) => {
@ -184,11 +208,18 @@ export const CliLogsRichView = ({
const hasContent = cliLogsTail.trim().length > 0; const hasContent = cliLogsTail.trim().length > 0;
return ( return (
<div <div
ref={scrollRef} ref={(el) => {
scrollRef.current = el;
containerRefCallback?.(el);
}}
className={cn( className={cn(
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
className className
)} )}
onScroll={(e) => {
const el = e.currentTarget;
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
}}
> >
{hasContent ? ( {hasContent ? (
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"> <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 ( return (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}> <div
{groups.map((group) => 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 ? ( group.items.length === 1 ? (
// Single item — render flat without collapsible group wrapper // Single item — render flat without collapsible group wrapper
<FlatGroupItem <FlatGroupItem
@ -213,6 +256,7 @@ export const CliLogsRichView = ({
group={group} group={group}
expandedItemIds={expandedItemIds} expandedItemIds={expandedItemIds}
onItemClick={handleItemClick} onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/> />
) : ( ) : (
<StreamGroup <StreamGroup
@ -222,6 +266,7 @@ export const CliLogsRichView = ({
onToggle={() => handleGroupToggle(group.id)} onToggle={() => handleGroupToggle(group.id)}
expandedItemIds={expandedItemIds} expandedItemIds={expandedItemIds}
onItemClick={handleItemClick} onItemClick={handleItemClick}
searchQueryOverride={searchQueryOverride}
/> />
) )
)} )}

View file

@ -114,7 +114,7 @@ export const CollapsibleTeamSection = ({
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>} {action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div> </div>
{isOpen && ( {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} {children}
</div> </div>
)} )}

View file

@ -44,27 +44,67 @@ function formatElapsed(seconds: number): string {
return `${m}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`;
} }
function useElapsedTimer(startedAt?: string): string | null { function useElapsedTimer(startedAt?: string, isRunning = true): string | null {
const [elapsed, setElapsed] = useState<string | null>(null); const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
useEffect(() => { 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); 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 tick = (): void => {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); setElapsedSeconds(computeElapsedSeconds());
setElapsed(formatElapsed(seconds));
}; };
tick(); tick();
const id = window.setInterval(tick, 1000); const id = window.setInterval(tick, 1000);
return () => { return () => {
window.clearInterval(id); window.clearInterval(id);
}; };
}, [startedAt]); }, [startedAt, isRunning]);
if (!startedAt) return null; 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 = ({ export const ProvisioningProgressBlock = ({
@ -81,11 +121,12 @@ export const ProvisioningProgressBlock = ({
assistantOutput, assistantOutput,
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt); const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(false); const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail));
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
// Auto-scroll assistant output // Auto-scroll assistant output
useEffect(() => { useEffect(() => {
@ -99,6 +140,14 @@ export const ProvisioningProgressBlock = ({
setLiveOutputOpen(defaultLiveOutputOpen); setLiveOutputOpen(defaultLiveOutputOpen);
}, [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 ( return (
<div <div
className={cn( className={cn(
@ -137,27 +186,26 @@ export const ProvisioningProgressBlock = ({
<p <p
className={cn( className={cn(
'mt-1.5 text-xs', '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} {message}
</p> </p>
) : null} ) : 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) => { {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => {
const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isDone = currentStepIndex >= 0 && index < currentStepIndex;
const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex;
return ( return (
<div key={step} className="flex items-center gap-1"> <div key={step} className="flex items-center gap-1">
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', 'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-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 && 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]"> <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> </span>
{STEP_LABELS[step]} {STEP_LABELS[step]}
</Badge> </Badge>
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span> <span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null} ) : null}
@ -190,13 +237,13 @@ export const ProvisioningProgressBlock = ({
isError && 'border-red-500/40' isError && 'border-red-500/40'
)} )}
> >
{assistantOutput ? ( {displayAssistantOutput ? (
<MarkdownViewer content={assistantOutput} bare maxHeight="max-h-none" /> <MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
) : ( ) : (
<p <p
className={cn( className={cn(
'text-[11px]', '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. No output captured yet.

View 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>
);
};

View 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

View file

@ -255,6 +255,21 @@ export const TeamListView = (): React.JSX.Element => {
}; };
}, [electronMode, teams]); }, [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(() => { const currentProjectPath = useMemo(() => {
if (viewMode === 'grouped') { if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
@ -367,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => {
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { 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( const handleRestoreTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
void restoreTeam(teamName); void (async () => {
try {
await restoreTeam(teamName);
} catch {
// error via store
}
})();
}, },
[restoreTeam] [restoreTeam]
); );
@ -394,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => {
variant: 'danger', variant: 'danger',
}); });
if (confirmed) { if (confirmed) {
void permanentlyDeleteTeam(teamName); try {
await permanentlyDeleteTeam(teamName);
} catch {
// error via store
}
} }
})(); })();
}, },
@ -527,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={teamsLoading}
onClick={() => { onClick={() => {
void fetchTeams(); void fetchTeams();
}} }}
> >
{teamsLoading ? (
<RotateCcw className="size-3.5 animate-spin" />
) : null}
Refresh Refresh
</Button> </Button>
</div> </div>

View file

@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({
return ( return (
<div className="mb-3"> <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"> <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 <Button
variant="outline" variant="outline"
size="sm" 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)} onClick={() => setDismissed(true)}
> >
<X size={12} /> <X size={12} />
@ -108,13 +108,13 @@ export const TeamProvisioningBanner = ({
if (isReady) { if (isReady) {
return ( return (
<div className="mb-3"> <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"> <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-emerald-400" /> <CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p> <p className="flex-1 text-xs text-[var(--step-success-text)]">Team launched process alive</p>
<Button <Button
variant="outline" variant="outline"
size="sm" 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)} onClick={() => setDismissed(true)}
> >
<X size={12} /> <X size={12} />

View file

@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { import {
CARD_BG, CARD_BG,
@ -174,25 +176,25 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
}); });
} }
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
/** Render `#<digits>` in plain text as clickable inline elements. */
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
return text.split(/(#\d+)/g).map((part, i) => { return text.split(/(#\d+)/g).map((part, i) => {
const match = /^#(\d+)$/.exec(part); const match = /^#(\d+)$/.exec(part);
if (!match) return <span key={i}>{part}</span>; if (!match) return <span key={i}>{part}</span>;
const taskId = match[1]; const taskId = match[1];
return ( return (
<button <TaskTooltip key={i} taskId={taskId}>
key={i} <button
type="button" type="button"
className="cursor-pointer font-medium text-blue-400 hover:underline" className="cursor-pointer font-medium text-blue-400 hover:underline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(taskId); onClick(taskId);
}} }}
> >
{part} {part}
</button> </button>
</TaskTooltip>
); );
}); });
} }
@ -233,25 +235,30 @@ export const ActivityItem = ({
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel); const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text + linkify task IDs + @mentions // Strip agent-only blocks + normalize escape sequences (before linkification)
const displayText = useMemo(() => { const strippedText = useMemo(() => {
if (structured) return null; if (structured) return null;
const stripped = stripAgentBlocks(message.text).trim(); const stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead if (!stripped) return null; // All content was agent-only blocks → show summary instead
// Normalize literal \n from CLI tools (teamctl.js) to real newlines // Normalize literal \n from CLI tools (teamctl.js) to real newlines
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
let result = normalized; }, [structured, message.text]);
if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result);
// 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) if (memberColorMap && memberColorMap.size > 0)
result = linkifyMentionsInMarkdown(result, memberColorMap); result = linkifyMentionsInMarkdown(result, memberColorMap);
return result; return result;
}, [structured, message.text, onTaskIdClick, memberColorMap]); }, [strippedText, memberColorMap]);
// Check if this is a reply message
const parsedReply = useMemo(
() => (displayText ? parseMessageReply(displayText) : null),
[displayText]
);
const rawSummary = const rawSummary =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
@ -276,25 +283,34 @@ export const ActivityItem = ({
}; };
const isHeaderClickable = Boolean(systemLabel); const isHeaderClickable = Boolean(systemLabel);
const isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system';
return ( return (
<article <article
className="group overflow-hidden rounded-md" className="group rounded-md [overflow:clip]"
style={{ style={{
marginLeft: isUserSent ? 15 : undefined,
backgroundColor: backgroundColor:
rateLimited || isApiError rateLimited || isApiError
? 'var(--tool-result-error-bg)' ? 'var(--tool-result-error-bg)'
: zebraShade : isSystemMessage
? CARD_BG_ZEBRA ? 'var(--system-activity-bg)'
: CARD_BG, : zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border: border:
rateLimited || isApiError rateLimited || isApiError
? '1px solid var(--tool-result-error-border)' ? '1px solid var(--tool-result-error-border)'
: CARD_BORDER_STYLE, : isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
borderLeft: borderLeft:
rateLimited || isApiError rateLimited || isApiError
? '3px solid var(--tool-result-error-text)' ? '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) */} {/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
@ -336,7 +352,7 @@ export const ActivityItem = ({
<MemberBadge <MemberBadge
name={message.from} name={message.from}
color={memberColor ?? message.color} color={memberColor ?? message.color}
hideAvatar={message.from === 'user'} hideAvatar={message.from === 'user' || message.from === 'system'}
onClick={onMemberNameClick} onClick={onMemberNameClick}
/> />
@ -467,27 +483,29 @@ export const ActivityItem = ({
</details> </details>
</div> </div>
) : parsedReply ? ( ) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} /> <ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} />
) : displayText ? ( ) : displayText ? (
<span <ExpandableContent>
onClickCapture={ <span
onTaskIdClick onClickCapture={
? (e) => { onTaskIdClick
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>( ? (e) => {
'a[href^="task://"]' const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
); 'a[href^="task://"]'
if (link) { );
e.preventDefault(); if (link) {
e.stopPropagation(); e.preventDefault();
const taskId = link.getAttribute('href')?.replace('task://', ''); e.stopPropagation();
if (taskId) onTaskIdClick(taskId); const taskId = link.getAttribute('href')?.replace('task://', '');
if (taskId) onTaskIdClick(taskId);
}
} }
} : undefined
: undefined }
} >
> <MarkdownViewer content={displayText} maxHeight="max-h-none" copyable bare />
<MarkdownViewer content={displayText} maxHeight="max-h-56" copyable bare /> </span>
</span> </ExpandableContent>
) : summaryText ? ( ) : summaryText ? (
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}> <p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText} {summaryText}

View file

@ -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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
import type { TimelineItem } from './LeadThoughtsGroup';
interface ActivityTimelineProps { interface ActivityTimelineProps {
messages: InboxMessage[]; messages: InboxMessage[];
@ -158,7 +159,7 @@ export const ActivityTimeline = ({
if (leadMember) { if (leadMember) {
const leadInfo = memberInfo.get(leadMember.name); const leadInfo = memberInfo.get(leadMember.name);
if (leadInfo) { 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] [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 zebraShadeSet = useMemo(() => {
const result = new Set<number>(); const result = new Set<number>();
let cardCount = 0; let cardCount = 0;
for (let i = 0; i < visibleMessages.length; i++) { for (let i = 0; i < timelineItems.length; i++) {
if (isNoiseMessage(visibleMessages[i].text)) continue; const item = timelineItems[i];
if (cardCount % 2 === 1) result.add(i); if (item.type === 'lead-thoughts') {
cardCount++; // 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; return result;
}, [visibleMessages]); }, [timelineItems]);
// Determine which messages are "new" (should animate). // Determine which items are "new" (should animate).
const newMessageKeys = useMemo(() => { const newItemKeys = useMemo(() => {
const getKey = (msg: InboxMessage, idx: number): string => const getItemKey = (item: TimelineItem): string => {
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`; 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 // First render: seed known keys, no animations
if (!isInitializedRef.current) { if (!isInitializedRef.current) {
isInitializedRef.current = true; isInitializedRef.current = true;
for (let i = 0; i < visibleMessages.length; i++) { for (const key of allKeys) {
knownKeysRef.current.add(getKey(visibleMessages[i], i)); knownKeysRef.current.add(key);
} }
prevVisibleCountRef.current = visibleCount; prevVisibleCountRef.current = visibleCount;
return new Set<string>(); return new Set<string>();
@ -218,23 +240,22 @@ export const ActivityTimeline = ({
prevVisibleCountRef.current = visibleCount; prevVisibleCountRef.current = visibleCount;
if (isPaginationExpansion) { if (isPaginationExpansion) {
for (let i = 0; i < visibleMessages.length; i++) { for (const key of allKeys) {
knownKeysRef.current.add(getKey(visibleMessages[i], i)); knownKeysRef.current.add(key);
} }
return new Set<string>(); return new Set<string>();
} }
// Normal update: unknown keys are new messages // Normal update: unknown keys are new items
const newKeys = new Set<string>(); const newKeys = new Set<string>();
for (let i = 0; i < visibleMessages.length; i++) { for (const key of allKeys) {
const key = getKey(visibleMessages[i], i);
if (!knownKeysRef.current.has(key)) { if (!knownKeysRef.current.has(key)) {
newKeys.add(key); newKeys.add(key);
knownKeysRef.current.add(key); knownKeysRef.current.add(key);
} }
} }
return newKeys; return newKeys;
}, [visibleMessages, visibleCount]); }, [timelineItems, visibleCount]);
/* eslint-enable react-hooks/refs -- end animation tracking block */ /* eslint-enable react-hooks/refs -- end animation tracking block */
const handleShowMore = (): void => { 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 ( return (
<div className="space-y-1"> <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 info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor = const recipientColor =
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
const globalIndex = index; const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
const messageKey = `${message.messageId ?? globalIndex}-${message.timestamp}-${message.from}`;
const isUnread = readState const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message)) ? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read; : !message.read;
return ( return (
<MessageRowWithObserver <React.Fragment key={messageKey}>
key={messageKey} {sessionSeparator}
message={message} <MessageRowWithObserver
teamName={teamName} message={message}
memberRole={info?.role} teamName={teamName}
memberColor={info?.color} memberRole={info?.role}
recipientColor={recipientColor} memberColor={info?.color}
isUnread={isUnread} recipientColor={recipientColor}
isNew={newMessageKeys.has(messageKey)} isUnread={isUnread}
zebraShade={zebraShadeSet.has(index)} isNew={newItemKeys.has(messageKey)}
memberColorMap={colorMap} zebraShade={zebraShadeSet.has(index)}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined} memberColorMap={colorMap}
onCreateTask={onCreateTaskFromMessage} onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onReply={onReplyToMessage} onCreateTask={onCreateTaskFromMessage}
onVisible={onMessageVisible} onReply={onReplyToMessage}
onTaskIdClick={onTaskIdClick} onVisible={onMessageVisible}
onRestartTeam={onRestartTeam} onTaskIdClick={onTaskIdClick}
/> onRestartTeam={onRestartTeam}
/>
</React.Fragment>
); );
})} })}
{hiddenCount > 0 && ( {hiddenCount > 0 && (

View 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>
);
};

View file

@ -1,29 +1,65 @@
import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting';
interface ReplyQuoteBlockProps { interface ReplyQuoteBlockProps {
reply: ParsedMessageReply; 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. */ /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
bodyMaxHeight?: string; bodyMaxHeight?: string;
} }
/** Threshold (characters) above which the "more/less" toggle is shown. */
const LONG_QUOTE_THRESHOLD = 200;
export const ReplyQuoteBlock = ({ export const ReplyQuoteBlock = ({
reply, reply,
memberColor,
bodyMaxHeight = 'max-h-56', bodyMaxHeight = 'max-h-56',
}: ReplyQuoteBlockProps): React.JSX.Element => ( }: ReplyQuoteBlockProps): React.JSX.Element => {
<div className="space-y-2"> const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
<div const [expanded, setExpanded] = useState(false);
className="rounded-md border-l-2 border-[var(--color-border-emphasis)] bg-[var(--color-surface)] px-3 py-2"
style={{ opacity: 0.7 }} const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]';
>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]"> return (
@{reply.agentName} <div className="space-y-2">
</span> {/* Quote block — styled like SendMessageDialog */}
<div className="line-clamp-3 text-xs text-[var(--color-text-muted)]"> <div className="relative overflow-hidden rounded-md border border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
<MarkdownViewer content={reply.originalText} maxHeight="max-h-[60px]" bare /> {/* 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]">
&ldquo;
</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> </div>
{/* Reply text */}
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
</div> </div>
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare /> );
</div> };
);

View file

@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
</div> </div>
{lightboxIndex !== null && items[lightboxIndex] ? ( {lightboxIndex !== null && items[lightboxIndex] ? (
<ImageLightbox <ImageLightbox
src={items[lightboxIndex].dataUrl}
alt={items[lightboxIndex].meta.filename}
open open
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))}
index={lightboxIndex}
/> />
) : null} ) : null}
</> </>

View file

@ -1,5 +1,5 @@
import { formatFileSize } from '@renderer/utils/attachmentUtils'; import { formatFileSize } from '@renderer/utils/attachmentUtils';
import { X } from 'lucide-react'; import { Ban, X } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types';
interface AttachmentPreviewItemProps { interface AttachmentPreviewItemProps {
attachment: AttachmentPayload; attachment: AttachmentPayload;
onRemove: (id: string) => void; onRemove: (id: string) => void;
disabled?: boolean;
} }
export const AttachmentPreviewItem = ({ export const AttachmentPreviewItem = ({
attachment, attachment,
onRemove, onRemove,
disabled,
}: AttachmentPreviewItemProps): React.JSX.Element => { }: AttachmentPreviewItemProps): React.JSX.Element => {
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
return ( 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"> <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" /> <AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]"> <span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">

View file

@ -8,12 +8,18 @@ interface AttachmentPreviewListProps {
attachments: AttachmentPayload[]; attachments: AttachmentPayload[];
onRemove: (id: string) => void; onRemove: (id: string) => void;
error?: string | null; 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 = ({ export const AttachmentPreviewList = ({
attachments, attachments,
onRemove, onRemove,
error, error,
disabled,
disabledHint,
}: AttachmentPreviewListProps): React.JSX.Element | null => { }: AttachmentPreviewListProps): React.JSX.Element | null => {
if (attachments.length === 0 && !error) return null; if (attachments.length === 0 && !error) return null;
@ -22,10 +28,24 @@ export const AttachmentPreviewList = ({
{attachments.length > 0 ? ( {attachments.length > 0 ? (
<div className="flex gap-2 overflow-x-auto py-1"> <div className="flex gap-2 overflow-x-auto py-1">
{attachments.map((att) => ( {attachments.map((att) => (
<AttachmentPreviewItem key={att.id} attachment={att} onRemove={onRemove} /> <AttachmentPreviewItem
key={att.id}
attachment={att}
onRemove={onRemove}
disabled={disabled}
/>
))} ))}
</div> </div>
) : null} ) : 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 ? ( {error ? (
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5"> <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" /> <AlertCircle size={13} className="shrink-0 text-red-400" />

View file

@ -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; src: string;
alt?: string; alt?: string;
title?: string;
}
interface ImageLightboxProps {
open: boolean; open: boolean;
onClose: () => void; 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 = ({ export const ImageLightbox = ({
src,
alt = 'Image',
open, open,
onClose, onClose,
slides: slidesProp,
index = 0,
src,
alt,
enableZoom = true,
enableFullscreen = true,
showCounter,
}: ImageLightboxProps): React.JSX.Element | null => { }: ImageLightboxProps): React.JSX.Element | null => {
const handleKeyDown = useCallback( const slides = useMemo<Slide[]>(() => {
(e: KeyboardEvent) => { if (slidesProp && slidesProp.length > 0) {
if (e.key === 'Escape') onClose(); return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title }));
}, }
[onClose] if (src) {
); return [{ src, alt }];
}
return [];
}, [slidesProp, src, alt]);
useEffect(() => { const plugins = useMemo<Plugin[]>(() => {
if (!open) return; const list: Plugin[] = [];
document.addEventListener('keydown', handleKeyDown); if (enableZoom) list.push(Zoom);
return () => document.removeEventListener('keydown', handleKeyDown); if (enableFullscreen) list.push(Fullscreen);
}, [open, handleKeyDown]); // 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 ( return (
<div <Lightbox
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in" open={open}
role="dialog" close={onClose}
aria-modal="true" slides={slides}
aria-label={alt} index={index}
> plugins={plugins}
<button carousel={{ finite: slides.length <= 1 }}
type="button" animation={{ fade: 200 }}
className="absolute inset-0 border-0 bg-transparent p-0" zoom={{
onClick={onClose} maxZoomPixelRatio: 5,
aria-label="Close" scrollToZoom: true,
/> }}
<button styles={{
type="button" container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
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>
); );
}; };

View file

@ -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 { Button } from '@renderer/components/ui/button';
import { import {
Dialog, Dialog,
@ -11,16 +12,15 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
Select, import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
SelectContent, import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
SelectItem, import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { Loader2 } from 'lucide-react'; 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-]*$/; const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
interface AddMemberDialogProps { interface AddMemberDialogProps {
@ -28,8 +28,12 @@ interface AddMemberDialogProps {
teamName: string; teamName: string;
existingNames: string[]; existingNames: string[];
onClose: () => void; onClose: () => void;
onAdd: (name: string, role?: string) => void; onAdd: (name: string, role?: string, workflow?: string) => void;
adding?: boolean; adding?: boolean;
/** Project path for @file mentions in workflow field. */
projectPath?: string | null;
/** Existing team members for @mention suggestions. */
existingMembers?: ResolvedTeamMember[];
} }
export const AddMemberDialog = ({ export const AddMemberDialog = ({
@ -39,12 +43,36 @@ export const AddMemberDialog = ({
onClose, onClose,
onAdd, onAdd,
adding, adding,
projectPath,
existingMembers = [],
}: AddMemberDialogProps): React.JSX.Element => { }: AddMemberDialogProps): React.JSX.Element => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE); const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
const [customRole, setCustomRole] = useState(''); const [customRole, setCustomRole] = useState('');
const [error, setError] = useState<string | null>(null); 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 = const effectiveRole =
roleSelect === CUSTOM_ROLE roleSelect === CUSTOM_ROLE
? customRole.trim() ? customRole.trim()
@ -72,7 +100,9 @@ export const AddMemberDialog = ({
return; return;
} }
setError(null); 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 => { const handleOpenChange = (nextOpen: boolean): void => {
@ -80,11 +110,20 @@ export const AddMemberDialog = ({
setName(''); setName('');
setRoleSelect(NO_ROLE); setRoleSelect(NO_ROLE);
setCustomRole(''); setCustomRole('');
workflowDraft.setValue('');
workflowDraft.clearDraft();
setError(null); setError(null);
onClose(); onClose();
} }
}; };
const handleWorkflowChange = useCallback(
(v: string) => {
workflowDraft.setValue(v);
},
[workflowDraft]
);
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -113,27 +152,31 @@ export const AddMemberDialog = ({
<div className="space-y-2"> <div className="space-y-2">
<Label className="label-optional">Role (optional)</Label> <Label className="label-optional">Role (optional)</Label>
<Select value={roleSelect} onValueChange={setRoleSelect}> <RoleSelect
<SelectTrigger> value={roleSelect}
<SelectValue placeholder="No role" /> onValueChange={setRoleSelect}
</SelectTrigger> customRole={customRole}
<SelectContent> onCustomRoleChange={setCustomRole}
<SelectItem value={NO_ROLE}>No role</SelectItem> />
{PRESET_ROLES.map((role) => ( </div>
<SelectItem key={role} value={role}>
{role} <div className="space-y-2">
</SelectItem> <Label className="label-optional">Workflow (optional)</Label>
))} <MentionableTextarea
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem> className="text-xs"
</SelectContent> minRows={3}
</Select> maxRows={8}
{roleSelect === CUSTOM_ROLE && ( value={workflowDraft.value}
<Input onValueChange={handleWorkflowChange}
placeholder="Custom role" suggestions={mentionSuggestions}
value={customRole} projectPath={projectPath ?? undefined}
onChange={(e) => setCustomRole(e.target.value)} 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>
</div> </div>

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -89,28 +89,45 @@ export const CreateTaskDialog = ({
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState(''); const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState(''); const [relatedSearch, setRelatedSearch] = useState('');
const [prevOpen, setPrevOpen] = useState(false); const prevOpenRef = useRef(false);
if (open && !prevOpen) { // Reset form when dialog opens (avoid setState during render)
setSubject(defaultSubject); useEffect(() => {
if (defaultChip) { if (open && !prevOpenRef.current) {
const token = chipToken(defaultChip); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
descriptionDraft.setValue(token + '\n'); setSubject(defaultSubject);
descChipDraft.setChips([defaultChip]); if (defaultChip) {
} else if (defaultDescription) { const token = chipToken(defaultChip);
descriptionDraft.setValue(defaultDescription); 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); prevOpenRef.current = open;
setBlockedBy([]); }, [
setRelated([]); open,
setStartImmediately(defaultStartImmediately ?? isTeamAlive); defaultSubject,
promptDraft.clearDraft(); defaultDescription,
setBlockedBySearch(''); defaultOwner,
setRelatedSearch(''); defaultStartImmediately,
} defaultChip,
if (open !== prevOpen) { isTeamAlive,
setPrevOpen(open); descriptionDraft,
} descChipDraft,
promptDraft,
]);
const mentionSuggestions = useMemo<MentionSuggestion[]>( const mentionSuggestions = useMemo<MentionSuggestion[]>(
() => () =>
@ -229,9 +246,16 @@ export const CreateTaskDialog = ({
</DialogHeader> </DialogHeader>
{!isTeamAlive ? ( {!isTeamAlive ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"> <div
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" /> className="flex items-start gap-2 rounded-md border px-3 py-2"
<p className="text-xs leading-relaxed text-amber-300"> 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> &mdash; launch the Team is offline. The task will be added to <strong>TODO</strong> &mdash; launch the
team to start execution. team to start execution.
</p> </p>

View file

@ -491,10 +491,11 @@ export const CreateTeamDialog = ({
activeError?.includes('Team already exists') === true && request.teamName.length > 0; activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const conflictingTeam = useMemo(() => { const conflictingTeam = useMemo(() => {
if (!launchTeam) return null;
if (!activeTeams?.length || !effectiveCwd) return null; if (!activeTeams?.length || !effectiveCwd) return null;
const norm = normalizePath(effectiveCwd); const norm = normalizePath(effectiveCwd);
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
}, [activeTeams, effectiveCwd]); }, [activeTeams, effectiveCwd, launchTeam]);
// Reset dismiss when conflict target changes // Reset dismiss when conflict target changes
useEffect(() => { 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 ( return (
<Dialog <Dialog
open={open} open={open}
@ -575,22 +589,32 @@ export const CreateTeamDialog = ({
</DialogHeader> </DialogHeader>
{conflictingTeam && !conflictDismissed ? ( {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"> <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"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
project this working directory
</p> </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 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div> </div>
<button <button
type="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)} onClick={() => setConflictDismissed(true)}
> >
<X className="size-3.5" /> <X className="size-3.5" />
@ -613,7 +637,11 @@ export const CreateTeamDialog = ({
{prepareWarnings.length > 0 ? ( {prepareWarnings.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300"> <p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning} {warning}
</p> </p>
))} ))}
@ -629,7 +657,14 @@ export const CreateTeamDialog = ({
) : null} ) : null}
{!canCreate ? ( {!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. Available only in local Electron mode.
</p> </p>
) : null} ) : null}
@ -641,7 +676,7 @@ export const CreateTeamDialog = ({
id="team-name" id="team-name"
className="h-8 text-xs" className="h-8 text-xs"
value={teamName} value={teamName}
onChange={(event) => setTeamName(event.target.value)} onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder="team-alpha" placeholder="team-alpha"
/> />
{existingTeamNames.includes(sanitizedTeamName) ? ( {existingTeamNames.includes(sanitizedTeamName) ? (

View file

@ -36,10 +36,17 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
</Label> </Label>
</div> </div>
{checked && ( {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"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1 text-amber-300/90"> <div className="space-y-1">
<p> <p>
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
subscribers, extra usage is billed separately. subscribers, extra usage is billed separately.
@ -48,7 +55,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
Requires API tier 4+ or extra usage enabled.{' '} Requires API tier 4+ or extra usage enabled.{' '}
<button <button
type="button" type="button"
className="underline underline-offset-2 hover:text-amber-200" className="underline underline-offset-2 hover:opacity-80"
onClick={() => onClick={() =>
window.electronAPI.openExternal( window.electronAPI.openExternal(
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window' 'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'

View file

@ -314,22 +314,32 @@ export const LaunchTeamDialog = ({
</DialogHeader> </DialogHeader>
{conflictingTeam && !conflictDismissed ? ( {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"> <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"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
project this working directory
</p> </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 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span>
</p>
</div> </div>
<button <button
type="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)} onClick={() => setConflictDismissed(true)}
> >
<X className="size-3.5" /> <X className="size-3.5" />
@ -352,7 +362,11 @@ export const LaunchTeamDialog = ({
{prepareWarnings.length > 0 ? ( {prepareWarnings.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300"> <p
key={warning}
className="text-[11px]"
style={{ color: 'var(--warning-text)' }}
>
{warning} {warning}
</p> </p>
))} ))}
@ -396,7 +410,7 @@ export const LaunchTeamDialog = ({
chips={chipDraft.chips} chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip} onChipRemove={chipDraft.removeChip}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}
placeholder="Instructions for team lead... Use @ to mention team members." placeholder="Instructions for team lead..."
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
@ -435,10 +449,17 @@ export const LaunchTeamDialog = ({
</Label> </Label>
</div> </div>
{clearContext && ( {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"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p className="text-amber-300/90"> <p>
The team lead will start a new session without resuming previous context. All The team lead will start a new session without resuming previous context. All
accumulated session memory and conversation history will not be available. accumulated session memory and conversation history will not be available.
</p> </p>

View file

@ -137,7 +137,7 @@ export const ProjectPathSelector = ({
) : null} ) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null} {projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projects.length === 0 ? ( {!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} ) : null}
</div> </div>
) : ( ) : (
@ -155,9 +155,13 @@ export const ProjectPathSelector = ({
size="sm" size="sm"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
const paths = await api.config.selectFolders(); try {
if (paths.length > 0) { const paths = await api.config.selectFolders();
onCustomCwdChange(paths[0]); if (paths.length > 0) {
onCustomCwdChange(paths[0]);
}
} catch {
// IPC error — dialog may have been cancelled or failed
} }
})(); })();
}} }}

View file

@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
Dialog, Dialog,
@ -14,16 +14,9 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments'; import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
@ -32,8 +25,9 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, X } from 'lucide-react'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge'; import { MemberBadge } from '../MemberBadge';
@ -46,6 +40,8 @@ interface QuotedMessage {
text: string; text: string;
} }
const MAX_MESSAGE_LENGTH = 4000;
interface SendMessageDialogProps { interface SendMessageDialogProps {
open: boolean; open: boolean;
teamName: string; teamName: string;
@ -69,8 +65,6 @@ interface SendMessageDialogProps {
onClose: () => void; onClose: () => void;
} }
const NO_MEMBER = '__none__';
export const SendMessageDialog = ({ export const SendMessageDialog = ({
open, open,
teamName, teamName,
@ -91,11 +85,11 @@ export const SendMessageDialog = ({
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined); const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [quoteExpanded, setQuoteExpanded] = useState(false); const [quoteExpanded, setQuoteExpanded] = useState(false);
const [member, setMember] = useState(''); const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` });
const chipDraft = useChipDraftPersistence('sendMessage:chips'); const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`);
const [summary, setSummary] = useState(''); const [summary, setSummary] = useState('');
const [prevOpen, setPrevOpen] = useState(false); const prevOpenRef = useRef(false);
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null); const prevResultRef = useRef<SendMessageResult | null>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
@ -114,35 +108,48 @@ export const SendMessageDialog = ({
const selectedMember = members.find((m) => m.name === member); const selectedMember = members.find((m) => m.name === member);
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; 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); const [pendingAutoClose, setPendingAutoClose] = useState(false);
if (open && lastResult && lastResult !== prevResult) { // Reset form on open transition (avoid setState in render)
setPrevResult(lastResult); useEffect(() => {
setMember(''); if (open && !prevOpenRef.current) {
setSummary(''); setMember(defaultRecipient ?? '');
setPendingAutoClose(true); 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 // Side effects (onClose mutates parent state) must run in useEffect, not render phase
useEffect(() => { useEffect(() => {
@ -170,11 +177,20 @@ export const SendMessageDialog = ({
[members, colorMap] [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 = const canSend =
member.trim().length > 0 && member.trim().length > 0 &&
textDraft.value.trim().length > 0 && finalText.length > 0 &&
finalText.length <= MAX_MESSAGE_LENGTH &&
summary.trim().length > 0 && summary.trim().length > 0 &&
!sending; !sending &&
!attachmentsBlocked;
const handleChipRemove = (chipId: string): void => { const handleChipRemove = (chipId: string): void => {
const chip = chipDraft.chips.find((c) => c.id === chipId); const chip = chipDraft.chips.find((c) => c.id === chipId);
@ -186,8 +202,6 @@ export const SendMessageDialog = ({
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (!canSend) return; if (!canSend) return;
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
onSend( onSend(
member.trim(), member.trim(),
finalText, finalText,
@ -254,7 +268,7 @@ export const SendMessageDialog = ({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent <DialogContent
className="sm:max-w-[560px]" className="sm:max-w-[720px]"
onDragEnter={canAttach ? handleDragEnter : undefined} onDragEnter={canAttach ? handleDragEnter : undefined}
onDragLeave={canAttach ? handleDragLeave : undefined} onDragLeave={canAttach ? handleDragLeave : undefined}
onDragOver={canAttach ? handleDragOver : undefined} onDragOver={canAttach ? handleDragOver : undefined}
@ -271,40 +285,13 @@ export const SendMessageDialog = ({
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="smd-recipient">Recipient</Label> <Label htmlFor="smd-recipient">Recipient</Label>
<Select <MemberSelect
value={member || NO_MEMBER} members={members}
onValueChange={(v) => setMember(v === NO_MEMBER ? '' : v)} value={member || null}
> onChange={(v) => setMember(v ?? '')}
<SelectTrigger id="smd-recipient"> placeholder="Select member..."
<SelectValue placeholder="Select member..." /> size="sm"
</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>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@ -351,6 +338,8 @@ export const SendMessageDialog = ({
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError} 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'}> <div className={quote ? 'flex flex-col' : 'contents'}>
@ -401,7 +390,7 @@ export const SendMessageDialog = ({
<MentionableTextarea <MentionableTextarea
id="smd-message" id="smd-message"
className={quote ? 'rounded-t-none' : undefined} className={quote ? 'rounded-t-none' : undefined}
placeholder="Write your message..." placeholder="Write your message... (Enter to send)"
value={textDraft.value} value={textDraft.value}
onValueChange={textDraft.setValue} onValueChange={textDraft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
@ -409,12 +398,43 @@ export const SendMessageDialog = ({
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath} projectPath={projectPath}
onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])}
onModEnter={handleSubmit}
minRows={4} minRows={4}
maxRows={12} 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={ footerRight={
textDraft.isSaved ? ( <div className="flex items-center gap-2">
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> {sendError ? (
) : null <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> </div>
@ -433,17 +453,12 @@ export const SendMessageDialog = ({
Shown as notification preview. Team lead also sees this for peer messages. Shown as notification preview. Team lead also sees this for peer messages.
</p> </p>
</div> </div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}> <Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
Cancel Cancel
</Button> </Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSend}>
{sending ? 'Sending...' : 'Send'}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -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 { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store'; 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']); 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 [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
id: string; const [thumbCache, setThumbCache] = useState<Map<string, string>>(new Map());
mimeType: AttachmentMediaType;
dataUrl: string | null;
loading: boolean;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); 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( const handleFileSelect = useCallback(
async (files: FileList | null) => { async (files: FileList | null) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -73,48 +82,74 @@ export const TaskAttachments = ({
); );
const handleDelete = useCallback( const handleDelete = useCallback(
async (attachmentId: string, mimeType: AttachmentMediaType) => { async (attachmentId: string, mimeType: string) => {
setDeletingId(attachmentId); setDeletingId(attachmentId);
try { try {
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
if (previewAttachment?.id === attachmentId) {
setPreviewAttachment(null);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete'); setError(err instanceof Error ? err.message : 'Failed to delete');
} finally { } finally {
setDeletingId(null); 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( const handlePreview = useCallback(
async (att: TaskAttachmentMeta) => { (att: TaskAttachmentMeta) => {
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { if (!isImageMimeType(att.mimeType)) {
setPreviewAttachment(null); void handleDownload(att);
return; return;
} }
setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true }); const idx = imageAttachments.findIndex((a) => a.id === att.id);
try { if (idx >= 0) {
const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); setLightboxIndex(idx);
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');
} }
}, },
[teamName, taskId, getTaskAttachmentData, previewAttachment] [imageAttachments, handleDownload]
); );
// Handle paste events for quick image attachment // Handle paste events for quick image attachment
@ -179,38 +214,31 @@ export const TaskAttachments = ({
teamName={teamName} teamName={teamName}
taskId={taskId} taskId={taskId}
isDeleting={deletingId === att.id} isDeleting={deletingId === att.id}
isPreviewActive={previewAttachment?.id === att.id} onPreview={() => {
onPreview={() => void handlePreview(att)} // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise
onDelete={() => void handleDelete(att.id, att.mimeType)} 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> </div>
) : null} ) : null}
{/* Preview panel */} {/* Image lightbox */}
{previewAttachment ? ( {lightboxIndex !== null && (
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <ImageLightbox
<button open
type="button" onClose={() => {
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)]" setLightboxIndex(null);
onClick={() => setPreviewAttachment(null)} }}
> slides={lightboxSlides}
<X size={14} /> index={lightboxIndex}
</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}
{/* Drop zone indicator */} {/* Drop zone indicator */}
{dragOver ? ( {dragOver ? (
@ -256,9 +284,9 @@ interface AttachmentThumbnailProps {
teamName: string; teamName: string;
taskId: string; taskId: string;
isDeleting: boolean; isDeleting: boolean;
isPreviewActive: boolean;
onPreview: () => void; onPreview: () => void;
onDelete: () => void; onDelete: () => void;
onDataLoaded?: (attachmentId: string, dataUrl: string) => void;
} }
const AttachmentThumbnail = ({ const AttachmentThumbnail = ({
@ -266,9 +294,9 @@ const AttachmentThumbnail = ({
teamName, teamName,
taskId, taskId,
isDeleting, isDeleting,
isPreviewActive,
onPreview, onPreview,
onDelete, onDelete,
onDataLoaded,
}: AttachmentThumbnailProps): React.JSX.Element => { }: AttachmentThumbnailProps): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null); const [thumbUrl, setThumbUrl] = useState<string | null>(null);
@ -277,6 +305,7 @@ const AttachmentThumbnail = ({
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
if (!isImageMimeType(attachment.mimeType)) return;
const base64 = await getTaskAttachmentData( const base64 = await getTaskAttachmentData(
teamName, teamName,
taskId, taskId,
@ -284,7 +313,9 @@ const AttachmentThumbnail = ({
attachment.mimeType attachment.mimeType
); );
if (!cancelled && base64) { if (!cancelled && base64) {
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); const dataUrl = `data:${attachment.mimeType};base64,${base64}`;
setThumbUrl(dataUrl);
onDataLoaded?.(attachment.id, dataUrl);
} }
} catch { } catch {
// ignore // ignore
@ -293,7 +324,7 @@ const AttachmentThumbnail = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]);
const sizeLabel = const sizeLabel =
attachment.size < 1024 attachment.size < 1024
@ -304,17 +335,22 @@ const AttachmentThumbnail = ({
return ( return (
<div <div
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${ 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)]`}
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)]`}
onClick={onPreview} onClick={onPreview}
> >
{thumbUrl ? ( {isImageMimeType(attachment.mimeType) ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" /> 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 */} {/* Delete button overlay */}
<button <button
@ -353,7 +389,7 @@ function fileToBase64(file: File): Promise<string> {
reject(new Error('Failed to read file as base64')); reject(new Error('Failed to read file as base64'));
} }
}; };
reader.onerror = () => reject(reader.error); reader.onerror = () => reject(reader.error ?? new Error('File read failed'));
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }

View file

@ -7,9 +7,8 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
@ -71,51 +70,45 @@ export const TaskCommentInput = ({
trimmed.length <= MAX_COMMENT_LENGTH && trimmed.length <= MAX_COMMENT_LENGTH &&
!addingComment; !addingComment;
const addFiles = useCallback( const addFiles = useCallback((files: FileList | File[]) => {
(files: FileList | File[]) => { setAttachError(null);
setAttachError(null); const fileArray = Array.from(files);
const fileArray = Array.from(files); for (const file of fileArray) {
for (const file of fileArray) { if (!ACCEPTED_TYPES.has(file.type)) {
if (!ACCEPTED_TYPES.has(file.type)) { setAttachError(`Unsupported type: ${file.type}`);
setAttachError(`Unsupported type: ${file.type}`); continue;
continue;
}
if (file.size > MAX_FILE_SIZE) {
setAttachError(
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
);
continue;
}
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);
} }
}, if (file.size > MAX_FILE_SIZE) {
[pendingAttachments.length] setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
); continue;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
if (!base64) return;
const id = crypto.randomUUID();
setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
}
return [
...prev,
{
id,
filename: file.name,
mimeType: file.type,
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
};
reader.readAsDataURL(file);
}
}, []);
const removeAttachment = useCallback((id: string) => { const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@ -132,7 +125,7 @@ export const TaskCommentInput = ({
? pendingAttachments.map((a) => ({ ? pendingAttachments.map((a) => ({
id: a.id, id: a.id,
filename: a.filename, filename: a.filename,
mimeType: a.mimeType as CommentAttachmentPayload['mimeType'], mimeType: a.mimeType,
base64Data: a.base64Data, base64Data: a.base64Data,
})) }))
: undefined; : undefined;
@ -246,16 +239,18 @@ export const TaskCommentInput = ({
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
if (e.target.files) addFiles(e.target.files); if (e.target.files) addFiles(e.target.files);
// eslint-disable-next-line no-param-reassign -- reset file input to allow re-selecting same file
e.target.value = ''; e.target.value = '';
}} }}
/> />
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`} placeholder="Add a comment... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
projectPath={projectPath} projectPath={projectPath}
onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
@ -275,6 +270,18 @@ export const TaskCommentInput = ({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Attach image (or paste)</TooltipContent> <TooltipContent side="top">Attach image (or paste)</TooltipContent>
</Tooltip> </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 <button
type="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" 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"

View file

@ -2,37 +2,25 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
Loader2,
MessageSquare,
Reply,
Send,
X,
} from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
AttachmentMediaType,
ResolvedTeamMember,
TaskAttachmentMeta,
TaskComment,
} from '@shared/types';
/** /**
* Convert literal backslash-n sequences to real newlines. * Convert literal backslash-n sequences to real newlines.
@ -61,6 +49,8 @@ interface TaskCommentsSectionProps {
onReply?: (author: string, text: string) => void; onReply?: (author: string, text: string) => void;
/** Called when a task ID link (e.g. #10) is clicked in comment text. */ /** Called when a task ID link (e.g. #10) is clicked in comment text. */
onTaskIdClick?: (taskId: string) => void; 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. */ /** 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 names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi'); 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 canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? ''; const color = memberColorMap.get(canonical) ?? '';
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
@ -90,35 +80,23 @@ export const TaskCommentsSection = ({
hideInput = false, hideInput = false,
onReply, onReply,
onTaskIdClick, onTaskIdClick,
containerClassName,
}: TaskCommentsSectionProps): React.JSX.Element => { }: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment); const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment); const addingComment = useStore((s) => s.addingComment);
const commentsRef = useMarkCommentsRead(teamName, taskId, comments); const commentsRef = useMarkCommentsRead(teamName, taskId, comments);
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); 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 [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null); const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
// Reset local state when team/task changes (React-recommended pattern for // Reset local UI state when team/task changes.
// adjusting state based on props without using effects or refs during render) useEffect(() => {
const currentKey = teamIdKey(teamName, taskId); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
const [prevKey, setPrevKey] = useState(currentKey);
if (prevKey !== currentKey) {
setPrevKey(currentKey);
setVisibleCount(INITIAL_VISIBLE_COMMENTS); setVisibleCount(INITIAL_VISIBLE_COMMENTS);
setExpandedCommentIds(new Set());
setReplyTo(null); setReplyTo(null);
} setPreviewImageUrl(null);
}, [teamName, taskId]);
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;
});
}, []);
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -183,91 +161,85 @@ export const TaskCommentsSection = ({
) : null} ) : null}
{comments.length > 0 ? ( {comments.length > 0 ? (
<div className="mb-3 space-y-2"> <div className="mb-3">
{comments.length > MAX_COMMENTS_TO_RENDER ? ( {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 Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
UI responsive. UI responsive.
</div> </div>
) : null} ) : null}
{visibleComments.map((comment, index) => ( <div className={containerClassName ?? ''}>
<div {visibleComments.map((comment, index) => (
key={comment.id} <div
className={[ key={comment.id}
'group rounded-md p-2.5', className={[
comment.type === 'review_approved' 'group px-4 py-2.5',
? 'border border-emerald-500/20 bg-emerald-500/5' comment.type === 'review_approved'
: comment.type === 'review_request' ? 'border-y border-emerald-500/20 bg-emerald-500/5'
? 'border border-blue-500/20 bg-blue-500/5' : comment.type === 'review_request'
: '', ? 'border-y border-blue-500/20 bg-blue-500/5'
].join(' ')} : '',
style={ ].join(' ')}
!comment.type && index % 2 === 1 style={
? { backgroundColor: 'var(--card-bg-zebra)' } !comment.type || comment.type === 'regular'
: undefined ? {
} backgroundColor:
> index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<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
} }
> : 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 ? ( {reply ? (
<ReplyQuoteBlock <ReplyQuoteBlock
reply={{ reply={{
@ -275,9 +247,8 @@ export const TaskCommentsSection = ({
originalText: stripAgentBlocks(reply.originalText), originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText), replyText: stripAgentBlocks(reply.replyText),
}} }}
bodyMaxHeight={ memberColor={colorMap.get(reply.agentName)}
needsExpandCollapse && !expanded ? 'max-h-56' : 'max-h-none' bodyMaxHeight="max-h-none"
}
/> />
) : ( ) : (
<span <span
@ -299,68 +270,29 @@ export const TaskCommentsSection = ({
> >
<MarkdownViewer <MarkdownViewer
content={(() => { content={(() => {
let t = displayText; let t = linkifyTaskIdsInMarkdown(displayText);
if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t);
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
return t; return t;
})()} })()}
maxHeight={ maxHeight="max-h-none"
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
}
bare bare
/> />
</span> </span>
)} )}
{showCollapsed && ( </ExpandableContent>
<> );
<div })()}
className="pointer-events-none absolute inset-x-0 bottom-0 h-14" {comment.attachments && comment.attachments.length > 0 ? (
style={{ <CommentAttachments
background: attachments={comment.attachments}
'linear-gradient(to top, var(--color-surface) 0%, transparent 100%)', teamName={teamName}
}} taskId={taskId}
aria-hidden onPreview={setPreviewImageUrl}
/> />
<div className="absolute inset-x-0 bottom-0 flex justify-center pt-1"> ) : null}
<button </div>
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)]" </div>
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>
))}
{sortedComments.length > visibleComments.length ? ( {sortedComments.length > visibleComments.length ? (
<div className="flex items-center justify-center pt-2"> <div className="flex items-center justify-center pt-2">
@ -378,22 +310,14 @@ export const TaskCommentsSection = ({
</div> </div>
) : null} ) : null}
{/* Full-size image preview overlay */} {/* Image lightbox */}
{previewImageUrl ? ( {previewImageUrl ? (
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <ImageLightbox
<button open
type="button" onClose={() => setPreviewImageUrl(null)}
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)]" src={previewImageUrl}
onClick={() => setPreviewImageUrl(null)} alt="Attachment preview"
> />
<X size={14} />
</button>
<img
src={previewImageUrl}
alt="Attachment preview"
className="max-h-[400px] max-w-full rounded object-contain"
/>
</div>
) : null} ) : null}
{!hideInput && ( {!hideInput && (
@ -427,10 +351,11 @@ export const TaskCommentsSection = ({
<div className="relative"> <div className="relative">
<MentionableTextarea <MentionableTextarea
id={`task-comment-${taskId}`} id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`} placeholder="Add a comment... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
onModEnter={() => void handleSubmit()}
minRows={2} minRows={2}
maxRows={8} maxRows={8}
maxLength={MAX_COMMENT_LENGTH} maxLength={MAX_COMMENT_LENGTH}
@ -487,11 +412,14 @@ const CommentAttachmentThumbnail = ({
}: CommentAttachmentThumbnailProps): React.JSX.Element => { }: CommentAttachmentThumbnailProps): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null); const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
try { try {
if (!isImageMimeType(attachment.mimeType)) return;
const base64 = await getTaskAttachmentData( const base64 = await getTaskAttachmentData(
teamName, teamName,
taskId, taskId,
@ -511,19 +439,76 @@ const CommentAttachmentThumbnail = ({
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
return ( return (
<div <Tooltip>
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)]" <TooltipTrigger asChild>
onClick={() => thumbUrl && onPreview(thumbUrl)} <div
> className={`group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border bg-[var(--color-surface)] transition-colors ${
{thumbUrl ? ( downloadError
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" /> ? '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"> </Tooltip>
{attachment.filename}
</div>
</div>
); );
}; };
@ -556,7 +541,3 @@ const CommentAttachments = ({
))} ))}
</div> </div>
); );
function teamIdKey(teamName: string, taskId: string): string {
return `${teamName}::${taskId}`;
}

View file

@ -14,20 +14,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { import { MemberSelect } from '@renderer/components/ui/MemberSelect';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea'; import { Textarea } from '@renderer/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage'; import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
buildMemberColorMap, buildMemberColorMap,
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
@ -107,6 +100,7 @@ export const TaskDetailDialog = ({
const updateTaskFields = useStore((s) => s.updateTaskFields); const updateTaskFields = useStore((s) => s.updateTaskFields);
const [logsRefreshing, setLogsRefreshing] = useState(false); const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
// Inline editing: subject // Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false); const [editingSubject, setEditingSubject] = useState(false);
@ -296,6 +290,12 @@ export const TaskDetailDialog = ({
.map((t) => t.id); .map((t) => t.id);
const isTodo = status === 'pending' && !kanbanColumn; const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange; 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <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 flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{canReassign ? ( {canReassign ? (
<Select <MemberSelect
value={currentTask.owner ?? '__unassigned__'} members={members}
onValueChange={(v) => { value={currentTask.owner ?? null}
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v); onChange={(v) => onOwnerChange(currentTask.id, v)}
}} allowUnassigned
> size="sm"
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs"> className="min-w-[160px]"
<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>
) : currentTask.owner ? ( ) : currentTask.owner ? (
<MemberBadge <MemberBadge
name={currentTask.owner} name={currentTask.owner}
@ -392,7 +364,7 @@ export const TaskDetailDialog = ({
size="md" size="md"
/> />
) : ( ) : (
<span className="text-xs text-[var(--color-text-muted)]">&mdash;</span> <span className="text-xs italic text-[var(--color-text-muted)]">Unassigned</span>
)} )}
</div> </div>
{currentTask.createdBy ? ( {currentTask.createdBy ? (
@ -545,7 +517,7 @@ export const TaskDetailDialog = ({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className="group max-h-[200px] cursor-pointer overflow-y-auto" className="group cursor-pointer"
onClick={startEditDescription} onClick={startEditDescription}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { 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 <Pencil
size={12} size={12}
className="mt-1 text-[var(--color-text-muted)] opacity-0 transition-opacity group-hover:opacity-100" 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" title="Execution Logs"
icon={<ScrollText size={14} />} icon={<ScrollText size={14} />}
headerExtra={ headerExtra={
logsRefreshing ? ( logsRefreshing || executionPreviewOnline ? (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]"> <span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<Loader2 size={10} className="animate-spin" /> {executionPreviewOnline ? (
Updating... <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> </span>
) : null ) : null
} }
@ -700,6 +687,13 @@ export const TaskDetailDialog = ({
taskStatus={currentTask.status} taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals} taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing} 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> </div>
</CollapsibleTeamSection> </CollapsibleTeamSection>
@ -858,18 +852,20 @@ export const TaskDetailDialog = ({
? (currentTask.comments?.length ?? 0) ? (currentTask.comments?.length ?? 0)
: undefined : undefined
} }
contentClassName="pl-2.5" contentClassName="overflow-x-visible pl-0"
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen defaultOpen
> >
<TaskCommentInput <div className="pl-2.5">
teamName={teamName} <TaskCommentInput
taskId={currentTask.id} teamName={teamName}
members={members} taskId={currentTask.id}
replyTo={effectiveReplyTo} members={members}
onClearReply={clearReply} replyTo={effectiveReplyTo}
/> onClearReply={clearReply}
/>
</div>
<TaskCommentsSection <TaskCommentsSection
teamName={teamName} teamName={teamName}
taskId={currentTask.id} taskId={currentTask.id}
@ -879,6 +875,7 @@ export const TaskDetailDialog = ({
hideInput hideInput
onReply={handleReply} onReply={handleReply}
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined} onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
containerClassName="-mx-6"
/> />
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -33,15 +33,15 @@ export const EditorImagePreview = ({
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
// Reset state when filePath changes (setState-during-render, React-approved pattern) // Reset state when filePath changes
const [prevFilePath, setPrevFilePath] = useState(filePath); useEffect(() => {
if (prevFilePath !== filePath) { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setPrevFilePath(filePath);
setLoading(true); setLoading(true);
setError(null); setError(null);
setDataUrl(null); setDataUrl(null);
setDimensions(null); setDimensions(null);
} setLightboxOpen(false);
}, [filePath]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -127,10 +127,10 @@ export const EditorImagePreview = ({
</div> </div>
<ImageLightbox <ImageLightbox
src={dataUrl}
alt={fileName}
open={lightboxOpen} open={lightboxOpen}
onClose={() => setLightboxOpen(false)} onClose={() => setLightboxOpen(false)}
src={dataUrl}
alt={fileName}
/> />
</div> </div>
); );

View file

@ -678,7 +678,14 @@ export const ProjectEditorOverlay = ({
{/* Draft recovery banner */} {/* Draft recovery banner */}
{draftRecoveredFile && activeTabId === draftRecoveredFile && ( {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" /> <RotateCcw className="size-3.5 shrink-0" />
<span>Recovered unsaved changes from a previous session.</span> <span>Recovered unsaved changes from a previous session.</span>
<Button <Button

View file

@ -37,18 +37,12 @@ export const QuickOpenDialog = ({
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]); const [allFiles, setAllFiles] = useState<QuickOpenFile[]>([]);
const [loading, setLoading] = useState(true); 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 // Load all project files on mount via backend API
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
setLoading(true);
window.electronAPI.editor window.electronAPI.editor
.listFiles() .listFiles()
.then((files) => { .then((files) => {

View file

@ -6,6 +6,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { import {
CheckCircle2, 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 = ( const boardContent = (
<> <>
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}> <div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
@ -475,7 +487,7 @@ export const KanbanBoard = ({
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <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 columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
return ( return (
@ -493,21 +505,32 @@ export const KanbanBoard = ({
})} })}
</div> </div>
) : ( ) : (
<div className="flex gap-3 overflow-x-auto pb-2"> <div className="flex overflow-x-auto pb-2">
{COLUMNS.map((column) => { {visibleColumns.map((column, index) => {
const columnTasks = groupedOrdered.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
const width = columnWidths.get(column.id) ?? 256;
return ( return (
<div key={column.id} className="w-64 shrink-0"> <div key={column.id} className="flex shrink-0">
<KanbanColumn <div style={{ width }}>
title={column.title} <KanbanColumn
count={columnTasks.length} title={column.title}
icon={accent.icon} count={columnTasks.length}
headerBg={accent.headerBg} icon={accent.icon}
bodyBg={accent.bodyBg} headerBg={accent.headerBg}
> bodyBg={accent.bodyBg}
{renderCards(column.id, columnTasks, true)} >
</KanbanColumn> {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> </div>
); );
})} })}

View file

@ -7,15 +7,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { Crown, Filter } from 'lucide-react'; import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data'; 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 const UNASSIGNED_OWNER = '__unassigned__';
export interface KanbanFilterState { export interface KanbanFilterState {
sessionId: string | null; sessionId: string | null;
selectedOwners: Set<string>; 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 { interface KanbanFilterPopoverProps {
filter: KanbanFilterState; filter: KanbanFilterState;
sessions: Session[]; sessions: Session[];
@ -35,8 +46,9 @@ export const KanbanFilterPopover = ({
let count = 0; let count = 0;
if (filter.sessionId !== null) count += 1; if (filter.sessionId !== null) count += 1;
if (filter.selectedOwners.size > 0) count += 1; if (filter.selectedOwners.size > 0) count += 1;
if (filter.columns.size > 0) count += 1;
return count; return count;
}, [filter.sessionId, filter.selectedOwners]); }, [filter.sessionId, filter.selectedOwners, filter.columns]);
const handleSessionSelect = (sessionId: string | null): void => { const handleSessionSelect = (sessionId: string | null): void => {
onFilterChange({ ...filter, sessionId }); onFilterChange({ ...filter, sessionId });
@ -52,8 +64,18 @@ export const KanbanFilterPopover = ({
onFilterChange({ ...filter, selectedOwners: next }); 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 => { const handleClearAll = (): void => {
onFilterChange({ sessionId: null, selectedOwners: new Set() }); onFilterChange({ sessionId: null, selectedOwners: new Set(), columns: new Set() });
}; };
return ( return (
@ -148,6 +170,28 @@ export const KanbanFilterPopover = ({
</div> </div>
</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 */} {/* Footer */}
<div className="flex justify-end p-2"> <div className="flex justify-end p-2">
<Button <Button

View file

@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -207,6 +206,12 @@ export const KanbanTaskCard = ({
}, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]); }, [showChangesColumn, task.status, task.id, teamName, taskHasChanges, checkTaskHasChanges]);
const isReviewManual = columnId === 'review' && !hasReviewers; const isReviewManual = columnId === 'review' && !hasReviewers;
const multiButton =
compact ||
columnId === 'todo' ||
columnId === 'in_progress' ||
columnId === 'done' ||
columnId === 'review';
const metaActions = ( const metaActions = (
<> <>
@ -243,7 +248,7 @@ export const KanbanTaskCard = ({
return ( return (
<div <div
data-task-id={task.id} 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 hasBlockedBy
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]' ? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
: 'border-[var(--color-border)] 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"> <div className="flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal"> {task.owner ? (
#{task.id} <MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
</Badge> ) : null}
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />} {!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div> </div>
{task.needsClarification ? ( {task.needsClarification ? (
@ -315,7 +322,7 @@ export const KanbanTaskCard = ({
</div> </div>
) : null} ) : 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"> <div className="flex flex-1 flex-wrap gap-2">
{columnId === 'todo' ? ( {columnId === 'todo' ? (
<> <>
@ -448,7 +455,11 @@ export const KanbanTaskCard = ({
) : null} ) : null}
</div> </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>
</div> </div>
); );

View file

@ -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-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)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]"> <td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? ''} {task.owner ?? 'Unassigned'}
</td> </td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]"> <td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt {task.deletedAt

View file

@ -1,7 +1,6 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
@ -40,18 +39,13 @@ export const MemberCard = ({
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
}: MemberCardProps): React.JSX.Element => { }: MemberCardProps): React.JSX.Element => {
const teamName = useStore((s) => s.selectedTeamName); // NOTE: lead context display disabled — usage formula is inaccurate
const leadContext = useStore((s) => // const teamName = useStore((s) => s.selectedTeamName);
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // const leadContext = useStore((s) =>
); // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const presenceLabel = getPresenceLabel( const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const colors = getTeamColorSet(memberColor); const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0; const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0; const inProgress = taskCounts?.inProgress ?? 0;
@ -151,19 +145,26 @@ export const MemberCard = ({
</span> </span>
) : null; ) : null;
})()} })()}
<Badge {presenceLabel === 'connecting' && !isRemoved ? (
variant="secondary" <Loader2
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)]'}`} className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
title={ aria-label="connecting"
isRemoved />
? 'This member has been removed' ) : (
: member.currentTaskId <Badge
? `Current task: ${member.currentTaskId}` variant="secondary"
: undefined 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
{isRemoved ? 'removed' : presenceLabel} ? 'This member has been removed'
</Badge> : member.currentTaskId
? `Current task: ${member.currentTaskId}`
: undefined
}
>
{isRemoved ? 'removed' : presenceLabel}
</Badge>
)}
<div <div
className="shrink-0" className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined} title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
@ -182,29 +183,7 @@ export const MemberCard = ({
/> />
</div> </div>
)} )}
{leadContext && leadContext.percent > 0 && ( {/* NOTE: lead context bar disabled — usage formula is inaccurate */}
<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>
)}
</div> </div>
{!isRemoved && ( {!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 items-center gap-0.5">

View file

@ -3,7 +3,6 @@ import { useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
@ -31,20 +30,15 @@ export const MemberDetailHeader = ({
}: MemberDetailHeaderProps): React.JSX.Element => { }: MemberDetailHeaderProps): React.JSX.Element => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const teamName = useStore((s) => s.selectedTeamName); // NOTE: lead context display disabled — usage formula is inaccurate
const leadContext = useStore((s) => // const teamName = useStore((s) => s.selectedTeamName);
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // const leadContext = useStore((s) =>
); // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
// );
const colors = getTeamColorSet(member.color ?? ''); const colors = getTeamColorSet(member.color ?? '');
const role = member.role || formatAgentRole(member.agentType); const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel( const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
member,
isTeamAlive,
isTeamProvisioning,
leadActivity,
leadContext?.percent
);
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole = const canEditRole =
@ -107,12 +101,7 @@ export const MemberDetailHeader = ({
> >
{presenceLabel} {presenceLabel}
</Badge> </Badge>
{leadContext && leadContext.percent > 0 && ( {/* NOTE: lead context token display disabled — usage formula is inaccurate */}
<span className="text-[10px] text-[var(--color-text-muted)]">
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
{(leadContext.contextWindow / 1000).toFixed(0)}k
</span>
)}
</> </>
)} )}
</div> </div>

View file

@ -1,17 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; 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} {nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
</div> </div>
<div className="space-y-1"> <div>
<Select <RoleSelect
value={member.roleSelection || NO_ROLE} value={member.roleSelection || '__none__'}
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)} onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
> customRole={member.customRole}
<SelectTrigger className="h-8 text-xs" aria-label={`Member ${index + 1} role`}> onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
<SelectValue placeholder="No role" /> triggerClassName="h-8 text-xs"
</SelectTrigger> inputClassName="h-8 text-xs"
<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}
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
{showWorkflow && onWorkflowChange ? ( {showWorkflow && onWorkflowChange ? (
@ -216,7 +191,7 @@ export const MemberDraftRow = ({
onChipRemove={handleChipRemove} onChipRemove={handleChipRemove}
projectPath={projectPath ?? undefined} projectPath={projectPath ?? undefined}
onFileChipInsert={handleFileChipInsert} 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={ footerRight={
workflowDraft.isSaved ? ( workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>

View file

@ -214,6 +214,7 @@ const AIExecutionGroup = ({
<div className="py-1 pl-2"> <div className="py-1 pl-2">
<DisplayItemList <DisplayItemList
items={enhanced.displayItems} items={enhanced.displayItems}
order="newest-first"
onItemClick={onToggleItem} onItemClick={onToggleItem}
expandedItemIds={expandedItemIds} expandedItemIds={expandedItemIds}
aiGroupId={group.id} aiGroupId={group.id}

View file

@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters'; import { formatDuration } from '@renderer/utils/formatters';
import { import {
@ -14,6 +18,9 @@ import {
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import type { EnhancedChunk } from '@renderer/types/data'; import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types'; import type { MemberLogSummary } from '@shared/types';
@ -28,6 +35,15 @@ interface MemberLogsTabProps {
taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
/** Notifies parent when a background refresh starts/ends. */ /** Notifies parent when a background refresh starts/ends. */
onRefreshingChange?: (isRefreshing: boolean) => void; 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 = ({ export const MemberLogsTab = ({
@ -38,6 +54,9 @@ export const MemberLogsTab = ({
taskStatus, taskStatus,
taskWorkIntervals, taskWorkIntervals,
onRefreshingChange, onRefreshingChange,
showSubagentPreview = false,
showLeadPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
const intervalsKey = useMemo( const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
@ -52,12 +71,104 @@ export const MemberLogsTab = ({
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null); const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false); 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(() => { useEffect(() => {
onRefreshingChange?.(refreshing); onRefreshingChange?.(refreshing);
return () => onRefreshingChange?.(false); return () => onRefreshingChange?.(false);
}, [refreshing, onRefreshingChange]); }, [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
@ -84,7 +195,7 @@ export const MemberLogsTab = ({
}) })
: await api.teams.getMemberLogs(teamName, memberName!); : await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) { if (!cancelled) {
setLogs(result); setLogs(Array.isArray(result) ? [...result] : []);
hasLoadedRef.current = true; hasLoadedRef.current = true;
} }
} catch (e) { } 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 // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
const fetchDetailForLog = useCallback(
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
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( const handleExpand = useCallback(
async (log: MemberLogSummary) => { async (log: MemberLogSummary) => {
const rowId = const rowId = getRowId(log);
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
if (expandedId === rowId) { if (expandedId === rowId) {
setExpandedId(null); setExpandedId(null);
@ -126,20 +321,15 @@ export const MemberLogsTab = ({
setDetailChunks(null); setDetailChunks(null);
setDetailLoading(true); setDetailLoading(true);
try { try {
if (log.kind === 'subagent') { const chunks = await fetchDetailForLog(log);
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); setDetailChunks(chunks ? [...chunks] : null);
setDetailChunks(d?.chunks ?? null);
} else {
const d = await api.getSessionDetail(log.projectId, log.sessionId);
setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null);
}
} catch { } catch {
setDetailChunks(null); setDetailChunks(null);
} finally { } finally {
setDetailLoading(false); setDetailLoading(false);
} }
}, },
[expandedId] [expandedId, fetchDetailForLog, getRowId]
); );
if (loading && logs.length === 0) { if (loading && logs.length === 0) {
@ -178,32 +368,19 @@ export const MemberLogsTab = ({
return ( return (
<div className="w-full min-w-0 space-y-1.5"> <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 <LogCard
key={ key={getRowId(log)}
log.kind === 'subagent' ? `${log.sessionId}-${log.subagentId}` : `lead-${log.sessionId}`
}
log={log} log={log}
expanded={ expanded={expandedId === getRowId(log)}
expandedId === detailChunks={expandedId === getRowId(log) ? detailChunks : null}
(log.kind === 'subagent' detailLoading={expandedId === getRowId(log) && detailLoading}
? `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
}
onToggle={() => void handleExpand(log)} onToggle={() => void handleExpand(log)}
/> />
))} ))}
@ -306,3 +483,54 @@ function formatRelativeTime(isoString: string): string {
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString(); 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;
}

View file

@ -1,14 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { Check, Loader2, X } from 'lucide-react'; import { Check, Loader2, X } from 'lucide-react';
@ -32,8 +25,6 @@ export const MemberRoleEditor = ({
const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? '')); const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? ''));
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const showCustomInput = selectValue === CUSTOM_ROLE;
const handleSelectChange = (value: string): void => { const handleSelectChange = (value: string): void => {
setSelectValue(value); setSelectValue(value);
setError(null); setError(null);
@ -65,40 +56,22 @@ export const MemberRoleEditor = ({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Select value={selectValue} onValueChange={handleSelectChange}> <RoleSelect
<SelectTrigger className="h-7 w-32 text-xs"> value={selectValue}
<SelectValue /> onValueChange={handleSelectChange}
</SelectTrigger> customRole={customInput}
<SelectContent> onCustomRoleChange={(val) => {
<SelectItem value={NO_ROLE}>No role</SelectItem> setCustomInput(val);
{PRESET_ROLES.map((r) => ( setError(null);
<SelectItem key={r} value={r}> }}
{r} triggerClassName="h-7 w-32 text-xs"
</SelectItem> inputClassName="h-7 w-28 text-xs"
))} customRoleError={error}
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem> onCustomRoleValidate={(val) => {
</SelectContent> if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) return 'This role is reserved';
</Select> return null;
}}
{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>
)}
<Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}> <Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />} {saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}

View file

@ -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>
);
};

View file

@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments'; import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
@ -13,12 +13,11 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types'; import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
interface MessageComposerProps { interface MessageComposerProps {
teamName: string; teamName: string;
@ -36,6 +35,58 @@ interface MessageComposerProps {
const MAX_MESSAGE_LENGTH = 4000; 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 = ({ export const MessageComposer = ({
teamName, teamName,
members, members,
@ -49,6 +100,8 @@ export const MessageComposer = ({
return lead?.name ?? members[0]?.name ?? ''; return lead?.name ?? members[0]?.name ?? '';
}); });
const [recipientOpen, setRecipientOpen] = useState(false); const [recipientOpen, setRecipientOpen] = useState(false);
const [recipientSearch, setRecipientSearch] = useState('');
const recipientSearchRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -93,14 +146,24 @@ export const MessageComposer = ({
); );
const trimmed = draft.value.trim(); 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 selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const selectedColorSet = selectedResolvedColor ? getTeamColorSet(selectedResolvedColor) : null;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; 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 // Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false); const pendingSendRef = useRef(false);
@ -109,7 +172,8 @@ export const MessageComposer = ({
if (!canSend) return; if (!canSend) return;
pendingSendRef.current = true; pendingSendRef.current = true;
const serialized = serializeChipsWithText(trimmed, chipDraft.chips); 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]); }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
// Clear draft only after send completes successfully (sending: true → false, no error) // Clear draft only after send completes successfully (sending: true → false, no error)
@ -127,7 +191,7 @@ export const MessageComposer = ({
const handleKeyDownCapture = useCallback( const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleSend(); handleSend();
@ -204,68 +268,94 @@ export const MessageComposer = ({
type="button" 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)]" 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 ? ( {recipient ? (
<span <MemberBadge
className="inline-block size-2 shrink-0 rounded-full" name={recipient}
style={{ backgroundColor: selectedColorSet.border }} 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)]" /> <ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button> </button>
</PopoverTrigger> </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"> <div className="max-h-48 space-y-0.5 overflow-y-auto">
{members.map((m) => { {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
const resolvedColor = colorMap.get(m.name); {(() => {
const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; const query = recipientSearch.toLowerCase().trim();
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); const filtered = query
const isSelected = m.name === recipient; ? members.filter((m) => m.name.toLowerCase().includes(query))
return ( : members;
<button if (filtered.length === 0) {
key={m.name} return (
type="button" <div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
className={cn( No results
'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)]', </div>
isSelected && 'bg-[var(--color-surface-raised)]' );
)} }
onClick={() => { return filtered.map((m) => {
setRecipient(m.name); const resolvedColor = colorMap.get(m.name);
setRecipientOpen(false); const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
}} const isSelected = m.name === recipient;
> return (
{colorSet ? ( <button
<span key={m.name}
className="inline-block size-2 shrink-0 rounded-full" type="button"
style={{ backgroundColor: colorSet.border }} 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)]'
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" /> )}
)} onClick={() => {
<span setRecipient(m.name);
className="min-w-0 truncate font-medium" setRecipientOpen(false);
style={colorSet ? { color: colorSet.text } : undefined} setRecipientSearch('');
}}
> >
{m.name} <MemberBadge
</span> name={m.name}
{role ? ( color={resolvedColor}
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> size="sm"
{role} hideAvatar={m.name === 'user'}
</span> />
) : null} {role ? (
{isSelected ? ( <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
<Check size={12} className="ml-auto shrink-0 text-blue-400" /> {role}
) : null} </span>
</button> ) : null}
); {isSelected ? (
})} <Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -308,7 +398,9 @@ export const MessageComposer = ({
) : null} ) : null}
{!isTeamAlive ? ( {!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} ) : null}
</div> </div>
@ -316,11 +408,13 @@ export const MessageComposer = ({
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError} 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 <MentionableTextarea
id={`compose-${teamName}`} id={`compose-${teamName}`}
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`} placeholder="Write a message... (Enter to send)"
value={draft.value} value={draft.value}
onValueChange={draft.setValue} onValueChange={draft.setValue}
suggestions={mentionSuggestions} suggestions={mentionSuggestions}
@ -328,23 +422,42 @@ export const MessageComposer = ({
onChipRemove={chipDraft.removeChip} onChipRemove={chipDraft.removeChip}
projectPath={projectPath} projectPath={projectPath}
onFileChipInsert={chipDraft.addChip} onFileChipInsert={chipDraft.addChip}
onModEnter={handleSend}
minRows={2} minRows={2}
maxRows={6} maxRows={6}
maxLength={MAX_MESSAGE_LENGTH} maxLength={MAX_MESSAGE_LENGTH}
disabled={sending} disabled={sending}
cornerAction={ cornerAction={
<button <div className="flex items-center gap-2">
type="button" {/* NOTE: ContextRing disabled — usage formula is inaccurate */}
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" <Tooltip>
disabled={!canSend} <TooltipTrigger asChild>
onClick={handleSend} <button
> type="button"
<Send size={12} /> 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)]"
Send onClick={() => void window.electronAPI.openExternal('https://voicetext.site')}
</button> >
<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={ footerRight={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[10px] text-[var(--color-text-muted)] opacity-70">
Mention &quot;create a task&quot; to add it to the board
</span>
{sendError ? ( {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"> <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" /> <AlertCircle size={10} className="shrink-0" />

View file

@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; 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 { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -11,6 +14,8 @@ import type { InboxMessage } from '@shared/types';
export interface MessagesFilterState { export interface MessagesFilterState {
from: Set<string>; from: Set<string>;
to: Set<string>; to: Set<string>;
/** When true, include internal coordination noise (idle/shutdown/etc.) */
showNoise: boolean;
} }
interface MessagesFilterPopoverProps { interface MessagesFilterPopoverProps {
@ -44,18 +49,26 @@ export const MessagesFilterPopover = ({
onOpenChange, onOpenChange,
onApply, onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => { }: 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(() => { useEffect(() => {
if (open) { if (open) {
const next = { const next = {
from: new Set(filter.from), from: new Set(filter.from),
to: new Set(filter.to), to: new Set(filter.to),
showNoise: !!filter.showNoise,
}; };
const schedule = (): void => setDraft(next); const schedule = (): void => setDraft(next);
queueMicrotask(schedule); 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 fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
const toOptions = useMemo(() => collectToOptions(messages), [messages]); const toOptions = useMemo(() => collectToOptions(messages), [messages]);
@ -87,7 +100,7 @@ export const MessagesFilterPopover = ({
}; };
const handleReset = (): void => { 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); setDraft(empty);
onApply(empty); onApply(empty);
}; };
@ -124,6 +137,7 @@ export const MessagesFilterPopover = ({
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p> <p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : ( ) : (
fromOptions.map((name) => ( fromOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label <label
key={name} key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]" className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
@ -132,7 +146,12 @@ export const MessagesFilterPopover = ({
checked={draft.from.has(name)} checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)} onCheckedChange={() => toggleFrom(name)}
/> />
{name} <MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label> </label>
)) ))
)} )}
@ -147,23 +166,39 @@ export const MessagesFilterPopover = ({
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p> <p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : ( ) : (
toOptions.map((name) => ( toOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label <label
key={name} key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]" className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
> >
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} /> <Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
{name} <MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label> </label>
)) ))
)} )}
</div> </div>
</div> </div>
<div className="border-b border-[var(--color-border)] p-3">
{/* 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"> <div className="flex justify-between gap-2 p-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]" 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} onClick={handleReset}
> >
Reset Reset

View file

@ -724,14 +724,12 @@ export const ChangeReviewDialog = ({
}); });
}, [activeChangeSet, initialFilePath, scrollToFile]); }, [activeChangeSet, initialFilePath, scrollToFile]);
// Clear selection state on close (React-approved setState-during-render pattern) // Clear selection state on close
const [prevOpen, setPrevOpen] = useState(open); useEffect(() => {
if (prevOpen !== open) {
setPrevOpen(open);
if (!open) { if (!open) {
setSelectionInfo(null); setSelectionInfo(null);
} }
} }, [open]);
// Cleanup refs/timers on close // Cleanup refs/timers on close
useEffect(() => { useEffect(() => {

View file

@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
<tr className="border-t border-[var(--color-border)]"> <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-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-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)]"> <td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label

View 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>
);
};

View 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