From b163892d200ca344bc6c81e8fde02750053efa66 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 12:15:03 +0200 Subject: [PATCH 01/23] feat: enhance attachment handling and improve UI components - Updated local image storage documentation to reflect changes in protocol handling for attachments. - Added attachment metadata handling in message sending and inbox writing processes, improving attachment management. - Implemented size validation for attachments in the TeamAttachmentStore, ensuring compliance with storage limits. - Enhanced UI components to support attachment previews and improved user experience in managing attachments. - Introduced a new RoleSelect component for better role management within teams. --- docs/research/local-image-storage.md | 2 +- src/main/ipc/teams.ts | 17 +- src/main/services/team/TeamAttachmentStore.ts | 5 + src/main/services/team/TeamDataService.ts | 28 ++- src/main/services/team/TeamInboxWriter.ts | 8 + .../services/team/TeamProvisioningService.ts | 32 ++- .../services/team/TeamTaskAttachmentStore.ts | 3 + src/main/services/team/TeamTaskReader.ts | 48 ++-- .../chat/viewers/MarkdownViewer.tsx | 39 ++- .../components/settings/SettingsView.tsx | 10 +- .../sidebar/DateGroupedSessions.tsx | 2 +- .../components/sidebar/SessionItem.tsx | 6 +- .../team/CollapsibleTeamSection.tsx | 2 +- src/renderer/components/team/RoleSelect.tsx | 158 ++++++++++++ src/renderer/components/team/TaskTooltip.tsx | 125 +++++++++ .../components/team/TeamDetailView.tsx | 18 +- .../components/team/activity/ActivityItem.tsx | 100 ++++---- .../team/activity/ReplyQuoteBlock.tsx | 70 ++++-- .../attachments/AttachmentPreviewItem.tsx | 9 +- .../attachments/AttachmentPreviewList.tsx | 19 +- .../team/dialogs/AddMemberDialog.tsx | 107 +++++--- .../team/dialogs/CreateTaskDialog.tsx | 54 ++-- .../team/dialogs/SendMessageDialog.tsx | 158 ++++++------ .../team/dialogs/TaskCommentInput.tsx | 26 +- .../team/dialogs/TaskCommentsSection.tsx | 197 +++++---------- .../team/dialogs/TaskDetailDialog.tsx | 26 +- .../team/editor/EditorImagePreview.tsx | 9 +- .../team/editor/QuickOpenDialog.tsx | 9 +- .../components/team/kanban/KanbanBoard.tsx | 49 +++- .../team/kanban/KanbanFilterPopover.tsx | 50 +++- .../components/team/kanban/KanbanTaskCard.tsx | 25 +- .../team/members/MemberDraftRow.tsx | 43 +--- .../team/members/MemberRoleEditor.tsx | 61 ++--- .../team/messages/MessageComposer.tsx | 237 +++++++++++++----- .../team/messages/MessagesFilterPopover.tsx | 10 +- .../team/review/ChangeReviewDialog.tsx | 8 +- .../components/ui/ExpandableContent.tsx | 108 ++++++++ .../components/ui/MentionableTextarea.tsx | 13 +- src/renderer/hooks/useAttachments.ts | 3 + src/renderer/hooks/useFileSuggestions.ts | 34 ++- src/renderer/hooks/useResizableColumns.ts | 125 +++++++++ src/renderer/utils/agentMessageFormatting.ts | 20 +- src/shared/types/team.ts | 1 + test/main/ipc/teams.test.ts | 29 +++ .../services/team/TeamDataService.test.ts | 68 ++++- 45 files changed, 1562 insertions(+), 609 deletions(-) create mode 100644 src/renderer/components/team/RoleSelect.tsx create mode 100644 src/renderer/components/team/TaskTooltip.tsx create mode 100644 src/renderer/components/ui/ExpandableContent.tsx create mode 100644 src/renderer/hooks/useResizableColumns.ts diff --git a/docs/research/local-image-storage.md b/docs/research/local-image-storage.md index b7c8e5c3..85997c47 100644 --- a/docs/research/local-image-storage.md +++ b/docs/research/local-image-storage.md @@ -8,7 +8,7 @@ This document evaluates approaches for storing images/attachments locally in our ## Approach 1: Filesystem + SQLite Metadata (Recommended) -**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. +**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app-img://...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. ### Pros - Best I/O performance — direct filesystem reads, no serialization overhead. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 29206169..2133abdb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -937,6 +937,13 @@ async function handleSendMessage( } if (stdinSent) { + const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; try { @@ -944,20 +951,14 @@ async function handleSendMessage( tn, leadName, payload.text!, - payload.summary + payload.summary, + attachmentMeta ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; } - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); - // Save attachment binary data to disk (best-effort) if (validatedAttachments?.length && result.messageId) { void attachmentStore diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index cd4d36d7..a6a93eb1 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -10,6 +10,7 @@ import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); const ATTACHMENTS_DIR = 'attachments'; +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { @@ -58,6 +59,10 @@ export class TeamAttachmentStore { let raw: string; try { + const stat = await fs.promises.stat(filePath); + if (!stat.isFile() || stat.size > MAX_ATTACHMENTS_FILE_BYTES) { + return []; + } raw = await fs.promises.readFile(filePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2f9cc319..7fa82f5d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -32,6 +32,7 @@ import { TeamTaskWriter } from './TeamTaskWriter'; import type { AddMemberRequest, + AttachmentMeta, CreateTaskRequest, GlobalTask, InboxMessage, @@ -631,6 +632,7 @@ export class TeamDataService { const newMember: TeamMember = { name: request.name, role: request.role?.trim() || undefined, + workflow: request.workflow?.trim() || undefined, agentType: 'general-purpose', color: getMemberColor(members.filter((m) => !m.removedAt).length), joinedAt: Date.now(), @@ -1001,6 +1003,8 @@ export class TeamDataService { ]); const task = tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); + const owner = task?.owner?.trim() || null; + const normalizedOwner = owner?.toLowerCase() ?? null; // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) @@ -1008,7 +1012,7 @@ export class TeamDataService { await this.taskWriter.setNeedsClarification(teamName, taskId, null); } - if (task?.owner && !this.isLeadOwner(task.owner, leadName)) { + if (task && owner && !this.isLeadOwner(owner, leadName)) { // Notify non-lead task owner via inbox (lead → member message) const parts = [ `Comment on task #${taskId} "${task.subject}":\n\n${text}`, @@ -1018,12 +1022,12 @@ export class TeamDataService { AGENT_BLOCK_CLOSE, ]; await this.sendMessage(teamName, { - member: task.owner, + member: owner, from: leadName, text: parts.join('\n'), summary: `Comment on #${taskId}`, }); - } else if (task?.owner && this.isLeadOwner(task.owner, leadName)) { + } else if (task && owner && this.isLeadOwner(owner, leadName)) { // Notify lead about user's comment on their own task. // Write to lead's inbox — relay delivers to stdin when process is alive. const parts = [ @@ -1076,7 +1080,8 @@ export class TeamDataService { teamName: string, leadName: string, text: string, - summary?: string + summary?: string, + attachments?: AttachmentMeta[] ): Promise { const messageId = randomUUID(); const msg: InboxMessage = { @@ -1088,6 +1093,7 @@ export class TeamDataService { summary, messageId, source: 'user_sent', + attachments: attachments?.length ? attachments : undefined, }; await this.sentMessagesStore.appendMessage(teamName, msg); return { deliveredToInbox: false, deliveredViaStdin: true, messageId }; @@ -1217,9 +1223,23 @@ export class TeamDataService { // Dedup broadcasts: same sender + same text → process only once const processedTexts = new Set(); + function isAutomatedCommentNotification(msg: InboxMessage): boolean { + const summary = typeof msg.summary === 'string' ? msg.summary : ''; + if (!/^Comment on #\d+/.test(summary)) return false; + const text = typeof msg.text === 'string' ? msg.text : ''; + if (!text) return false; + // These are system-generated inbox messages that already correspond to a real task comment. + // Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI. + if (text.includes('Reply to this comment using:')) return true; + if (text.startsWith('Comment on task #')) return true; + if (text.startsWith('New comment from user on your task #')) return true; + return false; + } + for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; + if (isAutomatedCommentNotification(msg)) continue; const textKey = `${msg.from}\0${msg.text}`; if (processedTexts.has(textKey)) continue; diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index cfb606be..fd04a317 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -13,6 +13,13 @@ export class TeamInboxWriter { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); const messageId = randomUUID(); + const attachmentMeta = request.attachments?.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + const payload: InboxMessage = { from: request.from ?? 'user', to: request.member, @@ -21,6 +28,7 @@ export class TeamInboxWriter { read: false, summary: request.summary, messageId, + attachments: attachmentMeta?.length ? attachmentMeta : undefined, }; await withInboxLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a6a44fe8..8f7a1fe6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1028,7 +1028,8 @@ export class TeamProvisioningService { const run = this.runs.get(runId); if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }; } @@ -1055,7 +1056,8 @@ export class TeamProvisioningService { } run.leadContextUsage.lastEmittedAt = now; const { currentTokens, contextWindow } = run.leadContextUsage; - const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percentRaw = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const percent = Math.max(0, Math.min(100, percentRaw)); const payload: LeadContextUsage = { currentTokens, contextWindow, @@ -2584,11 +2586,18 @@ export class TeamProvisioningService { typeof modelData.contextWindow === 'number' && modelData.contextWindow > 0 ) { - if (run.leadContextUsage) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: 0, + contextWindow: modelData.contextWindow, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { run.leadContextUsage.contextWindow = modelData.contextWindow; run.leadContextUsage.lastEmittedAt = 0; // force re-emit - this.emitLeadContextUsage(run); } + this.emitLeadContextUsage(run); break; } } @@ -2610,9 +2619,18 @@ export class TeamProvisioningService { ? resultUsage.cache_read_input_tokens : 0; const total = inp + cc + cr; - if (total > 0 && run.leadContextUsage) { - run.leadContextUsage.currentTokens = total; - run.leadContextUsage.lastEmittedAt = 0; + if (total > 0) { + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens: total, + contextWindow: 0, + lastUsageMessageId: null, + lastEmittedAt: 0, + }; + } else { + run.leadContextUsage.currentTokens = total; + run.leadContextUsage.lastEmittedAt = 0; + } this.emitLeadContextUsage(run); } } diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 9661df70..0d71dcd4 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -21,6 +21,9 @@ export class TeamTaskAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 7b7246f6..e6b8912c 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -172,8 +172,12 @@ export class TeamTaskReader { : 'pending', workIntervals, statusHistory, - blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, - blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, + blocks: Array.isArray(parsed.blocks) + ? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, + blockedBy: Array.isArray(parsed.blockedBy) + ? (parsed.blockedBy as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, related: Array.isArray(parsed.related) ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') : undefined, @@ -197,19 +201,30 @@ export class TeamTaskReader { ? c.type : ('regular' as const), attachments: Array.isArray(c.attachments) - ? (c.attachments as unknown[]).filter( - (a): a is TaskAttachmentMeta => - Boolean(a) && - typeof a === 'object' && - typeof (a as Record).id === 'string' && - typeof (a as Record).filename === 'string' && - typeof (a as Record).mimeType === 'string' && - VALID_ATTACHMENT_MIME_TYPES.has( - (a as Record).mimeType as string - ) && - typeof (a as Record).size === 'number' && - typeof (a as Record).addedAt === 'string' - ) + ? (() => { + const filtered = (c.attachments as unknown[]) + .filter( + (a): a is TaskAttachmentMeta => + Boolean(a) && + typeof a === 'object' && + typeof (a as Record).id === 'string' && + typeof (a as Record).filename === 'string' && + typeof (a as Record).mimeType === 'string' && + VALID_ATTACHMENT_MIME_TYPES.has( + (a as Record).mimeType as string + ) && + typeof (a as Record).size === 'number' && + typeof (a as Record).addedAt === 'string' + ) + .map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + addedAt: a.addedAt, + })); + return filtered.length > 0 ? filtered : undefined; + })() : undefined, })) : undefined, @@ -256,6 +271,9 @@ export class TeamTaskReader { } } + // Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11) + tasks.sort((a, b) => Number(a.id) - Number(b.id)); + return tasks; } diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index f7670baf..bc681d64 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import ReactMarkdown, { type Components } from 'react-markdown'; +import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CODE_BG, @@ -60,6 +61,15 @@ interface MarkdownViewerProps { // Helpers // ============================================================================= +/** + * Custom URL transform that preserves task:// and mention:// protocols. + * react-markdown v10 strips non-standard protocols by default. + */ +function allowCustomProtocols(url: string): string { + if (url.startsWith('task://') || url.startsWith('mention://')) return url; + return defaultUrlTransform(url); +} + /** Check if a URL is relative (not absolute, not data, not mailto, not hash) */ function isRelativeUrl(url: string): boolean { return ( @@ -200,13 +210,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), // Links — inline element, no hl(); parent block element's hl() descends here - // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) + // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture // mention:// links render as colored inline badges a: ({ href, children }) => { if (href?.startsWith('mention://')) { const path = href.slice('mention://'.length); const slashIdx = path.indexOf('/'); - const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + let color = ''; + try { + color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + } catch { + // malformed percent-encoding — use empty color + } const colorSet = getTeamColorSet(color); const bg = colorSet.badge; return ( @@ -223,6 +238,21 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ); } + if (href?.startsWith('task://')) { + const taskId = href.slice('task://'.length); + return ( + + e.preventDefault()} + > + {children} + + + ); + } return ( { e.preventDefault(); - if (href && !href.startsWith('task://')) { + if (href) { void api.openExternal(href); } }} @@ -629,6 +659,7 @@ export const MarkdownViewer: React.FC = ({ remarkPlugins={[remarkGfm]} rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS} components={components} + urlTransform={allowCustomProtocols} > {content} diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index 8b8016cd..d7206e22 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -3,7 +3,7 @@ * Provides UI for managing notifications, display settings, and advanced options. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useStore } from '@renderer/store'; import { Loader2 } from 'lucide-react'; @@ -23,15 +23,13 @@ export const SettingsView = (): React.JSX.Element | null => { const pendingSettingsSection = useStore((s) => s.pendingSettingsSection); const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection); - // Consume pending section during render (React-recommended pattern for adjusting state on prop change) - const [prevPending, setPrevPending] = useState(null); - if (pendingSettingsSection !== prevPending) { - setPrevPending(pendingSettingsSection); + // Consume pending section (avoid setState during render) + useEffect(() => { if (pendingSettingsSection) { setActiveSection(pendingSettingsSection as SettingsSection); clearPendingSettingsSection(); } - } + }, [pendingSettingsSection, clearPendingSettingsSection]); const { config, diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 06afc588..620108fc 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -150,7 +150,7 @@ type VirtualItem = * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; -const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx +const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index e54479f3..9695f22e 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -240,13 +240,13 @@ export const SessionItem = ({ } }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - // Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll + // Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll return ( <> + )} m.name)} + existingMembers={data.members} + projectPath={data.config.projectPath} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role) => { + onAdd={(name, role, workflow) => { setAddingMemberLoading(true); void (async () => { try { - await addMember(teamName, { name, role }); + await addMember(teamName, { name, role, workflow }); setAddMemberDialogOpen(false); } catch { // error shown via store diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index bf03e0a8..14416cd2 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -3,6 +3,8 @@ import { useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_BG, @@ -175,24 +177,25 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map` in plain text as clickable inline elements. */ +/** Render `#` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#\d+)/g).map((part, i) => { const match = /^#(\d+)$/.exec(part); if (!match) return {part}; const taskId = match[1]; return ( - + + + ); }); } @@ -233,25 +236,30 @@ export const ActivityItem = ({ const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; const [isExpanded, setIsExpanded] = useState(!systemLabel); - // Strip agent-only blocks from displayed text + linkify task IDs + @mentions - const displayText = useMemo(() => { + // Strip agent-only blocks + normalize escape sequences (before linkification) + const strippedText = useMemo(() => { if (structured) return null; const stripped = stripAgentBlocks(message.text).trim(); if (!stripped) return null; // All content was agent-only blocks → show summary instead // Normalize literal \n from CLI tools (teamctl.js) to real newlines - const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); - let result = normalized; - if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result); + return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); + }, [structured, message.text]); + + // Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name + // into markdown links which breaks the reply regex matcher + const parsedReply = useMemo( + () => (strippedText ? parseMessageReply(strippedText) : null), + [strippedText] + ); + + // Linkify task IDs (always, for TaskTooltip) + @mentions for display + const displayText = useMemo(() => { + if (!strippedText) return null; + let result = linkifyTaskIdsInMarkdown(strippedText); if (memberColorMap && memberColorMap.size > 0) result = linkifyMentionsInMarkdown(result, memberColorMap); return result; - }, [structured, message.text, onTaskIdClick, memberColorMap]); - - // Check if this is a reply message - const parsedReply = useMemo( - () => (displayText ? parseMessageReply(displayText) : null), - [displayText] - ); + }, [strippedText, memberColorMap]); const rawSummary = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; @@ -276,11 +284,13 @@ export const ActivityItem = ({ }; const isHeaderClickable = Boolean(systemLabel); + const isUserSent = message.source === 'user_sent'; return (
) : parsedReply ? ( - + ) : displayText ? ( - { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); - e.stopPropagation(); - const taskId = link.getAttribute('href')?.replace('task://', ''); - if (taskId) onTaskIdClick(taskId); + + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const taskId = link.getAttribute('href')?.replace('task://', ''); + if (taskId) onTaskIdClick(taskId); + } } - } - : undefined - } - > - - + : undefined + } + > + + + ) : summaryText ? (

{summaryText} diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 29efede0..6949671b 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,29 +1,71 @@ +import { useMemo, useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; interface ReplyQuoteBlockProps { reply: ParsedMessageReply; + /** Color name for the quoted agent (resolved from memberColorMap). */ + memberColor?: string; /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ bodyMaxHeight?: string; } +/** Threshold (characters) above which the "more/less" toggle is shown. */ +const LONG_QUOTE_THRESHOLD = 200; + export const ReplyQuoteBlock = ({ reply, + memberColor, bodyMaxHeight = 'max-h-56', -}: ReplyQuoteBlockProps): React.JSX.Element => ( -

-
- - @{reply.agentName} - -
- +}: ReplyQuoteBlockProps): React.JSX.Element => { + const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; + const [expanded, setExpanded] = useState(false); + + const quoteMaxHeight = expanded ? 'max-h-48' : 'max-h-[3.75rem]'; + + return ( +
+ {/* Quote block — styled like SendMessageDialog */} +
+ {/* Decorative quotation mark */} + + “ + + + {/* "Replying to" + MemberBadge */} +
+ Replying to + +
+ + {/* Quote text */} +
+ +
+ + {/* More/less toggle */} + {isLong ? ( + + ) : null}
+ + {/* Reply text */} +
- -
-); + ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 0a97c7c2..75c7cd4a 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -1,5 +1,5 @@ import { formatFileSize } from '@renderer/utils/attachmentUtils'; -import { X } from 'lucide-react'; +import { Ban, X } from 'lucide-react'; import { AttachmentThumbnail } from './AttachmentThumbnail'; @@ -8,16 +8,23 @@ import type { AttachmentPayload } from '@shared/types'; interface AttachmentPreviewItemProps { attachment: AttachmentPayload; onRemove: (id: string) => void; + disabled?: boolean; } export const AttachmentPreviewItem = ({ attachment, onRemove, + disabled, }: AttachmentPreviewItemProps): React.JSX.Element => { const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`; return (
+ {disabled ? ( +
+ +
+ ) : null}
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index d2571ed1..164afb47 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -8,12 +8,18 @@ interface AttachmentPreviewListProps { attachments: AttachmentPayload[]; onRemove: (id: string) => void; error?: string | null; + /** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */ + disabled?: boolean; + /** Hint text shown when disabled and attachments are present. */ + disabledHint?: string; } export const AttachmentPreviewList = ({ attachments, onRemove, error, + disabled, + disabledHint, }: AttachmentPreviewListProps): React.JSX.Element | null => { if (attachments.length === 0 && !error) return null; @@ -22,10 +28,21 @@ export const AttachmentPreviewList = ({ {attachments.length > 0 ? (
{attachments.map((att) => ( - + ))}
) : null} + {disabled && disabledHint && attachments.length > 0 ? ( +
+ +

{disabledHint}

+
+ ) : null} {error ? (
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 23fa6ae2..1549cd70 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { @@ -11,16 +11,16 @@ import { } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; -import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; +import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; +import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { Loader2 } from 'lucide-react'; +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { ResolvedTeamMember } from '@shared/types'; + const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; interface AddMemberDialogProps { @@ -28,8 +28,12 @@ interface AddMemberDialogProps { teamName: string; existingNames: string[]; onClose: () => void; - onAdd: (name: string, role?: string) => void; + onAdd: (name: string, role?: string, workflow?: string) => void; adding?: boolean; + /** Project path for @file mentions in workflow field. */ + projectPath?: string | null; + /** Existing team members for @mention suggestions. */ + existingMembers?: ResolvedTeamMember[]; } export const AddMemberDialog = ({ @@ -39,12 +43,36 @@ export const AddMemberDialog = ({ onClose, onAdd, adding, + projectPath, + existingMembers = [], }: AddMemberDialogProps): React.JSX.Element => { const [name, setName] = useState(''); const [roleSelect, setRoleSelect] = useState(NO_ROLE); const [customRole, setCustomRole] = useState(''); const [error, setError] = useState(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( + () => + existingMembers + .filter((m) => !m.removedAt) + .map((m) => ({ + id: m.name, + name: m.name, + subtitle: m.role ?? undefined, + color: m.color, + })), + [existingMembers] + ); + const effectiveRole = roleSelect === CUSTOM_ROLE ? customRole.trim() @@ -72,7 +100,9 @@ export const AddMemberDialog = ({ return; } setError(null); - onAdd(name.trim().toLowerCase(), effectiveRole); + const wf = workflowDraft.value.trim() || undefined; + onAdd(name.trim().toLowerCase(), effectiveRole, wf); + workflowDraft.clearDraft(); }; const handleOpenChange = (nextOpen: boolean): void => { @@ -80,11 +110,20 @@ export const AddMemberDialog = ({ setName(''); setRoleSelect(NO_ROLE); setCustomRole(''); + workflowDraft.setValue(''); + workflowDraft.clearDraft(); setError(null); onClose(); } }; + const handleWorkflowChange = useCallback( + (v: string) => { + workflowDraft.setValue(v); + }, + [workflowDraft] + ); + return ( @@ -113,27 +152,31 @@ export const AddMemberDialog = ({
- - {roleSelect === CUSTOM_ROLE && ( - setCustomRole(e.target.value)} - /> - )} + +
+ +
+ + Draft saved + ) : null + } + />
diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 8e5bbc09..d650bef7 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; @@ -89,28 +89,40 @@ export const CreateTaskDialog = ({ const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const [blockedBySearch, setBlockedBySearch] = useState(''); const [relatedSearch, setRelatedSearch] = useState(''); - const [prevOpen, setPrevOpen] = useState(false); + const prevOpenRef = useRef(false); - if (open && !prevOpen) { - setSubject(defaultSubject); - if (defaultChip) { - const token = chipToken(defaultChip); - descriptionDraft.setValue(token + '\n'); - descChipDraft.setChips([defaultChip]); - } else if (defaultDescription) { - descriptionDraft.setValue(defaultDescription); + // Reset form when dialog opens (avoid setState during render) + useEffect(() => { + if (open && !prevOpenRef.current) { + setSubject(defaultSubject); + if (defaultChip) { + const token = chipToken(defaultChip); + descriptionDraft.setValue(token + '\n'); + descChipDraft.setChips([defaultChip]); + } else if (defaultDescription) { + descriptionDraft.setValue(defaultDescription); + } + setOwner(defaultOwner); + setBlockedBy([]); + setRelated([]); + setStartImmediately(defaultStartImmediately ?? isTeamAlive); + promptDraft.clearDraft(); + setBlockedBySearch(''); + setRelatedSearch(''); } - setOwner(defaultOwner); - setBlockedBy([]); - setRelated([]); - setStartImmediately(defaultStartImmediately ?? isTeamAlive); - promptDraft.clearDraft(); - setBlockedBySearch(''); - setRelatedSearch(''); - } - if (open !== prevOpen) { - setPrevOpen(open); - } + prevOpenRef.current = open; + }, [ + open, + defaultSubject, + defaultDescription, + defaultOwner, + defaultStartImmediately, + defaultChip, + isTeamAlive, + descriptionDraft, + descChipDraft, + promptDraft, + ]); const mentionSuggestions = useMemo( () => diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 7a662e00..4c5961bd 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -15,13 +15,7 @@ import { import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; +import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; @@ -33,7 +27,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { ImagePlus, X } from 'lucide-react'; +import { Check, ImagePlus, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -69,8 +63,6 @@ interface SendMessageDialogProps { onClose: () => void; } -const NO_MEMBER = '__none__'; - export const SendMessageDialog = ({ open, teamName, @@ -87,6 +79,15 @@ export const SendMessageDialog = ({ onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const recipientOptions = useMemo( + () => + members.map((m) => ({ + value: m.name, + label: m.name, + description: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, + })), + [members] + ); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); @@ -94,8 +95,8 @@ export const SendMessageDialog = ({ const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); const chipDraft = useChipDraftPersistence('sendMessage:chips'); const [summary, setSummary] = useState(''); - const [prevOpen, setPrevOpen] = useState(false); - const [prevResult, setPrevResult] = useState(null); + const prevOpenRef = useRef(false); + const prevResultRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); @@ -116,33 +117,36 @@ export const SendMessageDialog = ({ const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; const canAttach = isLeadRecipient && isTeamAlive && canAddMore; - // Reset form when dialog opens - if (open && !prevOpen) { - setMember(defaultRecipient ?? ''); - setSummary(''); - setQuote(quotedMessage); - setQuoteExpanded(false); - setPrevResult(lastResult); - if (defaultChip) { - const token = chipToken(defaultChip); - textDraft.setValue(token + '\n'); - chipDraft.setChips([defaultChip]); - } else if (defaultText) { - textDraft.setValue(defaultText); - } - } - if (open !== prevOpen) { - setPrevOpen(open); - } - - // Track whether auto-close is needed (setState in render phase is fine) const [pendingAutoClose, setPendingAutoClose] = useState(false); - if (open && lastResult && lastResult !== prevResult) { - setPrevResult(lastResult); - setMember(''); - setSummary(''); - setPendingAutoClose(true); - } + // Reset form on open transition (avoid setState in render) + useEffect(() => { + if (open && !prevOpenRef.current) { + setMember(defaultRecipient ?? ''); + setSummary(''); + setQuote(quotedMessage); + setQuoteExpanded(false); + prevResultRef.current = lastResult; + if (defaultChip) { + const token = chipToken(defaultChip); + textDraft.setValue(token + '\n'); + chipDraft.setChips([defaultChip]); + } else if (defaultText) { + textDraft.setValue(defaultText); + } + } + prevOpenRef.current = open; + }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]); + + // Track whether auto-close is needed (avoid setState in render) + useEffect(() => { + if (!open) return; + if (lastResult && lastResult !== prevResultRef.current) { + prevResultRef.current = lastResult; + setMember(''); + setSummary(''); + setPendingAutoClose(true); + } + }, [open, lastResult]); // Side effects (onClose mutates parent state) must run in useEffect, not render phase useEffect(() => { @@ -170,11 +174,14 @@ export const SendMessageDialog = ({ [members, colorMap] ); + const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient; + const canSend = member.trim().length > 0 && textDraft.value.trim().length > 0 && summary.trim().length > 0 && - !sending; + !sending && + !attachmentsBlocked; const handleChipRemove = (chipId: string): void => { const chip = chipDraft.chips.find((c) => c.id === chipId); @@ -271,40 +278,44 @@ export const SendMessageDialog = ({
- + ) : null} + {isSelected ? ( + + ) : null} + + ); + }} + />
@@ -351,6 +362,8 @@ export const SendMessageDialog = ({ attachments={attachments} onRemove={removeAttachment} error={attachmentError} + disabled={attachmentsBlocked} + disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." />
@@ -409,6 +422,7 @@ export const SendMessageDialog = ({ onChipRemove={handleChipRemove} projectPath={projectPath} onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} + onModEnter={handleSubmit} minRows={4} maxRows={12} footerRight={ diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 4d44f30f..a25757a3 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -9,7 +9,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { ImagePlus, Send, Trash2, X } from 'lucide-react'; +import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; @@ -86,10 +86,6 @@ export const TaskCommentInput = ({ ); 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; @@ -97,7 +93,10 @@ export const TaskCommentInput = ({ if (!base64) return; const id = crypto.randomUUID(); setPendingAttachments((prev) => { - if (prev.length >= MAX_ATTACHMENTS) return prev; + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } return [ ...prev, { @@ -114,7 +113,7 @@ export const TaskCommentInput = ({ reader.readAsDataURL(file); } }, - [pendingAttachments.length] + [] ); const removeAttachment = useCallback((id: string) => { @@ -256,6 +255,7 @@ export const TaskCommentInput = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} projectPath={projectPath} + onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} @@ -275,6 +275,18 @@ export const TaskCommentInput = ({ Attach image (or paste) + + + + + Voice to text + -
- - )} -
- {showExpandedButton && ( -
- -
+ } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + )} -
+ ); })()} {comment.attachments && comment.attachments.length > 0 ? ( @@ -361,6 +286,7 @@ export const TaskCommentsSection = ({ ) : null}
))} +
{sortedComments.length > visibleComments.length ? (
@@ -431,6 +357,7 @@ export const TaskCommentsSection = ({ value={draft.value} onValueChange={draft.setValue} suggestions={mentionSuggestions} + onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} maxLength={MAX_COMMENT_LENGTH} @@ -556,7 +483,3 @@ const CommentAttachments = ({ ))}
); - -function teamIdKey(teamName: string, taskId: string): string { - return `${teamName}::${taskId}`; -} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 1845b8ed..8be7ad13 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -6,6 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Dialog, DialogContent, @@ -545,7 +546,7 @@ export const TaskDetailDialog = ({
{ if (e.key === 'Enter' || e.key === ' ') { @@ -554,7 +555,9 @@ export const TaskDetailDialog = ({ } }} > - + + + - +
+ +
handleDependencyClick(taskId) : undefined} + containerClassName="-mx-6" /> diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx index eff4b230..f9f6f278 100644 --- a/src/renderer/components/team/editor/EditorImagePreview.tsx +++ b/src/renderer/components/team/editor/EditorImagePreview.tsx @@ -33,15 +33,14 @@ export const EditorImagePreview = ({ const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null); const imgRef = useRef(null); - // Reset state when filePath changes (setState-during-render, React-approved pattern) - const [prevFilePath, setPrevFilePath] = useState(filePath); - if (prevFilePath !== filePath) { - setPrevFilePath(filePath); + // Reset state when filePath changes + useEffect(() => { setLoading(true); setError(null); setDataUrl(null); setDimensions(null); - } + setLightboxOpen(false); + }, [filePath]); useEffect(() => { let cancelled = false; diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index eaca7c3e..b2a237ae 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -37,18 +37,11 @@ export const QuickOpenDialog = ({ const [allFiles, setAllFiles] = useState([]); const [loading, setLoading] = useState(true); - // Reset loading state when projectPath changes (React-recommended - // "adjusting state when props change" pattern without effects or refs) - const [prevProjectPath, setPrevProjectPath] = useState(projectPath); - if (prevProjectPath !== projectPath) { - setPrevProjectPath(projectPath); - setLoading(true); - } - // Load all project files on mount via backend API useEffect(() => { let cancelled = false; + setLoading(true); window.electronAPI.editor .listFiles() .then((files) => { diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index b8c8d2cf..3caacd0f 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -6,6 +6,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd- import { CSS } from '@dnd-kit/utilities'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useResizableColumns } from '@renderer/hooks/useResizableColumns'; import { cn } from '@renderer/lib/utils'; import { CheckCircle2, @@ -402,6 +403,17 @@ export const KanbanBoard = ({ ); }; + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] + ); + + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const boardContent = ( <>
@@ -475,7 +487,7 @@ export const KanbanBoard = ({ {viewMode === 'grid' ? (
- {COLUMNS.map((column) => { + {visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; return ( @@ -493,21 +505,32 @@ export const KanbanBoard = ({ })}
) : ( -
- {COLUMNS.map((column) => { +
+ {visibleColumns.map((column, index) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; return ( -
- - {renderCards(column.id, columnTasks, true)} - +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null}
); })} diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index cfd8a163..bd48b8b0 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -7,15 +7,26 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { Crown, Filter } from 'lucide-react'; import type { Session } from '@renderer/types/data'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { KanbanColumnId, ResolvedTeamMember } from '@shared/types'; export const UNASSIGNED_OWNER = '__unassigned__'; export interface KanbanFilterState { sessionId: string | null; selectedOwners: Set; + /** When non-empty, only these columns are visible on the kanban board. Empty = all columns. */ + columns: Set; } +/** Column definitions with display labels and accent colors for filter UI. */ +export const KANBAN_COLUMNS: { id: KanbanColumnId; label: string; color: string }[] = [ + { id: 'todo', label: 'TODO', color: 'rgb(59, 130, 246)' }, + { id: 'in_progress', label: 'IN PROGRESS', color: 'rgb(234, 179, 8)' }, + { id: 'done', label: 'DONE', color: 'rgb(34, 197, 94)' }, + { id: 'review', label: 'REVIEW', color: 'rgb(139, 92, 246)' }, + { id: 'approved', label: 'APPROVED', color: 'rgb(22, 163, 74)' }, +]; + interface KanbanFilterPopoverProps { filter: KanbanFilterState; sessions: Session[]; @@ -35,8 +46,9 @@ export const KanbanFilterPopover = ({ let count = 0; if (filter.sessionId !== null) count += 1; if (filter.selectedOwners.size > 0) count += 1; + if (filter.columns.size > 0) count += 1; return count; - }, [filter.sessionId, filter.selectedOwners]); + }, [filter.sessionId, filter.selectedOwners, filter.columns]); const handleSessionSelect = (sessionId: string | null): void => { onFilterChange({ ...filter, sessionId }); @@ -52,8 +64,18 @@ export const KanbanFilterPopover = ({ onFilterChange({ ...filter, selectedOwners: next }); }; + const handleColumnToggle = (columnId: KanbanColumnId): void => { + const next = new Set(filter.columns); + if (next.has(columnId)) { + next.delete(columnId); + } else { + next.add(columnId); + } + onFilterChange({ ...filter, columns: next }); + }; + const handleClearAll = (): void => { - onFilterChange({ sessionId: null, selectedOwners: new Set() }); + onFilterChange({ sessionId: null, selectedOwners: new Set(), columns: new Set() }); }; return ( @@ -148,6 +170,28 @@ export const KanbanFilterPopover = ({
+ {/* Column section */} +
+

+ Column +

+
+ {KANBAN_COLUMNS.map((col) => ( + + ))} +
+
+ {/* Footer */}
- + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )}
- {members.map((m) => { - const resolvedColor = colorMap.get(m.name); - const colorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - })} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + }); + })()}
@@ -316,6 +397,8 @@ export const MessageComposer = ({ attachments={attachments} onRemove={removeAttachment} error={attachmentError} + disabled={attachmentsBlocked} + disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." /> - - Send - +
+ {leadContext && leadContext.percent > 0 && } + + + + + Voice to text + + +
} footerRight={
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 547193ca..8f0a72aa 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { useStore } from '@renderer/store'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { Filter } from 'lucide-react'; import type { InboxMessage } from '@shared/types'; @@ -57,6 +60,9 @@ export const MessagesFilterPopover = ({ } }, [open, filter.from, filter.to]); + const members = useStore((s) => s.selectedTeamData?.members ?? []); + const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const fromOptions = useMemo(() => collectFromOptions(messages), [messages]); const toOptions = useMemo(() => collectToOptions(messages), [messages]); @@ -132,7 +138,7 @@ export const MessagesFilterPopover = ({ checked={draft.from.has(name)} onCheckedChange={() => toggleFrom(name)} /> - {name} + )) )} @@ -152,7 +158,7 @@ export const MessagesFilterPopover = ({ 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)]" > toggleTo(name)} /> - {name} + )) )} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 13003874..9a162fb0 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -724,14 +724,12 @@ export const ChangeReviewDialog = ({ }); }, [activeChangeSet, initialFilePath, scrollToFile]); - // Clear selection state on close (React-approved setState-during-render pattern) - const [prevOpen, setPrevOpen] = useState(open); - if (prevOpen !== open) { - setPrevOpen(open); + // Clear selection state on close + useEffect(() => { if (!open) { setSelectionInfo(null); } - } + }, [open]); // Cleanup refs/timers on close useEffect(() => { diff --git a/src/renderer/components/ui/ExpandableContent.tsx b/src/renderer/components/ui/ExpandableContent.tsx new file mode 100644 index 00000000..37158459 --- /dev/null +++ b/src/renderer/components/ui/ExpandableContent.tsx @@ -0,0 +1,108 @@ +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(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, collapsedHeight] + ); + + const handleCollapse = useCallback(() => { + setExpanded(false); + anchorRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, []); + + return ( +
+
+ {children} +
+ + {/* Show more */} + {!expanded && needsTruncation ? ( +
+ +
+ ) : null} + + {/* Sticky Show less */} + {expanded && needsTruncation ? ( +
+ +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 2b304812..d08f7ad9 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -204,6 +204,8 @@ interface MentionableTextareaProps extends Omit< projectPath?: string | null; /** Called when a file chip is created via @ selection. Parent must add chip to state. */ onFileChipInsert?: (chip: InlineChip) => void; + /** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */ + onModEnter?: () => void; } export const MentionableTextarea = React.forwardRef( @@ -220,6 +222,7 @@ export const MentionableTextarea = React.forwardRef) => { + // Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) { + e.preventDefault(); + onModEnter(); + return; + } handleChipKeyDown(e); if (!e.defaultPrevented) { if (enableFiles) { @@ -509,7 +518,7 @@ export const MentionableTextarea = React.forwardRef { const raw = await draftStorage.loadDraft(persistenceKey); if (cancelled || raw == null) return; diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 6db45f0b..9cedb0e7 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -5,7 +5,7 @@ * Returns up to 8 matching files filtered by name or relative path. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getQuickOpenCache, @@ -72,17 +72,15 @@ export function useFileSuggestions( // Bumped on cache invalidation (file create/delete) to trigger refetch const [fetchTrigger, setFetchTrigger] = useState(0); - // Re-seed from cache when projectPath changes (setState-during-render pattern) - const [prevPath, setPrevPath] = useState(projectPath); - if (prevPath !== projectPath) { - setPrevPath(projectPath); - const cached = projectPath ? getQuickOpenCache(projectPath) : null; - if (cached) { - setAllFiles(cached.files); - } else { + // Re-seed from cache when projectPath changes + useEffect(() => { + if (!projectPath) { setAllFiles([]); + return; } - } + const cached = getQuickOpenCache(projectPath); + setAllFiles(cached?.files ?? []); + }, [projectPath]); // React to cache invalidation from EditorFileWatcher (create/delete events) useEffect(() => { @@ -90,13 +88,13 @@ export function useFileSuggestions( }, []); // Lazy refetch: when dropdown opens and cache is stale, trigger a reload - const [prevEnabled, setPrevEnabled] = useState(enabled); - if (enabled && !prevEnabled && projectPath && !getQuickOpenCache(projectPath)) { - setFetchTrigger((n) => n + 1); - } - if (prevEnabled !== enabled) { - setPrevEnabled(enabled); - } + const prevEnabledRef = useRef(enabled); + useEffect(() => { + if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) { + setFetchTrigger((n) => n + 1); + } + prevEnabledRef.current = enabled; + }, [enabled, projectPath]); // Load files from API when cache is empty. // Uses project:listFiles (not editor:listFiles) — works without editor being open. @@ -126,7 +124,7 @@ export function useFileSuggestions( // Fetch only when cache is empty. Cache seeding is handled by: // - lazy initializer (first mount) - // - setState-during-render (projectPath change) + // - effect (projectPath change) useEffect(() => { if (!projectPath) return; diff --git a/src/renderer/hooks/useResizableColumns.ts b/src/renderer/hooks/useResizableColumns.ts new file mode 100644 index 00000000..332c836f --- /dev/null +++ b/src/renderer/hooks/useResizableColumns.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const STORAGE_PREFIX = 'kanban-column-widths:'; +const MIN_COLUMN_WIDTH = 180; +const DEFAULT_COLUMN_WIDTH = 256; // w-64 + +interface UseResizableColumnsOptions { + /** Storage key suffix (e.g. teamName). */ + storageKey: string; + /** Column IDs in display order. */ + columnIds: string[]; +} + +interface UseResizableColumnsResult { + /** Width in px for each column ID. */ + widths: Map; + /** Props to spread on the drag handle between columns. */ + getHandleProps: (leftColumnId: string) => { + onPointerDown: (e: React.PointerEvent) => void; + style: React.CSSProperties; + 'aria-label': string; + }; +} + +function loadWidths(key: string): Record { + try { + const raw = localStorage.getItem(STORAGE_PREFIX + key); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'number' && v >= MIN_COLUMN_WIDTH) { + result[k] = v; + } + } + return result; + } catch { + return {}; + } +} + +function saveWidths(key: string, widths: Record): void { + try { + localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(widths)); + } catch { + // Quota exceeded — ignore + } +} + +export function useResizableColumns({ + storageKey, + columnIds, +}: UseResizableColumnsOptions): UseResizableColumnsResult { + const [widthRecord, setWidthRecord] = useState>(() => + loadWidths(storageKey) + ); + + // Re-read from localStorage when storageKey changes + useEffect(() => { + setWidthRecord(loadWidths(storageKey)); + }, [storageKey]); + + const draggingRef = useRef<{ + leftId: string; + startX: number; + startWidth: number; + } | null>(null); + + const widths = new Map(); + for (const id of columnIds) { + widths.set(id, widthRecord[id] ?? DEFAULT_COLUMN_WIDTH); + } + + const handlePointerMove = useCallback((e: PointerEvent) => { + const drag = draggingRef.current; + if (!drag) return; + const delta = e.clientX - drag.startX; + const newWidth = Math.max(MIN_COLUMN_WIDTH, drag.startWidth + delta); + setWidthRecord((prev) => ({ ...prev, [drag.leftId]: newWidth })); + }, []); + + const handlePointerUp = useCallback(() => { + const drag = draggingRef.current; + if (!drag) return; + draggingRef.current = null; + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + // Persist + setWidthRecord((current) => { + saveWidths(storageKey, current); + return current; + }); + }, [handlePointerMove, storageKey]); + + const getHandleProps = useCallback( + (leftColumnId: string) => ({ + onPointerDown: (e: React.PointerEvent) => { + e.preventDefault(); + const currentWidth = widthRecord[leftColumnId] ?? DEFAULT_COLUMN_WIDTH; + draggingRef.current = { + leftId: leftColumnId, + startX: e.clientX, + startWidth: currentWidth, + }; + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + style: { + cursor: 'col-resize' as const, + width: 8, + flexShrink: 0, + alignSelf: 'stretch' as const, + }, + 'aria-label': `Resize column ${leftColumnId}`, + }), + [widthRecord, handlePointerMove, handlePointerUp] + ); + + return { widths, getHandleProps }; +} diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index ab41fb17..17311dc6 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -15,20 +15,30 @@ export interface ParsedMessageReply { const REPLY_BLOCK_RE = new RegExp( '```' + MESSAGE_REPLY_TAG + - '\\nReply on @([\\w-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' + '\\nReply on @([\\w.-]+) original message with text "([\\s\\S]*?)", here is answer: "([\\s\\S]*?)"\\n```' ); +function encodeReplyField(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function decodeReplyField(value: string): string { + // Backwards-compat: avoid touching legacy content that has no escapes. + if (!value.includes('\\"') && !value.includes('\\\\')) return value; + return value.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); +} + /** * Parses a message_reply_for_agent block from content. * Returns null if no reply block is found. */ export function parseMessageReply(content: string): ParsedMessageReply | null { - const match = REPLY_BLOCK_RE.exec(content); + const match = content.match(REPLY_BLOCK_RE); if (!match) return null; return { agentName: match[1], - originalText: match[2], - replyText: match[3], + originalText: decodeReplyField(match[2]), + replyText: decodeReplyField(match[3]), }; } @@ -43,7 +53,7 @@ export function buildReplyBlock( const tag = MESSAGE_REPLY_TAG; return [ '```' + tag, - `Reply on @${agentName} original message with text "${originalText}", here is answer: "${replyText}"`, + `Reply on @${agentName} original message with text "${encodeReplyField(originalText)}", here is answer: "${encodeReplyField(replyText)}"`, '```', ].join('\n'); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f67c6c9c..49100cd6 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -444,6 +444,7 @@ export interface MemberFullStats { export interface AddMemberRequest { name: string; role?: string; + workflow?: string; } export interface RemoveMemberRequest { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index c1227e30..48ed8954 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -46,6 +46,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({ TEAM_GET_ATTACHMENTS: 'team:getAttachments', TEAM_KILL_PROCESS: 'team:killProcess', TEAM_LEAD_ACTIVITY: 'team:leadActivity', + TEAM_LEAD_CONTEXT: 'team:leadContext', TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', @@ -102,6 +103,14 @@ import { TEAM_ADD_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, + TEAM_UPDATE_TASK_OWNER, + TEAM_UPDATE_TASK_FIELDS, + TEAM_LEAD_CONTEXT, + TEAM_RESTORE_TASK, + TEAM_SHOW_MESSAGE_NOTIFICATION, + TEAM_SAVE_TASK_ATTACHMENT, + TEAM_GET_TASK_ATTACHMENT, + TEAM_DELETE_TASK_ATTACHMENT, } from '../../../src/preload/constants/ipcChannels'; import { initializeTeamHandlers, @@ -231,6 +240,17 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(true); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(true); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(true); + expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(true); + expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(true); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(true); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(true); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(true); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(true); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true); }); it('returns success false on invalid sendMessage args', async () => { @@ -552,6 +572,15 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false); expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false); expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_OWNER)).toBe(false); + expect(handlers.has(TEAM_UPDATE_TASK_FIELDS)).toBe(false); + expect(handlers.has(TEAM_REPLACE_MEMBERS)).toBe(false); + expect(handlers.has(TEAM_LEAD_CONTEXT)).toBe(false); + expect(handlers.has(TEAM_RESTORE_TASK)).toBe(false); + expect(handlers.has(TEAM_SHOW_MESSAGE_NOTIFICATION)).toBe(false); + expect(handlers.has(TEAM_SAVE_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_ATTACHMENT)).toBe(false); + expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false); }); describe('addTaskRelationship', () => { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 4fdfa0be..b69b798b 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; -import type { TeamTask } from '../../../../src/shared/types/team'; +import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team'; describe('TeamDataService', () => { it('runs kanban garbage-collect only after tasks are loaded', async () => { @@ -47,6 +47,72 @@ describe('TeamDataService', () => { expect(order).toEqual(['tasks', 'gc']); }); + it('does not sync automated comment notifications into task comments', async () => { + const tasks: TeamTask[] = [ + { + id: '12', + subject: 'Task', + status: 'pending', + }, + ]; + + const addComment = vi.fn(async () => { + throw new Error('Should not be called'); + }); + + const messages: InboxMessage[] = [ + { + from: 'team-lead', + to: 'alice', + summary: 'Comment on #12', + messageId: 'm1', + timestamp: new Date().toISOString(), + read: false, + text: + 'Comment on task #12 "Task":\n\nHello\n\n' + + '\n' + + 'Reply to this comment using:\n' + + 'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' + + '', + }, + ]; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })), + } as never, + { + getTasks: vi.fn(async () => tasks), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => messages), + } as never, + {} as never, + { + addComment, + } as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never, + {} as never, + { + readMembers: vi.fn(async () => []), + } as never, + { + readMessages: vi.fn(async () => []), + } as never + ); + + await service.getTeamData('my-team'); + expect(addComment).not.toHaveBeenCalled(); + }); + it('skips kanban garbage-collect when tasks fail to load', async () => { const garbageCollect = vi.fn(async () => undefined); const service = new TeamDataService( From afb0173c0560ee7ce2ceb3ace7474400f9e58a14 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 13:32:58 +0200 Subject: [PATCH 02/23] feat: improve task sorting and enhance UI component behavior - Updated task sorting logic in TeamTaskReader to handle non-numeric IDs with stable lexicographic ordering. - Refactored ActivityItem to remove unnecessary whitespace for cleaner code. - Enhanced AttachmentPreviewItem to improve accessibility by adding pointer-events-none class for disabled state. - Modified MessageComposer to ensure the summary remains compact during message sending. - Added cleanup logic in useResizableColumns to handle global listeners and styles during drag events. --- src/main/services/team/TeamTaskReader.ts | 12 ++++++++++-- .../components/team/activity/ActivityItem.tsx | 1 - .../team/attachments/AttachmentPreviewItem.tsx | 2 +- .../components/team/messages/MessageComposer.tsx | 3 ++- src/renderer/hooks/useResizableColumns.ts | 11 +++++++++++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index e6b8912c..ef445afa 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -271,8 +271,16 @@ export class TeamTaskReader { } } - // Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11) - tasks.sort((a, b) => Number(a.id) - Number(b.id)); + // 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; } diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 14416cd2..fe1e9eeb 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -176,7 +176,6 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map` in plain text as clickable inline elements with TaskTooltip. */ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] { return text.split(/(#\d+)/g).map((part, i) => { diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx index 75c7cd4a..819ebaf6 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -21,7 +21,7 @@ export const AttachmentPreviewItem = ({ return (
{disabled ? ( -
+
) : null} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 84a4eb6f..47e45bf4 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -168,7 +168,8 @@ export const MessageComposer = ({ if (!canSend) return; pendingSendRef.current = true; const serialized = serializeChipsWithText(trimmed, chipDraft.chips); - onSend(recipient, serialized, serialized, attachments.length > 0 ? attachments : undefined); + // Summary should stay compact (no expanded chip markdown) + onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined); }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) diff --git a/src/renderer/hooks/useResizableColumns.ts b/src/renderer/hooks/useResizableColumns.ts index 332c836f..ee55fde2 100644 --- a/src/renderer/hooks/useResizableColumns.ts +++ b/src/renderer/hooks/useResizableColumns.ts @@ -95,6 +95,17 @@ export function useResizableColumns({ }); }, [handlePointerMove, storageKey]); + // Safety: if the board unmounts or storageKey changes mid-drag, clean up global listeners/styles. + useEffect(() => { + return () => { + draggingRef.current = null; + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [handlePointerMove, handlePointerUp]); + const getHandleProps = useCallback( (leftColumnId: string) => ({ onPointerDown: (e: React.PointerEvent) => { From 82bea01e0fa9f3c279c379d2370b7e736e1ec1f7 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 14:29:51 +0200 Subject: [PATCH 03/23] feat: enhance message handling and improve UI components - Updated SendMessageDialog and MessageComposer to support team-specific draft persistence for messages and attachments. - Refactored logic for attachment handling to ensure proper validation based on team status. - Improved MemberLogsTab to sort logs and manage expanded log details more effectively. - Enhanced useDraftPersistence and useChipDraftPersistence hooks to manage draft states with improved key handling. - Added debounced save functionality for draft persistence to prevent stale data issues. --- .../team/dialogs/SendMessageDialog.tsx | 11 +- .../components/team/members/MemberLogsTab.tsx | 108 ++++++++++++------ .../team/messages/MessageComposer.tsx | 7 +- src/renderer/hooks/useAttachments.ts | 24 ++-- src/renderer/hooks/useChipDraftPersistence.ts | 62 +++++----- src/renderer/hooks/useDraftPersistence.ts | 66 ++++++----- 6 files changed, 173 insertions(+), 105 deletions(-) diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4c5961bd..0815db50 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -92,8 +92,8 @@ export const SendMessageDialog = ({ const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); const [member, setMember] = useState(''); - const textDraft = useDraftPersistence({ key: 'sendMessage:text' }); - const chipDraft = useChipDraftPersistence('sendMessage:chips'); + const textDraft = useDraftPersistence({ key: `sendMessage:${teamName}:text` }); + const chipDraft = useChipDraftPersistence(`sendMessage:${teamName}:chips`); const [summary, setSummary] = useState(''); const prevOpenRef = useRef(false); const prevResultRef = useRef(null); @@ -115,7 +115,8 @@ export const SendMessageDialog = ({ const selectedMember = members.find((m) => m.name === member); const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; - const canAttach = isLeadRecipient && isTeamAlive && canAddMore; + const supportsAttachments = isLeadRecipient && !!isTeamAlive; + const canAttach = supportsAttachments && canAddMore; const [pendingAutoClose, setPendingAutoClose] = useState(false); // Reset form on open transition (avoid setState in render) @@ -174,7 +175,7 @@ export const SendMessageDialog = ({ [members, colorMap] ); - const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient; + const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const canSend = member.trim().length > 0 && @@ -363,7 +364,7 @@ export const SendMessageDialog = ({ onRemove={removeAttachment} error={attachmentError} disabled={attachmentsBlocked} - disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." + disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 15b01582..faeecc5e 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -53,11 +53,43 @@ export const MemberLogsTab = ({ const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const getRowId = useCallback((log: MemberLogSummary): string => { + return log.kind === 'subagent' + ? `subagent:${log.sessionId}:${log.subagentId}` + : `lead:${log.sessionId}`; + }, []); + + const sortedLogs = useMemo(() => { + const withIndex = logs.map((log, index) => ({ log, index })); + withIndex.sort((a, b) => { + const aTime = new Date(a.log.startTime).getTime(); + const bTime = new Date(b.log.startTime).getTime(); + 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 expandedLogSummary = useMemo(() => { + if (!expandedId) return null; + return logs.find((log) => getRowId(log) === expandedId) ?? null; + }, [expandedId, getRowId, logs]); + useEffect(() => { onRefreshingChange?.(refreshing); return () => onRefreshingChange?.(false); }, [refreshing, onRefreshingChange]); + useEffect(() => { + if (!expandedId) return; + if (expandedLogSummary) return; + setExpandedId(null); + setDetailChunks(null); + setDetailLoading(false); + }, [expandedId, expandedLogSummary]); + useEffect(() => { let cancelled = false; const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress'; @@ -84,7 +116,7 @@ export const MemberLogsTab = ({ }) : await api.teams.getMemberLogs(teamName, memberName!); if (!cancelled) { - setLogs(result); + setLogs(Array.isArray(result) ? [...result] : []); hasLoadedRef.current = true; } } catch (e) { @@ -110,12 +142,42 @@ export const MemberLogsTab = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); + const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise => { + 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(() => { + const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; + if (!expandedLogSummary) return; + if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; + + let cancelled = false; + + const interval = setInterval(async () => { + try { + const next = await fetchDetailForLog(expandedLogSummary); + if (cancelled) return; + // Ensure new reference so memoized transforms update. + setDetailChunks(next ? [...next] : null); + } catch { + // Keep last successful data; avoid flicker during transient errors. + } + }, 5000); + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); + const handleExpand = useCallback( async (log: MemberLogSummary) => { - const rowId = - log.kind === 'subagent' - ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + const rowId = getRowId(log); if (expandedId === rowId) { setExpandedId(null); @@ -126,20 +188,15 @@ export const MemberLogsTab = ({ setDetailChunks(null); setDetailLoading(true); try { - if (log.kind === 'subagent') { - const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); - setDetailChunks(d?.chunks ?? null); - } else { - const d = await api.getSessionDetail(log.projectId, log.sessionId); - setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null); - } + const chunks = await fetchDetailForLog(log); + setDetailChunks(chunks ? [...chunks] : null); } catch { setDetailChunks(null); } finally { setDetailLoading(false); } }, - [expandedId] + [expandedId, fetchDetailForLog, getRowId] ); if (loading && logs.length === 0) { @@ -178,31 +235,16 @@ export const MemberLogsTab = ({ return (
- {logs.map((log) => ( + {sortedLogs.map((log) => ( void handleExpand(log)} /> diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 47e45bf4..fd3a903a 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -152,8 +152,9 @@ export const MessageComposer = ({ const leadContext = useStore((s) => isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined ); - const canAttach = isLeadRecipient && isTeamAlive && canAddMore; - const attachmentsBlocked = attachments.length > 0 && !isLeadRecipient; + const supportsAttachments = isLeadRecipient && !!isTeamAlive; + const canAttach = supportsAttachments && canAddMore; + const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && @@ -399,7 +400,7 @@ export const MessageComposer = ({ onRemove={removeAttachment} error={attachmentError} disabled={attachmentsBlocked} - disabledHint="Image attachments are only supported when sending to team lead. Remove attachments or switch recipient." + disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." /> ([]); const timerRef = useRef | null>(null); - const pendingRef = useRef(null); + const pendingRef = useRef<{ key: string; value: AttachmentPayload[] } | null>(null); const keyRef = useRef(persistenceKey); keyRef.current = persistenceKey; @@ -67,7 +67,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR const key = keyRef.current; if (!key) return; - pendingRef.current = nextAttachments; + pendingRef.current = { key, value: nextAttachments }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -79,10 +79,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR pendingRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(key); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(key, JSON.stringify(pending)); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); } }, DEBOUNCE_MS); }, []); @@ -93,14 +93,12 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR timerRef.current = null; } if (pendingRef.current != null) { - const val = pendingRef.current; - const key = keyRef.current; + const pending = pendingRef.current; pendingRef.current = null; - if (!key) return; - if (val.length === 0) { - void draftStorage.deleteDraft(key); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(key, JSON.stringify(val)); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); } } }, []); @@ -110,6 +108,8 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR if (!persistenceKey) return; let cancelled = false; + // Flush any pending debounced save for the previous key before switching. + flushPending(); // Clear stale attachments from previous persistenceKey before loading attachmentsRef.current = []; setAttachments([]); @@ -136,7 +136,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR return () => { cancelled = true; }; - }, [persistenceKey]); + }, [persistenceKey, flushPending]); // Flush on unmount useEffect(() => { diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts index e0c9b49b..e7874ada 100644 --- a/src/renderer/hooks/useChipDraftPersistence.ts +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -46,19 +46,45 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { const [chips, setChipsState] = useState([]); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingRef = useRef(null); + const pendingRef = useRef<{ key: string; value: InlineChip[] } | null>(null); const keyRef = useRef(key); + keyRef.current = key; // Ref for current chips — allows addChip/removeChip to read latest value // without stale closures, using the same sync-ref pattern as keyRef. const chipsRef = useRef([]); + const mountedRef = useRef(true); useEffect(() => { - keyRef.current = key; - }, [key]); + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); - // Load on mount + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const pending = pendingRef.current; + pendingRef.current = null; + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); + } else { + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)); + } + } + }, []); + + // Load on mount / key change useEffect(() => { let cancelled = false; + // Flush any pending debounced save for the previous key and reset local state for the new key. + flushPending(); + chipsRef.current = []; + setChipsState([]); + setIsSaved(false); void (async () => { const raw = await draftStorage.loadDraft(key); if (cancelled || raw == null) return; @@ -76,23 +102,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { return () => { cancelled = true; }; - }, [key]); - - const flushPending = useCallback(() => { - if (timerRef.current != null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (pendingRef.current != null) { - const val = pendingRef.current; - pendingRef.current = null; - if (val.length === 0) { - void draftStorage.deleteDraft(keyRef.current); - } else { - void draftStorage.saveDraft(keyRef.current, JSON.stringify(val)); - } - } - }, []); + }, [key, flushPending]); // Flush on unmount useEffect(() => { @@ -105,7 +115,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { chipsRef.current = nextChips; setChipsState(nextChips); setIsSaved(false); - pendingRef.current = nextChips; + pendingRef.current = { key: keyRef.current, value: nextChips }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -117,11 +127,11 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { pendingRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(keyRef.current); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(keyRef.current, JSON.stringify(pending)).then(() => { - setIsSaved(true); + void draftStorage.saveDraft(pending.key, JSON.stringify(pending.value)).then(() => { + if (mountedRef.current) setIsSaved(true); }); } }, DEBOUNCE_MS); diff --git a/src/renderer/hooks/useDraftPersistence.ts b/src/renderer/hooks/useDraftPersistence.ts index fe6ecb03..0d6b22b3 100644 --- a/src/renderer/hooks/useDraftPersistence.ts +++ b/src/renderer/hooks/useDraftPersistence.ts @@ -25,15 +25,46 @@ export function useDraftPersistence({ const [value, setValueState] = useState(initialValue ?? ''); const [isSaved, setIsSaved] = useState(false); const timerRef = useRef | null>(null); - const pendingValueRef = useRef(null); + const pendingValueRef = useRef<{ key: string; value: string } | null>(null); const keyRef = useRef(key); keyRef.current = key; + const mountedRef = useRef(true); - // Load draft on mount useEffect(() => { - if (!enabled) return; + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingValueRef.current != null) { + const pending = pendingValueRef.current; + pendingValueRef.current = null; + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); + } else { + void draftStorage.saveDraft(pending.key, pending.value); + } + } + }, []); + + // Load draft on mount / key change + useEffect(() => { let cancelled = false; + // Prevent debounced saves for the previous key from landing under the new key. + flushPending(); + // Reset local state for the new key immediately. If a draft exists, it will overwrite below. + setValueState(initialValue ?? ''); + setIsSaved(false); + + if (!enabled) return () => { + cancelled = true; + }; void (async () => { const draft = await draftStorage.loadDraft(key); if (cancelled) return; @@ -46,24 +77,7 @@ export function useDraftPersistence({ return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount - }, [key, enabled]); - - const flushPending = useCallback(() => { - if (timerRef.current != null) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - if (pendingValueRef.current != null) { - const val = pendingValueRef.current; - pendingValueRef.current = null; - if (val.length === 0) { - void draftStorage.deleteDraft(keyRef.current); - } else { - void draftStorage.saveDraft(keyRef.current, val); - } - } - }, []); + }, [key, enabled, initialValue, flushPending]); // Flush on unmount useEffect(() => { @@ -79,7 +93,7 @@ export function useDraftPersistence({ if (!enabled) return; - pendingValueRef.current = v; + pendingValueRef.current = { key: keyRef.current, value: v }; if (timerRef.current != null) { clearTimeout(timerRef.current); @@ -91,11 +105,11 @@ export function useDraftPersistence({ pendingValueRef.current = null; if (pending == null) return; - if (pending.length === 0) { - void draftStorage.deleteDraft(keyRef.current); + if (pending.value.length === 0) { + void draftStorage.deleteDraft(pending.key); } else { - void draftStorage.saveDraft(keyRef.current, pending).then(() => { - setIsSaved(true); + void draftStorage.saveDraft(pending.key, pending.value).then(() => { + if (mountedRef.current) setIsSaved(true); }); } }, debounceMs); From fdb52922fe178852882932fcf9c1f8aff4d3a790 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 15:32:34 +0200 Subject: [PATCH 04/23] feat: add Claude logs retrieval functionality - Introduced TEAM_GET_CLAUDE_LOGS IPC channel for fetching buffered Claude CLI logs. - Implemented handleGetClaudeLogs function to validate requests and retrieve logs with pagination support. - Enhanced TeamProvisioningService to manage and store Claude log lines, including limits on stored logs. - Added ClaudeLogsSection component to display logs in the UI, with support for pagination and real-time updates. - Updated relevant types and API interfaces to accommodate new log retrieval features. --- src/main/ipc/teams.ts | 38 +++ .../services/team/TeamProvisioningService.ts | 223 +++++++++++++++++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 6 + src/renderer/api/httpClient.ts | 9 + .../components/team/ClaudeLogsSection.tsx | 134 +++++++++++ .../team/ProvisioningProgressBlock.tsx | 72 +++++- .../components/team/TeamDetailView.tsx | 3 + src/renderer/components/team/TeamListView.tsx | 15 ++ .../team/dialogs/CreateTeamDialog.tsx | 24 +- .../team/dialogs/LaunchTeamDialog.tsx | 7 +- .../components/team/members/MemberCard.tsx | 43 +--- .../team/members/MemberDetailHeader.tsx | 26 +- .../team/messages/MessageComposer.tsx | 11 +- src/renderer/store/slices/teamSlice.ts | 4 +- src/shared/types/api.ts | 4 + src/shared/types/team.ts | 18 ++ 17 files changed, 553 insertions(+), 87 deletions(-) create mode 100644 src/renderer/components/team/ClaudeLogsSection.tsx diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 2133abdb..db3142f6 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -14,6 +14,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, @@ -108,6 +109,8 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -195,6 +198,7 @@ export function initializeTeamHandlers( export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); @@ -248,6 +252,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_LAUNCH); @@ -634,6 +639,39 @@ async function validateProvisioningRequest( }; } +async function handleGetClaudeLogs( + _event: IpcMainInvokeEvent, + teamName: unknown, + query?: unknown +): Promise> { + 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; + parsed = { + offset: typeof q.offset === 'number' ? q.offset : undefined, + limit: typeof q.limit === 'number' ? q.limit : undefined, + }; + } + + return wrapTeamHandler('getClaudeLogs', async () => { + const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed); + return { + lines: data.lines, + total: data.total, + hasMore: data.hasMore, + updatedAt: data.updatedAt, + }; + }); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 8f7a1fe6..7d30a616 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -114,6 +114,14 @@ interface ProvisioningRun { progress: TeamProvisioningProgress; stdoutBuffer: string; stderrBuffer: string; + /** Rolling buffer of CLI log lines (oldest -> newest). */ + claudeLogLines: string[]; + /** Carry buffer for stdout line splitting (CLI output). */ + stdoutLogLineBuf: string; + /** Carry buffer for stderr line splitting (CLI output). */ + stderrLogLineBuf: string; + /** ISO timestamp when the last CLI line was recorded. */ + claudeLogsUpdatedAt?: string; processKilled: boolean; finalizingByTimeout: boolean; cancelRequested: boolean; @@ -719,7 +727,8 @@ ${membersFooter} function buildLaunchPrompt( request: TeamLaunchRequest, members: TeamCreateRequest['members'], - tasks: TeamTask[] + tasks: TeamTask[], + isResume: boolean ): string { const membersBlock = buildMembersPrompt(members); const userPromptBlock = request.prompt?.trim() @@ -828,7 +837,9 @@ ${memberSpawnInstructions} ? `Members:\n${membersBlock}` : 'Members: (none — solo team lead)'; - return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] + const startLabel = isResume ? 'Team Start (resume)' : 'Team Start'; + + return `${startLabel} [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. You are "${leadName}", the team lead. @@ -967,6 +978,8 @@ interface CachedProbeResult { let cachedProbeResult: CachedProbeResult | null = null; export class TeamProvisioningService { + private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; + private readonly runs = new Map(); private readonly activeByTeam = new Map(); private readonly teamOpLocks = new Map>(); @@ -983,6 +996,71 @@ export class TeamProvisioningService { private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore() ) {} + getClaudeLogs( + teamName: string, + query?: { offset?: number; limit?: number } + ): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + return { lines: [], total: 0, hasMore: false }; + } + const run = this.runs.get(runId); + if (!run) { + return { lines: [], total: 0, hasMore: false }; + } + + const offsetRaw = query?.offset ?? 0; + const limitRaw = query?.limit ?? 100; + const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; + const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + + const total = run.claudeLogLines.length; + if (total === 0) { + return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt }; + } + + const newestExclusive = Math.max(0, total - offset); + const oldestInclusive = Math.max(0, newestExclusive - limit); + const windowOldestToNewest = run.claudeLogLines.slice(oldestInclusive, newestExclusive); + const lines = windowOldestToNewest.reverse(); + return { + lines, + total, + hasMore: oldestInclusive > 0, + updatedAt: run.claudeLogsUpdatedAt, + }; + } + + private appendCliLogs(run: ProvisioningRun, stream: 'stdout' | 'stderr', text: string): void { + const nowMs = Date.now(); + run.claudeLogsUpdatedAt = new Date(nowMs).toISOString(); + + const prefix = stream === 'stdout' ? '[stdout] ' : '[stderr] '; + if (stream === 'stdout') { + run.stdoutLogLineBuf += text; + const parts = run.stdoutLogLineBuf.split('\n'); + run.stdoutLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(prefix + normalized); + } + } else { + run.stderrLogLineBuf += text; + const parts = run.stderrLogLineBuf.split('\n'); + run.stderrLogLineBuf = parts.pop() ?? ''; + for (const part of parts) { + const normalized = part.endsWith('\r') ? part.slice(0, -1) : part; + run.claudeLogLines.push(prefix + normalized); + } + } + if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) { + run.claudeLogLines.splice( + 0, + run.claudeLogLines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT + ); + } + } + /** * Serializes operations per team name using promise-chaining. * Same pattern as withInboxLock / withTaskLock. @@ -1189,6 +1267,67 @@ export class TeamProvisioningService { ); } + private hasApiError(text: string): boolean { + return /api error:\s*\d{3}\b/i.test(text) || /invalid_request_error/i.test(text); + } + + private sanitizeCliSnippet(text: string): string { + // Remove control characters that often show up as binary noise in CLI error payloads. + // Preserve newlines/tabs for readability. + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); + } + + private extractApiErrorSnippet(text: string): string | null { + const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); + if (!match || match.index === undefined) return null; + const start = Math.max(0, match.index - 200); + const end = Math.min(text.length, match.index + 4000); + const raw = text.slice(start, end).trim(); + if (!raw) return null; + // Avoid breaking markdown fences if the payload contains ``` accidentally. + return this.sanitizeCliSnippet(raw).replace(/```/g, '``\\`'); + } + + private failProvisioningWithApiError(run: ProvisioningRun, source: string): void { + if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return; + if (run.progress.state === 'failed' || run.cancelRequested) return; + + const combined = [ + buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), + run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', + ] + .filter(Boolean) + .join('\n') + .trim(); + + const snippet = + this.extractApiErrorSnippet(combined) ?? this.extractApiErrorSnippet(source) ?? null; + const status = + /api error:\s*(\d{3})\b/i.exec(combined)?.[1] ?? /api error:\s*(\d{3})\b/i.exec(source)?.[1]; + + const hint = run.isLaunch ? 'Launch' : 'Provisioning'; + const statusLabel = status ? `API Error ${status}` : 'API Error'; + if (snippet) { + run.provisioningOutputParts.push( + `**${hint} failed: ${statusLabel} detected**\n\n\`\`\`\n${snippet}\n\`\`\`` + ); + } else { + run.provisioningOutputParts.push(`**${hint} failed: ${statusLabel} detected**`); + } + + const progress = updateProgress(run, 'failed', `${hint} failed — ${statusLabel}`, { + error: `Claude CLI reported ${statusLabel} during startup. The team was not started.`, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + + run.processKilled = true; + run.cancelRequested = true; + run.child?.stdin?.end(); + killProcessTree(run.child); + this.cleanupRun(run); + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -1250,6 +1389,10 @@ export class TeamProvisioningService { // Reset buffers for fresh attempt run.stdoutBuffer = ''; run.stderrBuffer = ''; + run.claudeLogLines = []; + run.stdoutLogLineBuf = ''; + run.stderrLogLineBuf = ''; + run.claudeLogsUpdatedAt = undefined; run.authFailureRetried = true; updateProgress(run, 'spawning', 'Auth failed — retrying after short delay'); @@ -1362,6 +1505,7 @@ export class TeamProvisioningService { let stdoutLineBuf = ''; child.stdout.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stdout', text); run.stdoutBuffer += text; if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT); @@ -1380,6 +1524,9 @@ export class TeamProvisioningService { } catch { // Not valid JSON — check for auth failure in raw text output this.handleAuthFailureInOutput(run, trimmed, 'stdout'); + if (this.hasApiError(trimmed) && !this.isAuthFailureWarning(trimmed)) { + this.failProvisioningWithApiError(run, trimmed); + } } } @@ -1398,6 +1545,7 @@ export class TeamProvisioningService { child.stderr.on('data', (chunk: Buffer) => { const text = chunk.toString('utf8'); + this.appendCliLogs(run, 'stderr', text); run.stderrBuffer += text; if (run.stderrBuffer.length > STDERR_RING_LIMIT) { run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT); @@ -1405,6 +1553,9 @@ export class TeamProvisioningService { // Detect auth failure early instead of waiting for 5-minute timeout this.handleAuthFailureInOutput(run, text, 'stderr'); + if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) { + this.failProvisioningWithApiError(run, text); + } const currentTs = Date.now(); if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { @@ -1460,6 +1611,10 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1744,6 +1899,10 @@ export class TeamProvisioningService { startedAt, stdoutBuffer: '', stderrBuffer: '', + claudeLogLines: [], + stdoutLogLineBuf: '', + stderrLogLineBuf: '', + claudeLogsUpdatedAt: undefined, processKilled: false, finalizingByTimeout: false, cancelRequested: false, @@ -1800,7 +1959,12 @@ export class TeamProvisioningService { ); } - const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks); + const prompt = buildLaunchPrompt( + request, + expectedMemberSpecs, + existingTasks, + Boolean(previousSessionId) + ); let child: ReturnType; const { env: shellEnv } = await this.buildProvisioningEnv(); const launchArgs = [ @@ -2480,6 +2644,10 @@ export class TeamProvisioningService { // Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login") // rather than stderr or a result.subtype=error. Detect early to avoid false "ready". this.handleAuthFailureInOutput(run, text, 'assistant'); + if (this.hasApiError(text) && !this.isAuthFailureWarning(text)) { + this.failProvisioningWithApiError(run, text); + return; + } logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); // During provisioning (before provisioningComplete), accumulate for live UI preview. // Emission is handled by the throttled emitLogsProgress() in the stdout data handler. @@ -2735,9 +2903,39 @@ export class TeamProvisioningService { // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage if (msg.type === 'system') { const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined; - if (sub === 'compact_boundary' && run.leadContextUsage) { - run.leadContextUsage.lastUsageMessageId = null; - logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`); + if (sub === 'compact_boundary') { + if (run.leadContextUsage) { + run.leadContextUsage.lastUsageMessageId = null; + } + + // Extract compact metadata for the system message + const meta = (msg as Record).compact_metadata as + | Record + | undefined; + const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto'; + const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null; + const tokenInfo = preTokens + ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` + : ''; + + const compactMsg: InboxMessage = { + from: 'system', + text: `Context compacted${tokenInfo}, trigger: ${trigger}`, + timestamp: nowIso(), + read: true, + summary: `Context compacted (${trigger})`, + messageId: `compact-${run.runId}-${Date.now()}`, + source: 'lead_process', + }; + this.pushLiveLeadProcessMessage(run.teamName, compactMsg); + this.teamChangeEmitter?.({ + type: 'inbox', + teamName: run.teamName, + detail: 'compact_boundary', + }); + logger.info( + `[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}` + ); } } } @@ -2750,19 +2948,24 @@ export class TeamProvisioningService { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { // Guard: must be set synchronously BEFORE any await to prevent // double-invocation from filesystem monitor + stream-json racing. - if (run.provisioningComplete || run.cancelRequested) return; + if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed') + return; // Prevent false "ready" when auth failure was printed as assistant text or logs // but the filesystem monitor observed files on disk. - const authFailureText = [ + const preCompleteText = [ buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer), run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n') : '', ] .filter(Boolean) .join('\n') .trim(); - if (authFailureText && this.isAuthFailureWarning(authFailureText)) { - this.handleAuthFailureInOutput(run, authFailureText, 'pre-complete'); + if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) { + this.failProvisioningWithApiError(run, preCompleteText); + return; + } + if (preCompleteText && this.isAuthFailureWarning(preCompleteText)) { + this.handleAuthFailureInOutput(run, preCompleteText, 'pre-complete'); return; } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a3ca64c3..72722ee0 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,9 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get buffered Claude CLI logs (paged, newest-first) */ +export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; + /** Update team kanban state */ export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 8361d241..863c48d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,6 +70,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, @@ -197,6 +198,8 @@ import type { TaskChangeSetV2, TaskComment, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, @@ -696,6 +699,9 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { + return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); + }, deleteTeam: async (teamName: string) => { return invokeIpcWithResult(TEAM_DELETE_TEAM, teamName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b2c69fa8..7fc64c7c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -45,6 +45,8 @@ import type { SshLastConnection, SubagentDetail, TeamChangeEvent, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, TeamData, @@ -644,6 +646,13 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getClaudeLogs: async ( + _teamName: string, + _query?: TeamClaudeLogsQuery + ): Promise => { + console.warn('[HttpAPIClient] getClaudeLogs is not available in browser mode'); + return { lines: [], total: 0, hasMore: false }; + }, deleteTeam: async (_teamName: string): Promise => { throw new Error('Team deletion is not available in browser mode'); }, diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx new file mode 100644 index 00000000..7bb46cd0 --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -0,0 +1,134 @@ +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 { Terminal } from 'lucide-react'; + +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; + +import type { TeamClaudeLogsResponse } from '@shared/types'; + +const PAGE_SIZE = 100; +const POLL_MS = 2000; +const ONLINE_WINDOW_MS = 10_000; + +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; +} + +export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inFlightRef = useRef(false); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + setData({ lines: [], total: 0, hasMore: false }); + setError(null); + }, [teamName]); + + useEffect(() => { + let cancelled = false; + + const fetchLogs = async (): Promise => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + setLoading(true); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); + if (cancelled) return; + setData(next); + 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 ? ( + + + + + ) : null; + + return ( + } + badge={badge} + headerExtra={headerExtra} + defaultOpen + contentClassName="pt-0" + > +
+ + {data.total > 0 ? ( + <> + Showing {Math.min(data.total, visibleCount)} of{' '} + {data.total} + + ) : ( + 'No logs yet.' + )} + + {showMoreVisible && ( + + )} +
+ +
+ {error ? ( +

{error}

+ ) : data.lines.length > 0 ? ( +
+            {data.lines.join('\n')}
+          
+ ) : ( +

+ {loading ? 'Loading…' : 'No logs captured.'} +

+ )} +
+
+ ); +}; + diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 29c7e229..70450e2a 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -44,27 +44,66 @@ function formatElapsed(seconds: number): string { return `${m}:${String(s).padStart(2, '0')}`; } -function useElapsedTimer(startedAt?: string): string | null { - const [elapsed, setElapsed] = useState(null); +function useElapsedTimer(startedAt?: string, isRunning = true): string | null { + const [elapsedSeconds, setElapsedSeconds] = useState(null); useEffect(() => { - if (!startedAt) return () => setElapsed(null); + if (!startedAt) { + setElapsedSeconds(null); + return; + } + const startMs = Date.parse(startedAt); - if (isNaN(startMs)) return () => setElapsed(null); + if (isNaN(startMs)) { + setElapsedSeconds(null); + return; + } + + const computeElapsedSeconds = (): number => + Math.max(0, Math.floor((Date.now() - startMs) / 1000)); + + if (!isRunning) { + // Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick. + setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev)); + return; + } const tick = (): void => { - const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); - setElapsed(formatElapsed(seconds)); + setElapsedSeconds(computeElapsedSeconds()); }; + tick(); const id = window.setInterval(tick, 1000); return () => { window.clearInterval(id); }; - }, [startedAt]); + }, [startedAt, isRunning]); if (!startedAt) return null; - return elapsed; + if (elapsedSeconds === null) return null; + return formatElapsed(elapsedSeconds); +} + +function sanitizeAssistantOutput(raw?: string, isError = false): string | null { + if (!raw) return null; + if (!isError) return raw; + + const looksLikeRawApiEnvelope = + raw.includes('API Error: 400') && + (raw.includes('"_requests"') || + raw.includes('"session_id"') || + raw.includes('"parent_tool_use_id"') || + raw.includes('\\u000')); + + if (!looksLikeRawApiEnvelope) { + return raw; + } + + return ( + 'API Error: 400\n\n' + + 'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' + + 'Open **CLI logs** below for readable diagnostics.' + ); } export const ProvisioningProgressBlock = ({ @@ -81,11 +120,12 @@ export const ProvisioningProgressBlock = ({ assistantOutput, className, }: ProvisioningProgressBlockProps): React.JSX.Element => { - const elapsed = useElapsedTimer(startedAt); - const [logsOpen, setLogsOpen] = useState(false); + const elapsed = useElapsedTimer(startedAt, loading); + const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail)); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; + const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); // Auto-scroll assistant output useEffect(() => { @@ -99,6 +139,14 @@ export const ProvisioningProgressBlock = ({ setLiveOutputOpen(defaultLiveOutputOpen); }, [defaultLiveOutputOpen]); + // On error with logs available, prioritize logs view over noisy live stream payload. + useEffect(() => { + if (isError && cliLogsTail) { + setLogsOpen(true); + setLiveOutputOpen(false); + } + }, [isError, cliLogsTail]); + return (
- {assistantOutput ? ( - + {displayAssistantOutput ? ( + ) : (

)} + + { }; }, [electronMode, teams]); + // Refresh alive teams when opening the create dialog so conflict warning is accurate. + useEffect(() => { + if (!electronMode || !showCreateDialog) return; + let cancelled = false; + void api.teams + .aliveList() + .then((list) => { + if (!cancelled) setAliveTeams(list); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [electronMode, showCreateDialog]); + const currentProjectPath = useMemo(() => { if (viewMode === 'grouped') { const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 292194ae..41768955 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -491,10 +491,11 @@ export const CreateTeamDialog = ({ activeError?.includes('Team already exists') === true && request.teamName.length > 0; const conflictingTeam = useMemo(() => { + if (!launchTeam) return null; if (!activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; - }, [activeTeams, effectiveCwd]); + }, [activeTeams, effectiveCwd, launchTeam]); // Reset dismiss when conflict target changes useEffect(() => { @@ -554,6 +555,18 @@ export const CreateTeamDialog = ({ })(); }; + const handleTeamNameChange = (value: string): void => { + setTeamName(value); + setFieldErrors((prev) => { + if (!prev.teamName) return prev; + const { teamName: _teamName, ...rest } = prev; + if (!rest.members && !rest.cwd && localError === 'Check form fields') { + setLocalError(null); + } + return rest; + }); + }; + return (

- Team “{conflictingTeam.displayName}” is already running in this - project + Another team “{conflictingTeam.displayName}” is already running for + this working directory

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.

+

+ Working directory: {effectiveCwd} +

)} - {leadContext && leadContext.percent > 0 && ( - - -
-
90 - ? 'bg-red-500' - : leadContext.percent > 70 - ? 'bg-amber-500' - : 'bg-blue-500' - }`} - style={{ width: `${Math.min(leadContext.percent, 100)}%` }} - /> -
- - - Context: {Math.round(leadContext.percent)}% ( - {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} - {(leadContext.contextWindow / 1000).toFixed(0)}k tokens) - - - )} + {/* TODO: lead context bar disabled — usage formula is inaccurate */}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 5dc422ae..8ac7e03c 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { useStore } from '@renderer/store'; +// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -31,20 +31,15 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); - const teamName = useStore((s) => s.selectedTeamName); - const leadContext = useStore((s) => - member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - ); + // TODO: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getPresenceLabel( - member, - isTeamAlive, - isTeamProvisioning, - leadActivity, - leadContext?.percent - ); + const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const canEditRole = @@ -107,12 +102,7 @@ export const MemberDetailHeader = ({ > {presenceLabel} - {leadContext && leadContext.percent > 0 && ( - - {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} - {(leadContext.contextWindow / 1000).toFixed(0)}k - - )} + {/* TODO: lead context token display disabled — usage formula is inaccurate */} )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index fd3a903a..23d68d0b 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -148,10 +148,11 @@ export const MessageComposer = ({ const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; - const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; - const leadContext = useStore((s) => - isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined - ); + // TODO: 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; @@ -420,7 +421,7 @@ export const MessageComposer = ({ disabled={sending} cornerAction={
- {leadContext && leadContext.percent > 0 && } + {/* TODO: ContextRing disabled — usage formula is inaccurate */} +
+ )} +
+
+ + {/* Context panel sidebar */} + {isContextPanelVisible && leadSessionId && ( +
+ {leadSessionLoaded ? ( + setContextPanelVisible(false)} + projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath} + totalSessionTokens={lastAiGroupTotalTokens} + sessionMetrics={leadSessionDetail?.metrics} + subagentCostUsd={leadSubagentCostUsd} + phaseInfo={leadSessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + /> + ) : ( +
+
+
+

Visible Context

+

+ {leadSessionLoading ? 'Loading…' : 'No session loaded'} +

+
+ +
+
+

+ {leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'} +

+
+
+ )} +
+ )}
{editorOpen && data.config.projectPath && ( diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 0815db50..339c244c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -27,7 +27,8 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { Check, ImagePlus, X } from 'lucide-react'; +import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; +import { AlertCircle, Check, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -40,6 +41,8 @@ interface QuotedMessage { text: string; } +const MAX_MESSAGE_LENGTH = 4000; + interface SendMessageDialogProps { open: boolean; teamName: string; @@ -177,9 +180,13 @@ export const SendMessageDialog = ({ const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; + const trimmedText = textDraft.value.trim(); + const remaining = MAX_MESSAGE_LENGTH - trimmedText.length; + const canSend = member.trim().length > 0 && - textDraft.value.trim().length > 0 && + trimmedText.length > 0 && + trimmedText.length <= MAX_MESSAGE_LENGTH && summary.trim().length > 0 && !sending && !attachmentsBlocked; @@ -262,7 +269,7 @@ export const SendMessageDialog = ({ return ( + + {sending ? 'Sending...' : 'Send'} + + } footerRight={ - textDraft.isSaved ? ( - Draft saved - ) : null +
+ {sendError ? ( + + + {sendError} + + ) : null} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {textDraft.isSaved ? ( + Draft saved + ) : null} +
} />
@@ -449,16 +484,12 @@ export const SendMessageDialog = ({

- {sendError ?

{sendError}

: null}
- diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 1d68bcf2..2bbe3858 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { ImagePlus, Loader2, Trash2, X } from 'lucide-react'; +import { File, ImagePlus, Loader2, Trash2, X } from 'lucide-react'; -import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types'; +import { isImageMimeType } from '@renderer/utils/attachmentUtils'; + +import type { TaskAttachmentMeta } from '@shared/types'; const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); @@ -30,7 +32,7 @@ export const TaskAttachments = ({ const [error, setError] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ id: string; - mimeType: AttachmentMediaType; + mimeType: string; dataUrl: string | null; loading: boolean; } | null>(null); @@ -73,7 +75,7 @@ export const TaskAttachments = ({ ); const handleDelete = useCallback( - async (attachmentId: string, mimeType: AttachmentMediaType) => { + async (attachmentId: string, mimeType: string) => { setDeletingId(attachmentId); try { await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); @@ -89,8 +91,39 @@ export const TaskAttachments = ({ [teamName, taskId, deleteTaskAttachment, previewAttachment] ); + 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] + ); + const handlePreview = useCallback( async (att: TaskAttachmentMeta) => { + if (!isImageMimeType(att.mimeType)) { + void handleDownload(att); + return; + } if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { setPreviewAttachment(null); return; @@ -114,7 +147,7 @@ export const TaskAttachments = ({ setError('Failed to load attachment'); } }, - [teamName, taskId, getTaskAttachmentData, previewAttachment] + [teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload] ); // Handle paste events for quick image attachment @@ -277,6 +310,7 @@ const AttachmentThumbnail = ({ let cancelled = false; void (async () => { try { + if (!isImageMimeType(attachment.mimeType)) return; const base64 = await getTaskAttachmentData( teamName, taskId, @@ -311,10 +345,19 @@ const AttachmentThumbnail = ({ } bg-[var(--color-surface)]`} onClick={onPreview} > - {thumbUrl ? ( - {attachment.filename} + {isImageMimeType(attachment.mimeType) ? ( + thumbUrl ? ( + {attachment.filename} + ) : ( + + ) ) : ( - +
+ +
+ {attachment.filename} +
+
)} {/* Delete button overlay */}
)} @@ -1192,18 +1212,34 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
} onRequestReview={(taskId) => { - void requestReview(teamName, taskId); + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); }} onApprove={(taskId) => { - void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + // error via store + } + })(); }} onRequestChanges={(taskId) => { setRequestChangesTaskId(taskId); }} onMoveBackToDone={(taskId) => { void (async () => { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } })(); }} onStartTask={(taskId) => { @@ -1237,7 +1273,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele })(); }} onCompleteTask={(taskId) => { - void updateTaskStatus(teamName, taskId, 'completed'); + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); }} onCancelTask={(taskId) => { void (async () => { diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 60f98b39..3b508855 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -382,7 +382,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void deleteTeam(teamName); + try { + await deleteTeam(teamName); + } catch { + // error via store + } } })(); }, @@ -392,7 +396,13 @@ export const TeamListView = (): React.JSX.Element => { const handleRestoreTeam = useCallback( (teamName: string, e: React.MouseEvent) => { e.stopPropagation(); - void restoreTeam(teamName); + void (async () => { + try { + await restoreTeam(teamName); + } catch { + // error via store + } + })(); }, [restoreTeam] ); @@ -409,7 +419,11 @@ export const TeamListView = (): React.JSX.Element => { variant: 'danger', }); if (confirmed) { - void permanentlyDeleteTeam(teamName); + try { + await permanentlyDeleteTeam(teamName); + } catch { + // error via store + } } })(); }, diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx index b3461d6a..cd6291c8 100644 --- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx +++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx @@ -87,10 +87,10 @@ export const AttachmentDisplay = ({
{lightboxIndex !== null && items[lightboxIndex] ? ( setLightboxIndex(null)} + slides={items.map((item) => ({ src: item.dataUrl, alt: item.meta.filename }))} + index={lightboxIndex} /> ) : null} diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 34169fe9..51e3fd47 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -1,58 +1,85 @@ -import { useCallback, useEffect } from 'react'; +import { useMemo } from 'react'; -interface ImageLightboxProps { +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 'yet-another-react-lightbox/styles.css'; +import 'yet-another-react-lightbox/plugins/counter.css'; + +import type { Plugin, Slide } from 'yet-another-react-lightbox'; + +export interface ImageLightboxSlide { src: string; alt?: string; + title?: string; +} + +interface ImageLightboxProps { open: boolean; onClose: () => void; + /** Array of slides for gallery mode. */ + slides?: ImageLightboxSlide[]; + /** Starting slide index (default: 0). */ + index?: number; + /** Single image src — convenience shorthand for `slides={[{ src }]}`. */ + src?: string; + /** Alt text for single-image mode. */ + alt?: string; + enableZoom?: boolean; + enableFullscreen?: boolean; + showCounter?: boolean; } export const ImageLightbox = ({ - src, - alt = 'Image', open, onClose, + slides: slidesProp, + index = 0, + src, + alt, + enableZoom = true, + enableFullscreen = true, + showCounter, }: ImageLightboxProps): React.JSX.Element | null => { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose] - ); + const slides = useMemo(() => { + if (slidesProp && slidesProp.length > 0) { + return slidesProp.map((s) => ({ src: s.src, alt: s.alt, title: s.title })); + } + if (src) { + return [{ src, alt }]; + } + return []; + }, [slidesProp, src, alt]); - useEffect(() => { - if (!open) return; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [open, handleKeyDown]); + const plugins = useMemo(() => { + const list: Plugin[] = []; + if (enableZoom) list.push(Zoom); + if (enableFullscreen) list.push(Fullscreen); + // Show counter only when multiple slides (unless explicitly set) + const shouldShowCounter = showCounter ?? slides.length > 1; + if (shouldShowCounter) list.push(Counter); + return list; + }, [enableZoom, enableFullscreen, showCounter, slides.length]); - if (!open) return null; + if (!open || slides.length === 0) return null; return ( -
- -
+ ); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 339c244c..4916a5ff 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -14,10 +14,9 @@ import { } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { Combobox, type ComboboxOption } from '@renderer/components/ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useAttachments } from '@renderer/hooks/useAttachments'; import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; @@ -28,7 +27,7 @@ import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; -import { AlertCircle, Check, ImagePlus, Send, X } from 'lucide-react'; +import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -82,15 +81,6 @@ export const SendMessageDialog = ({ onClose, }: SendMessageDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const recipientOptions = useMemo( - () => - members.map((m) => ({ - value: m.name, - label: m.name, - description: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, - })), - [members] - ); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const [quote, setQuote] = useState(undefined); const [quoteExpanded, setQuoteExpanded] = useState(false); @@ -286,43 +276,12 @@ export const SendMessageDialog = ({
- setMember(v ?? '')} placeholder="Select member..." - searchPlaceholder="Search members..." - emptyMessage="No members found." - options={recipientOptions} - renderOption={(option, isSelected) => { - const resolvedColor = colorMap.get(option.value); - const optionColorSet = resolvedColor ? getTeamColorSet(resolvedColor) : null; - return ( - <> - {optionColorSet ? ( - - ) : ( - - )} - - {option.label} - - {option.description ? ( - - {option.description} - - ) : null} - {isSelected ? ( - - ) : null} - - ); - }} + size="sm" />
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 2bbe3858..5f608fbd 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { File, ImagePlus, Loader2, Trash2, X } from 'lucide-react'; +import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import type { TaskAttachmentMeta } from '@shared/types'; @@ -30,14 +31,21 @@ export const TaskAttachments = ({ const [uploading, setUploading] = useState(false); const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ - id: string; - mimeType: string; - dataUrl: string | null; - loading: boolean; - } | null>(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); + const imageAttachments = attachments.filter((a) => isImageMimeType(a.mimeType)); + + const handleThumbLoaded = useCallback((attachmentId: string, dataUrl: string) => { + setThumbCache((prev) => { + if (prev.get(attachmentId) === dataUrl) return prev; + const next = new Map(prev); + next.set(attachmentId, dataUrl); + return next; + }); + }, []); + const handleFileSelect = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; @@ -79,16 +87,13 @@ export const TaskAttachments = ({ setDeletingId(attachmentId); try { await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); - if (previewAttachment?.id === attachmentId) { - setPreviewAttachment(null); - } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete'); } finally { setDeletingId(null); } }, - [teamName, taskId, deleteTaskAttachment, previewAttachment] + [teamName, taskId, deleteTaskAttachment] ); const handleDownload = useCallback( @@ -119,35 +124,15 @@ export const TaskAttachments = ({ ); const handlePreview = useCallback( - async (att: TaskAttachmentMeta) => { + (att: TaskAttachmentMeta) => { if (!isImageMimeType(att.mimeType)) { void handleDownload(att); return; } - if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { - setPreviewAttachment(null); - return; - } - setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true }); - try { - const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); - if (base64) { - setPreviewAttachment({ - id: att.id, - mimeType: att.mimeType, - dataUrl: `data:${att.mimeType};base64,${base64}`, - loading: false, - }); - } else { - setPreviewAttachment(null); - setError('Attachment file not found'); - } - } catch { - setPreviewAttachment(null); - setError('Failed to load attachment'); - } + const idx = imageAttachments.findIndex((a) => a.id === att.id); + if (idx >= 0) setLightboxIndex(idx); }, - [teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload] + [imageAttachments, handleDownload] ); // Handle paste events for quick image attachment @@ -212,38 +197,28 @@ export const TaskAttachments = ({ teamName={teamName} taskId={taskId} isDeleting={deletingId === att.id} - isPreviewActive={previewAttachment?.id === att.id} onPreview={() => void handlePreview(att)} onDelete={() => void handleDelete(att.id, att.mimeType)} + onDataLoaded={handleThumbLoaded} /> ))}
) : null} - {/* Preview panel */} - {previewAttachment ? ( -
- - {previewAttachment.loading ? ( -
- - Loading image... -
- ) : previewAttachment.dataUrl ? ( - Attachment preview - ) : null} -
- ) : null} + {/* Image lightbox */} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + slides={imageAttachments + .map((att) => { + const dataUrl = thumbCache.get(att.id); + return dataUrl ? { src: dataUrl, alt: att.filename } : null; + }) + .filter(Boolean) as { src: string; alt: string }[]} + index={lightboxIndex} + /> + )} {/* Drop zone indicator */} {dragOver ? ( @@ -289,9 +264,9 @@ interface AttachmentThumbnailProps { teamName: string; taskId: string; isDeleting: boolean; - isPreviewActive: boolean; onPreview: () => void; onDelete: () => void; + onDataLoaded?: (attachmentId: string, dataUrl: string) => void; } const AttachmentThumbnail = ({ @@ -299,9 +274,9 @@ const AttachmentThumbnail = ({ teamName, taskId, isDeleting, - isPreviewActive, onPreview, onDelete, + onDataLoaded, }: AttachmentThumbnailProps): React.JSX.Element => { const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); @@ -318,7 +293,9 @@ const AttachmentThumbnail = ({ attachment.mimeType ); if (!cancelled && base64) { - setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); + const dataUrl = `data:${attachment.mimeType};base64,${base64}`; + setThumbUrl(dataUrl); + onDataLoaded?.(attachment.id, dataUrl); } } catch { // ignore @@ -327,7 +304,7 @@ const AttachmentThumbnail = ({ return () => { cancelled = true; }; - }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); + }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData, onDataLoaded]); const sizeLabel = attachment.size < 1024 @@ -338,11 +315,7 @@ const AttachmentThumbnail = ({ return (
{isImageMimeType(attachment.mimeType) ? ( diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 20666c95..f4fcf71d 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -305,22 +306,14 @@ export const TaskCommentsSection = ({
) : null} - {/* Full-size image preview overlay */} + {/* Image lightbox */} {previewImageUrl ? ( -
- - Attachment preview -
+ setPreviewImageUrl(null)} + src={previewImageUrl} + alt="Attachment preview" + /> ) : null} {!hideInput && ( @@ -416,6 +409,7 @@ const CommentAttachmentThumbnail = ({ const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); const [thumbUrl, setThumbUrl] = useState(null); const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(null); useEffect(() => { let cancelled = false; @@ -441,58 +435,76 @@ const CommentAttachmentThumbnail = ({ }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); return ( -
{ - if (isImageMimeType(attachment.mimeType)) { - if (thumbUrl) onPreview(thumbUrl); - return; - } - void (async () => { - setDownloading(true); - 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); - } finally { - setDownloading(false); - } - })(); - }} - > - {isImageMimeType(attachment.mimeType) ? ( - thumbUrl ? ( - {attachment.filename} - ) : ( - - ) - ) : downloading ? ( - + + +
{ + 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 ? ( + {attachment.filename} + ) : ( + + ) + ) : downloading ? ( + + ) : ( + + )} +
+ {attachment.filename} +
+
+
+ {downloadError ? ( + + {downloadError} + ) : ( - + {attachment.filename} )} -
- {attachment.filename} -
-
+ ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 8be7ad13..fa63ac59 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -7,6 +7,7 @@ import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Dialog, DialogContent, @@ -16,19 +17,10 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap, KANBAN_COLUMN_DISPLAY, @@ -350,42 +342,14 @@ export const TaskDetailDialog = ({
{canReassign ? ( - + onOwnerChange(currentTask.id, v)} + allowUnassigned + size="sm" + className="min-w-[160px]" + /> ) : currentTask.owner ? ( setLightboxOpen(false)} + src={dataUrl} + alt={fileName} />
); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index ca34de0a..e86968a7 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -270,9 +270,7 @@ export const KanbanTaskCard = ({
{task.owner ? ( - ) : ( - Unassigned - )} + ) : null} {!compact && }
{task.needsClarification ? ( diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 55f3baec..2fbb7b7e 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} - {task.owner ?? 'Unassigned'} + {task.owner ?? '\u2014'} {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx new file mode 100644 index 00000000..b99c68c5 --- /dev/null +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -0,0 +1,203 @@ +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'; + + const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => { + const resolvedColor = colorMap.get(member.name); + const colors = getTeamColorSet(resolvedColor ?? ''); + return ( + + + + {member.name === 'team-lead' ? 'lead' : member.name} + + + ); + }; + + return ( + + + + + + +
+ +
+ e.stopPropagation()} + > + + No members found. + + {allowUnassigned && !search.trim() ? ( + { + 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)]" + > + Unassigned + {value === null ? ( + + ) : null} + + ) : 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 ( + { + 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)]" + > + + + {m.name === 'team-lead' ? 'lead' : m.name} + + {role ? ( + + {role} + + ) : null} + {isSelected ? ( + + ) : null} + + ); + })} + +
+
+
+ ); +}; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 48ed8954..522dd469 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -8,58 +8,11 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); -vi.mock('@preload/constants/ipcChannels', () => ({ - TEAM_LIST: 'team:list', - TEAM_GET_DATA: 'team:getData', - TEAM_DELETE_TEAM: 'team:deleteTeam', - TEAM_PREPARE_PROVISIONING: 'team:prepareProvisioning', - TEAM_CREATE: 'team:create', - TEAM_LAUNCH: 'team:launch', - TEAM_CREATE_CONFIG: 'team:createConfig', - TEAM_CREATE_TASK: 'team:createTask', - TEAM_PROVISIONING_STATUS: 'team:provisioningStatus', - TEAM_CANCEL_PROVISIONING: 'team:cancelProvisioning', - TEAM_PROVISIONING_PROGRESS: 'team:provisioningProgress', - TEAM_SEND_MESSAGE: 'team:sendMessage', - TEAM_REQUEST_REVIEW: 'team:requestReview', - TEAM_UPDATE_KANBAN: 'team:updateKanban', - TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder', - TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus', - TEAM_UPDATE_TASK_FIELDS: 'team:updateTaskFields', - TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner', - TEAM_PROCESS_SEND: 'team:processSend', - TEAM_PROCESS_ALIVE: 'team:processAlive', - TEAM_ALIVE_LIST: 'team:aliveList', - TEAM_STOP: 'team:stop', - TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs', - TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask', - TEAM_GET_MEMBER_STATS: 'team:getMemberStats', - TEAM_UPDATE_CONFIG: 'team:updateConfig', - TEAM_START_TASK: 'team:startTask', - TEAM_GET_ALL_TASKS: 'team:getAllTasks', - TEAM_ADD_TASK_COMMENT: 'team:addTaskComment', - TEAM_ADD_MEMBER: 'team:addMember', - TEAM_REPLACE_MEMBERS: 'team:replaceMembers', - TEAM_REMOVE_MEMBER: 'team:removeMember', - TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole', - TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch', - TEAM_GET_ATTACHMENTS: 'team:getAttachments', - TEAM_KILL_PROCESS: 'team:killProcess', - TEAM_LEAD_ACTIVITY: 'team:leadActivity', - TEAM_LEAD_CONTEXT: 'team:leadContext', - TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', - TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', - TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification', - TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification', - TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship', - TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship', - TEAM_RESTORE: 'team:restoreTeam', - TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam', - TEAM_RESTORE_TASK: 'team:restoreTask', - TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment', - TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment', - TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment', -})); +// Keep this mock resilient to new exports (avoid drift). +vi.mock('@preload/constants/ipcChannels', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); import { TEAM_ALIVE_LIST, From 98593b495d6ff38ea5236b780f0a58b059e6c557 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 17:00:25 +0200 Subject: [PATCH 07/23] feat: refactor context token calculations and enhance UI components - Introduced utility functions for summing context injection tokens and formatting percentages of total tokens. - Updated SessionContextPanel and SessionContextHeader to utilize new utility functions for improved readability and maintainability. - Refactored TeamDetailView to streamline context token calculations and enhance the display of context percentages. - Improved error handling in ProjectPathSelector for better user experience during folder selection. - Made minor adjustments to ActivityTimeline and ProvisioningProgressBlock for code clarity and consistency. --- .../components/SessionContextHeader.tsx | 5 +- .../chat/SessionContextPanel/index.tsx | 3 +- .../team/ProvisioningProgressBlock.tsx | 3 -- .../components/team/TeamDetailView.tsx | 54 +++++++++++++------ .../team/activity/ActivityTimeline.tsx | 2 +- .../team/dialogs/ProjectPathSelector.tsx | 10 ++-- src/renderer/utils/contextMath.ts | 28 ++++++++++ src/renderer/utils/streamJsonParser.ts | 27 +++++++++- 8 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 src/renderer/utils/contextMath.ts diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 0879452a..748b55cc 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -16,6 +16,7 @@ import { formatCostUsd } from '@shared/utils/costFormatting'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; +import { formatPercentOfTotal } from '@renderer/utils/contextMath'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; @@ -110,7 +111,7 @@ export const SessionContextHeader = ({ )}
{/* Percentage of total */} - {totalSessionTokens !== undefined && totalSessionTokens > 0 && ( + {formatPercentOfTotal(totalTokens, totalSessionTokens) && ( - {Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total + {formatPercentOfTotal(totalTokens, totalSessionTokens)} )}
diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 83b7ac8f..6866f0ad 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -29,6 +29,7 @@ import { SECTION_TOOL_OUTPUTS, SECTION_USER_MESSAGES, } from './types'; +import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types'; import type { @@ -133,7 +134,7 @@ export const SessionContextPanel = ({ // Calculate total tokens const totalTokens = useMemo( - () => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + () => sumContextInjectionTokens(injections), [injections] ); diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 4fdd94c7..64fd0789 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -195,10 +195,8 @@ export const ProvisioningProgressBlock = ({ {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; - return (
- {/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */} {STEP_LABELS[step]} - {/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */} {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( ) : null} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 4b7cb936..e051da90 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -20,6 +20,7 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; +import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; @@ -434,14 +435,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); - const visibleContextTokens = useMemo(() => { - return allContextInjections.reduce((sum, inj) => sum + (inj.estimatedTokens ?? 0), 0); - }, [allContextInjections]); - - const visibleContextPercentOfTotal = useMemo(() => { - if (lastAiGroupTotalTokens === undefined || lastAiGroupTotalTokens <= 0) return null; - return Math.min((visibleContextTokens / lastAiGroupTotalTokens) * 100, 100); - }, [visibleContextTokens, lastAiGroupTotalTokens]); + const visibleContextTokens = useMemo( + () => sumContextInjectionTokens(allContextInjections), + [allContextInjections] + ); + const visibleContextPercentLabel = useMemo( + () => formatPercentOfTotal(visibleContextTokens, lastAiGroupTotalTokens), + [visibleContextTokens, lastAiGroupTotalTokens] + ); useEffect(() => { if (!projectId) return; @@ -863,9 +864,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele className="relative size-full flex-1 overflow-auto p-4" data-team-name={teamName} > - {/* Sticky context button in top-right while scrolling */} + {/* Context button pinned to bottom-right of viewport */} {leadSessionId && ( -
+
+ + + Filter logs + + +
+

+ Stream +

+
+ + +
+
+ +
+

+ Content +

+
+ + + +
+
+ +
+ + +
+
+ + ); +}; + diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 9c110816..5cae2663 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,12 +3,17 @@ 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 { Terminal } from 'lucide-react'; +import { Search, Terminal, X } from 'lucide-react'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CliLogsRichView } from './CliLogsRichView'; +import { + ClaudeLogsFilterPopover, + DEFAULT_CLAUDE_LOGS_FILTER, +} from './ClaudeLogsFilterPopover'; import type { TeamClaudeLogsResponse } from '@shared/types'; +import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; const PAGE_SIZE = 100; const POLL_MS = 2000; @@ -65,22 +70,178 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { return out.join('\n'); } +type AssistantContentBlock = + | { type: 'text'; text?: string } + | { type: 'thinking'; thinking?: string } + | { type: 'tool_use'; id?: string; name?: string; input?: Record } + | { 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: 'stdout' | 'stderr' | null = null; + let lastEmittedStream: 'stdout' | 'stderr' | 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): 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; + if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; + } + return null; + }; + + const writeBlocks = (parsed: Record, blocks: AssistantContentBlock[]): Record => { + if (Array.isArray(parsed.content)) { + return { ...parsed, content: blocks }; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + return { ...parsed, message: { ...(msg as Record), 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; + + 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 [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [pending, setPending] = useState(null); + const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); + const atTopRef = useRef(true); + const latestRef = useRef(null); + const logContainerRef = useRef(null); + const committedRef = useRef({ lines: [], total: 0, hasMore: false }); + const pendingCountRef = useRef(0); + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState(() => ({ + 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 => { if (inFlightRef.current) return; inFlightRef.current = true; @@ -88,7 +249,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J setLoading(true); const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); if (cancelled) return; - setData(next); + 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; @@ -118,6 +288,32 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J ) : 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 ( - {showMoreVisible && ( - - )} +
+
+ + 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 && ( + + )} +
+ + {pendingNewCount > 0 && ( + + )} + {showMoreVisible && ( + + )} +
{error ?

{error}

: null} - {!error && data.lines.length > 0 ? ( + {!error && filteredText.trim().length > 0 ? ( { + logContainerRef.current = el; + }} + onScroll={({ scrollTop }) => { + const atTop = scrollTop <= 8; + atTopRef.current = atTop; + if (atTop && pendingCountRef.current > 0) { + applyPending(); + } + }} /> ) : null} {!error && data.lines.length === 0 ? ( @@ -171,6 +416,11 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J {loading ? 'Loading…' : 'No logs captured.'}

) : null} + {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( +

+ No matching logs. +

+ ) : null}
); diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index dfc82fc0..bb04ff47 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -19,6 +19,8 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser'; interface CliLogsRichViewProps { cliLogsTail: string; order?: 'oldest-first' | 'newest-first'; + onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; + containerRefCallback?: (el: HTMLDivElement | null) => void; className?: string; } @@ -130,9 +132,11 @@ const StreamGroup = ({ export const CliLogsRichView = ({ cliLogsTail, order = 'oldest-first', + onScroll, + containerRefCallback, className, }: CliLogsRichViewProps): React.JSX.Element => { - const scrollRef = useRef(null); + const scrollRef = useRef(null); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); @@ -190,11 +194,18 @@ export const CliLogsRichView = ({ const hasContent = cliLogsTail.trim().length > 0; return (
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} className={cn( 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} > {hasContent ? (
@@ -212,7 +223,17 @@ export const CliLogsRichView = ({
   const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
 
   return (
-    
+
{ + scrollRef.current = el; + containerRefCallback?.(el); + }} + className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)} + onScroll={(e) => { + const el = e.currentTarget; + onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }); + }} + > {visibleGroups.map((group) => group.items.length === 1 ? ( // Single item — render flat without collapsible group wrapper diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e051da90..251538e0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -27,6 +27,7 @@ import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { AlertTriangle, Bell, @@ -304,6 +305,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [messagesFilter, setMessagesFilter] = useState({ from: new Set(), to: new Set(), + showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); @@ -444,6 +446,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele [visibleContextTokens, lastAiGroupTotalTokens] ); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + + // Keep lead-session context fresh in the background while the team tab is active. + // This keeps the button value current even when the panel is closed. + useEffect(() => { + if (!isThisTabActive) return; + if (!tabId || !projectId || !leadSessionId) return; + if (!data?.isAlive) return; + + const tick = (): void => { + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); + }; + tick(); + const id = window.setInterval(tick, 30_000); + return () => window.clearInterval(id); + }, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]); + useEffect(() => { if (!projectId) return; @@ -558,6 +578,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele return ts >= timeWindow.start && ts < timeWindow.end; }); } + if (!messagesFilter.showNoise) { + list = list.filter((m) => !isInboxNoiseMessage(typeof m.text === 'string' ? m.text : '')); + } if (messagesFilter.from.size > 0) { list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim())); } @@ -874,8 +897,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onClick={() => { const next = !isContextPanelVisible; setContextPanelVisible(next); - if (next && tabId && projectId && leadSessionId && !leadSessionLoaded) { - void fetchSessionDetail(projectId, leadSessionId, tabId); + if (tabId && projectId && leadSessionId) { + void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true }); } }} onMouseEnter={() => setIsContextButtonHovered(true)} @@ -899,11 +922,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : leadSessionId } > - {visibleContextPercentLabel - ? visibleContextPercentLabel - : typeof leadContextPercent === 'number' - ? `${Math.round(leadContextPercent)}%` - : 'Context'} + {visibleContextPercentLabel ?? 'Context'}
)} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 4916a5ff..45d8ad17 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -171,12 +171,14 @@ export const SendMessageDialog = ({ const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; const trimmedText = textDraft.value.trim(); - const remaining = MAX_MESSAGE_LENGTH - trimmedText.length; + const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); + const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; + const remaining = MAX_MESSAGE_LENGTH - finalText.length; const canSend = member.trim().length > 0 && - trimmedText.length > 0 && - trimmedText.length <= MAX_MESSAGE_LENGTH && + finalText.length > 0 && + finalText.length <= MAX_MESSAGE_LENGTH && summary.trim().length > 0 && !sending && !attachmentsBlocked; @@ -191,8 +193,6 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; - const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips); - const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; onSend( member.trim(), finalText, diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 5f608fbd..aa823502 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -32,6 +32,7 @@ export const TaskAttachments = ({ const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); + const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]); const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); @@ -130,9 +131,19 @@ export const TaskAttachments = ({ return; } const idx = imageAttachments.findIndex((a) => a.id === att.id); - if (idx >= 0) setLightboxIndex(idx); + if (idx >= 0) { + const snapshot = imageAttachments.map((a) => { + const dataUrl = thumbCache.get(a.id); + return { + src: dataUrl ?? `data:image/svg+xml,`, + alt: a.filename, + }; + }); + setLightboxSlides(snapshot); + setLightboxIndex(idx); + } }, - [imageAttachments, handleDownload] + [imageAttachments, thumbCache, handleDownload] ); // Handle paste events for quick image attachment @@ -209,13 +220,11 @@ export const TaskAttachments = ({ {lightboxIndex !== null && ( setLightboxIndex(null)} - slides={imageAttachments - .map((att) => { - const dataUrl = thumbCache.get(att.id); - return dataUrl ? { src: dataUrl, alt: att.filename } : null; - }) - .filter(Boolean) as { src: string; alt: string }[]} + onClose={() => { + setLightboxIndex(null); + setLightboxSlides([]); + }} + slides={lightboxSlides} index={lightboxIndex} /> )} diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 8f0a72aa..25117732 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -14,6 +14,8 @@ import type { InboxMessage } from '@shared/types'; export interface MessagesFilterState { from: Set; to: Set; + /** When true, include internal coordination noise (idle/shutdown/etc.) */ + showNoise: boolean; } interface MessagesFilterPopoverProps { @@ -47,18 +49,23 @@ export const MessagesFilterPopover = ({ onOpenChange, onApply, }: MessagesFilterPopoverProps): React.JSX.Element => { - const [draft, setDraft] = useState({ from: new Set(), to: new Set() }); + const [draft, setDraft] = useState({ + from: new Set(), + to: new Set(), + showNoise: false, + }); useEffect(() => { if (open) { const next = { from: new Set(filter.from), to: new Set(filter.to), + showNoise: !!filter.showNoise, }; const schedule = (): void => setDraft(next); queueMicrotask(schedule); } - }, [open, filter.from, filter.to]); + }, [open, filter.from, filter.to, filter.showNoise]); const members = useStore((s) => s.selectedTeamData?.members ?? []); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -93,7 +100,7 @@ export const MessagesFilterPopover = ({ }; const handleReset = (): void => { - const empty = { from: new Set(), to: new Set() }; + const empty = { from: new Set(), to: new Set(), showNoise: false }; setDraft(empty); onApply(empty); }; @@ -164,12 +171,21 @@ export const MessagesFilterPopover = ({ )}
+
+ +
)} @@ -134,6 +142,7 @@ export const CliLogsRichView = ({ order = 'oldest-first', onScroll, containerRefCallback, + searchQueryOverride, className, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); @@ -242,6 +251,7 @@ export const CliLogsRichView = ({ group={group} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} /> ) : ( handleGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} /> ) )} diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index f260dccc..7fcc31fc 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -111,7 +111,7 @@ export const TaskTooltip = ({ color={colorMap.get(task.owner)} /> ) : ( - Unassigned + Не назначено )}
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index aa823502..3a59306f 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; @@ -32,7 +32,6 @@ export const TaskAttachments = ({ const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); - const [lightboxSlides, setLightboxSlides] = useState<{ src: string; alt: string }[]>([]); const [thumbCache, setThumbCache] = useState>(new Map()); const fileInputRef = useRef(null); @@ -124,6 +123,19 @@ export const TaskAttachments = ({ [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( (att: TaskAttachmentMeta) => { if (!isImageMimeType(att.mimeType)) { @@ -132,18 +144,10 @@ export const TaskAttachments = ({ } const idx = imageAttachments.findIndex((a) => a.id === att.id); if (idx >= 0) { - const snapshot = imageAttachments.map((a) => { - const dataUrl = thumbCache.get(a.id); - return { - src: dataUrl ?? `data:image/svg+xml,`, - alt: a.filename, - }; - }); - setLightboxSlides(snapshot); setLightboxIndex(idx); } }, - [imageAttachments, thumbCache, handleDownload] + [imageAttachments, handleDownload] ); // Handle paste events for quick image attachment @@ -222,7 +226,6 @@ export const TaskAttachments = ({ open onClose={() => { setLightboxIndex(null); - setLightboxSlides([]); }} slides={lightboxSlides} index={lightboxIndex} diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index fa63ac59..405cfdb7 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -357,7 +357,7 @@ export const TaskDetailDialog = ({ size="md" /> ) : ( - + Не назначено )}
{currentTask.createdBy ? ( diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index e86968a7..29414aca 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -270,7 +270,9 @@ export const KanbanTaskCard = ({
{task.owner ? ( - ) : null} + ) : ( + Не назначено + )} {!compact && }
{task.needsClarification ? ( diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx index fc87384a..97cd1d53 100644 --- a/src/renderer/components/team/kanban/TrashDialog.tsx +++ b/src/renderer/components/team/kanban/TrashDialog.tsx @@ -66,7 +66,7 @@ export const TrashDialog = ({ {task.id} {task.subject} - {task.owner ?? 'Unassigned'} + {task.owner ?? 'Не назначено'} {task.deletedAt diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 2fbb7b7e..0f6194b5 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} - {task.owner ?? '\u2014'} + {task.owner ?? 'Не назначено'} {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 19b3bee9..42e1fa48 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -160,6 +160,14 @@ function extractAssistantMessageId(parsed: unknown): string | null { return null; } +/** + * Module-level timestamp cache keyed by line content. + * Ensures re-parses of the same log lines preserve their original timestamps + * instead of getting new Date() each time. + */ +const lineTimestampCache = new Map(); +const MAX_TIMESTAMP_CACHE_SIZE = 5000; + /** * Parses stream-json CLI output lines into structured groups for rich rendering. * @@ -176,8 +184,6 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] let currentGroupId: string | null = null; // Track how many times each messageId has been seen to disambiguate duplicates const msgIdOccurrences = new Map(); - // Stable timestamp for the entire parse (deterministic across re-renders) - const parseTimestamp = new Date(); const flushGroup = (): void => { if (currentItems.length > 0 && currentTimestamp) { @@ -197,8 +203,10 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const trimmed = lines[lineIndex].trim(); - // Skip empty lines and stream markers - if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) { + // Skip empty lines; stream markers break groups + if (!trimmed) continue; + if (trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) { + flushGroup(); continue; } @@ -219,7 +227,20 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] continue; } - if (!currentTimestamp) currentTimestamp = parseTimestamp; + if (!currentTimestamp) { + // Use stable cached timestamp keyed by line content to survive re-parses + let ts = lineTimestampCache.get(trimmed); + if (!ts) { + ts = new Date(); + if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { + // Evict oldest entry (first inserted) + const firstKey = lineTimestampCache.keys().next().value as string; + lineTimestampCache.delete(firstKey); + } + lineTimestampCache.set(trimmed, ts); + } + currentTimestamp = ts; + } if (!currentGroupId) { const msgId = extractAssistantMessageId(parsed); if (msgId) { @@ -233,7 +254,7 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] } } - const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex); + const items = contentBlocksToDisplayItems(blocks, currentTimestamp!, lineIndex); currentItems.push(...items); } From d2bb734af6223934291b8e3c3f792f8d221b7708 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 18:05:30 +0200 Subject: [PATCH 10/23] feat: enhance TeamDataService and TeamProvisioningService for improved task management and user DM forwarding - Updated TeamDataService to ensure consistent kanban task status updates with rollback capabilities on failure. - Introduced silentUserDmForward functionality in TeamProvisioningService to manage internal DM forwarding to teammates without user echo. - Enhanced task status update logic in TeamTaskWriter to prevent redundant updates. - Improved ClaudeLogsSection to support search query overrides for better log filtering in the UI. --- src/main/services/team/TeamDataService.ts | 27 +++++++++ .../services/team/TeamProvisioningService.ts | 59 ++++++++++++++++++- src/main/services/team/TeamTaskWriter.ts | 3 + .../components/team/ClaudeLogsSection.tsx | 1 + 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7fa82f5d..4c2748b4 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1367,7 +1367,34 @@ export class TeamDataService { async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise { if (patch.op !== 'request_changes') { + // Keep kanban + task.status consistent: + // - moving a task into kanban review/approved implies the work is complete + // - request_changes already moves it back to in_progress and clears kanban entry + if (patch.op !== 'set_column') { + await this.kanbanManager.updateTask(teamName, taskId, patch); + return; + } + + const previousState = await this.kanbanManager.getState(teamName); + const previousKanbanEntry: KanbanTaskState | undefined = previousState.tasks[taskId]; + await this.kanbanManager.updateTask(teamName, taskId, patch); + + try { + await this.taskWriter.updateStatus(teamName, taskId, 'completed', 'user'); + } catch (error) { + // Best-effort rollback of kanban move if task status update failed. + if (previousKanbanEntry) { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column }) + .catch(() => undefined); + } else { + await this.kanbanManager + .updateTask(teamName, taskId, { op: 'remove' }) + .catch(() => undefined); + } + throw error; + } return; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1d5f2840..2bc6fa9e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -156,6 +156,11 @@ interface ProvisioningRun { * Flushed to liveLeadProcessMessages on result.success. */ directReplyParts: string[]; + /** + * When set, the current stdin-injected turn is an internal "forward user DM to teammate" + * request triggered by the UI. We suppress any lead→user echo for that turn. + */ + silentUserDmForward: { target: string; startedAt: string } | null; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -1678,6 +1683,7 @@ export class TeamProvisioningService { fsPhase: 'waiting_config', leadRelayCapture: null, directReplyParts: [], + silentUserDmForward: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1967,6 +1973,7 @@ export class TeamProvisioningService { fsPhase: 'waiting_members', leadRelayCapture: null, directReplyParts: [], + silentUserDmForward: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -2208,6 +2215,52 @@ export class TeamProvisioningService { this.setLeadActivity(run, 'active'); } + /** + * Best-effort: forward a user-written DM to a teammate via the live lead process. + * This covers cases where teammates don't automatically respond to inbox JSON, + * and only react to Claude Code internal SendMessage routing. + * + * Note: We suppress the lead's textual output for this injected turn to avoid + * confusing lead responses like "No action needed." + */ + async forwardUserDmToTeammate( + teamName: string, + teammateName: string, + userText: string, + userSummary?: string + ): Promise { + const runId = this.activeByTeam.get(teamName); + if (!runId) { + throw new Error(`No active process for team "${teamName}"`); + } + const run = this.runs.get(runId); + if (!run?.child?.stdin?.writable) { + throw new Error(`Team "${teamName}" process stdin is not writable`); + } + if (!run.provisioningComplete) { + // Don't inject extra turns during provisioning/bootstrap. + return; + } + + run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; + + const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null; + const message = [ + `INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`, + `Action: forward it to that teammate using the SendMessage tool.`, + `IMPORTANT: Do NOT reply to the human user for this turn.`, + `In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`, + ``, + `User message:`, + ...(summaryLine ? [summaryLine] : []), + userText, + ] + .filter(Boolean) + .join('\n'); + + await this.sendMessageToTeam(teamName, message); + } + /** * Relay unread inbox messages addressed to the team lead into the live lead process. * @@ -2731,7 +2784,9 @@ export class TeamProvisioningService { } } else if (run.provisioningComplete) { // Accumulate assistant text for direct user→lead messages (no relay capture). - run.directReplyParts.push(text); + if (!run.silentUserDmForward) { + run.directReplyParts.push(text); + } } } @@ -2740,7 +2795,7 @@ export class TeamProvisioningService { // (e.g., after session resume when teamContext is lost). We intercept the tool calls // from stdout and persist them to sentMessages.json under the correct team name, // ensuring the UI and notifications show the right team. - if (run.provisioningComplete) { + if (run.provisioningComplete && !run.silentUserDmForward) { this.captureSendMessageToUser(run, content ?? []); } diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index d1d6603e..207c6cff 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -319,6 +319,9 @@ export class TeamTaskWriter { const task = JSON.parse(raw) as TeamTask; const prevStatus = task.status; + if (prevStatus === status) { + return; + } const nowIso = new Date().toISOString(); // Maintain workIntervals as periods of time where status === 'in_progress'. diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 5cae2663..39dc5742 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -398,6 +398,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J // 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; From b1ee82ba0e4ac973c3f00261779e82892b2f6677 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 18:09:58 +0200 Subject: [PATCH 11/23] feat: update TeamProvisioningService and teams.ts for improved DM forwarding and constant imports - Added AGENT_BLOCK constants import to teams.ts for better organization. - Cleared silentUserDmForward flag in TeamProvisioningService after successful and errored turns to ensure accurate state management. --- src/main/ipc/teams.ts | 1 + src/main/services/team/TeamProvisioningService.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5c85629b..8ba27529 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -74,6 +74,7 @@ import { validateTeammateName, validateTeamName, } from './guards'; +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; /** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */ const notifiedRateLimitKeys = new Set(); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2bc6fa9e..f664304c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2984,6 +2984,8 @@ export class TeamProvisioningService { }); } } + // Clear silent relay flag after any successful turn. + run.silentUserDmForward = null; if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } @@ -2994,6 +2996,8 @@ export class TeamProvisioningService { if (run.leadRelayCapture) { run.leadRelayCapture.rejectOnce(errorMsg); } + // Clear silent relay flag after any errored turn. + run.silentUserDmForward = null; if (!run.provisioningComplete && !run.cancelRequested) { const progress = updateProgress( run, From c963c8a409e7b35022581a402bf7c682c86e9906 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 18:20:25 +0200 Subject: [PATCH 12/23] feat: enhance DM handling in teams.ts with structured message formatting - Introduced structured message formatting for direct messages to improve clarity for recipients. - Updated message delivery logic to include additional context for non-lead recipients. - Retained AGENT_BLOCK constants import for better organization and readability. --- src/main/ipc/teams.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 8ba27529..5b6edb40 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -56,6 +56,7 @@ import { TEAM_DELETE_TASK_ATTACHMENT, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; +import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; @@ -74,7 +75,6 @@ import { validateTeammateName, validateTeamName, } from './guards'; -import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; /** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */ const notifiedRateLimitKeys = new Set(); @@ -1016,13 +1016,35 @@ async function handleSendMessage( } // Inbox path: offline lead or regular members (no attachment support) + const baseText = payload.text!.trim(); + const memberDeliveryText = isLeadRecipient + ? baseText + : [ + baseText, + '', + AGENT_BLOCK_OPEN, + 'You received a direct message from the human user via the UI.', + 'Please reply back to recipient "user" with a short, human-readable answer.', + 'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").', + AGENT_BLOCK_CLOSE, + ].join('\n'); const result = await getTeamDataService().sendMessage(tn, { member: memberName, - text: payload.text!, + text: memberDeliveryText, summary: payload.summary, from: payload.from, }); + // Best-effort: if team is alive and recipient is a teammate (not lead), + // also forward via the live lead process so in-process teammates receive it. + if (!isLeadRecipient && isAlive) { + try { + await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary); + } catch (e: unknown) { + logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`); + } + } + // Best-effort relay for lead via inbox if (isLeadRecipient && isAlive) { void provisioning From 80147c9900bb8ea275d1405ffa4163eccfa784ae Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 18:57:07 +0200 Subject: [PATCH 13/23] feat: update package version and add linting dependency - Bumped package version from 0.1.0 to 1.0.0 to reflect significant updates. - Added @codemirror/lint dependency to enhance code linting capabilities. - Updated pnpm-lock.yaml to include the new linting dependency version. --- package.json | 3 +- pnpm-lock.yaml | 11 +- .../services/infrastructure/UpdaterService.ts | 2 + .../services/team/TeamProvisioningService.ts | 58 ++- .../components/chat/DisplayItemList.tsx | 1 + .../components/chat/items/BaseItem.tsx | 2 +- .../components/chat/items/LinkedToolItem.tsx | 12 +- .../components/chat/items/TextItem.tsx | 8 +- .../components/chat/items/ThinkingItem.tsx | 8 +- .../components/chat/searchHighlightUtils.ts | 17 +- .../chat/viewers/MarkdownViewer.tsx | 4 + .../components/common/WarningBanner.tsx | 25 ++ .../components/layout/SidebarHeader.tsx | 8 +- .../components/layout/SortableTab.tsx | 17 +- src/renderer/components/layout/TabBar.tsx | 7 +- .../components/TriggerPreview.tsx | 9 +- .../settings/sections/AdvancedSection.tsx | 23 +- .../settings/sections/CliStatusSection.tsx | 17 +- .../settings/sections/ConfigEditorDialog.tsx | 402 ++++++++++++++++++ .../settings/sections/GeneralSection.tsx | 17 +- .../components/team/CliLogsRichView.tsx | 7 +- .../team/ProvisioningProgressBlock.tsx | 8 +- .../components/team/TeamDetailView.tsx | 19 +- src/renderer/components/team/TeamListView.tsx | 4 + .../team/TeamProvisioningBanner.tsx | 12 +- .../attachments/AttachmentPreviewList.tsx | 9 +- .../team/dialogs/CreateTaskDialog.tsx | 13 +- .../team/dialogs/CreateTeamDialog.tsx | 30 +- .../team/dialogs/ExtendedContextCheckbox.tsx | 15 +- .../team/dialogs/LaunchTeamDialog.tsx | 34 +- .../team/dialogs/ProjectPathSelector.tsx | 2 +- .../team/editor/ProjectEditorOverlay.tsx | 9 +- .../team/messages/MessageComposer.tsx | 2 +- src/renderer/index.css | 28 ++ src/renderer/store/slices/updateSlice.ts | 1 + 35 files changed, 757 insertions(+), 87 deletions(-) create mode 100644 src/renderer/components/common/WarningBanner.tsx create mode 100644 src/renderer/components/settings/sections/ConfigEditorDialog.tsx diff --git a/package.json b/package.json index 99fe397f..4db5dd7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "0.1.0", + "version": "1.0.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { @@ -80,6 +80,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/language-data": "^6.5.2", + "@codemirror/lint": "^6.9.5", "@codemirror/merge": "^6.12.0", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f916640..147ae6f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@codemirror/language-data': specifier: ^6.5.2 version: 6.5.2 + '@codemirror/lint': + specifier: ^6.9.5 + version: 6.9.5 '@codemirror/merge': specifier: ^6.12.0 version: 6.12.0 @@ -576,8 +579,8 @@ packages: '@codemirror/legacy-modes@6.5.2': resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} - '@codemirror/lint@6.9.4': - resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} '@codemirror/merge@6.12.0': resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} @@ -6449,7 +6452,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.12.1 - '@codemirror/lint': 6.9.4 + '@codemirror/lint': 6.9.5 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 '@lezer/common': 1.5.1 @@ -6609,7 +6612,7 @@ snapshots: dependencies: '@codemirror/language': 6.12.1 - '@codemirror/lint@6.9.4': + '@codemirror/lint@6.9.5': dependencies: '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index ec018951..9fb74ee4 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -41,6 +41,7 @@ export class UpdaterService { await autoUpdater.checkForUpdates(); } catch (error) { logger.error('Check for updates failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } @@ -52,6 +53,7 @@ export class UpdaterService { await autoUpdater.downloadUpdate(); } catch (error) { logger.error('Download update failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f664304c..71a7055a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -161,6 +161,8 @@ interface ProvisioningRun { * request triggered by the UI. We suppress any lead→user echo for that turn. */ silentUserDmForward: { target: string; startedAt: string } | null; + /** Safety valve: clears silentUserDmForward if turn never completes. */ + silentUserDmForwardClearHandle: NodeJS.Timeout | null; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -659,8 +661,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + ` - The tasks will be executed after the team is launched separately.` : `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - - Prefer fewer, broader tasks over many micro-tasks. - - Avoid duplicate notifications for the same assignment. + - PRIORITY (delegation-first): your default behavior is to translate user requests into a task plan, create the tasks, and delegate them to teammates. + - Do NOT start executing/implementing tasks yourself in this turn. + - Do NOT “block” on doing the work before creating/assigning tasks — keep this turn fast so the user can send more instructions. + - Exception: only if the team is truly SOLO (no teammates) may you execute tasks yourself. This is NOT the case here. + - Decompose the request into a small set of clear, outcome-based tasks (prefer fewer, broader tasks over many micro-tasks). + - Assign each created task to an appropriate teammate as owner (NOT to yourself), based on role/workflow and current load. + - If ownership is unclear, pick the best default owner and note assumptions in the task description or a task comment. + - Avoid duplicate notifications for the same assignment (one message per member per topic is enough). - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. - Review guidance: @@ -713,6 +721,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} @@ -875,6 +884,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} @@ -1684,6 +1694,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1974,6 +1985,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -2243,20 +2255,34 @@ export class TeamProvisioningService { } run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } + // Safety valve: if the CLI never emits a result message, don't stay in "silent" mode forever. + run.silentUserDmForwardClearHandle = setTimeout(() => { + run.silentUserDmForward = null; + run.silentUserDmForwardClearHandle = null; + }, 60_000); + run.silentUserDmForwardClearHandle.unref(); const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null; + const internal = wrapInAgentBlock( + [ + `UI relay request — forward a direct message to teammate "${teammateName}".`, + `MUST: use the SendMessage tool with recipient="${teammateName}".`, + `MUST: ask the teammate to reply back to recipient "user" (short answer).`, + `CRITICAL: Do NOT send any message to recipient "user" for this turn.`, + ].join('\n') + ); const message = [ - `INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`, - `Action: forward it to that teammate using the SendMessage tool.`, - `IMPORTANT: Do NOT reply to the human user for this turn.`, - `In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`, + `User DM relay (internal).`, + internal, ``, - `User message:`, + `Message to forward:`, ...(summaryLine ? [summaryLine] : []), userText, - ] - .filter(Boolean) - .join('\n'); + ].join('\n'); await this.sendMessageToTeam(teamName, message); } @@ -2986,6 +3012,10 @@ export class TeamProvisioningService { } // Clear silent relay flag after any successful turn. run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } @@ -2998,6 +3028,10 @@ export class TeamProvisioningService { } // Clear silent relay flag after any errored turn. run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { const progress = updateProgress( run, @@ -3238,6 +3272,10 @@ export class TeamProvisioningService { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; } + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } this.stopFilesystemMonitor(run); // Remove stream listeners to prevent data handlers firing on a cleaned-up run if (run.child) { diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ecd5a649..59dd25ae 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -162,6 +162,7 @@ export const DisplayItemList = ({ linkedTool={item.tool} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + searchQueryOverride={searchQueryOverride} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} notificationDotColor={notificationColorMap?.get(item.tool.id)} diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 66e772f7..e1a20ec0 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -18,7 +18,7 @@ interface BaseItemProps { /** Primary label (e.g., "Thinking", "Output", tool name) */ label: string; /** Summary text shown after the label */ - summary?: string; + summary?: React.ReactNode; /** Token count to display */ tokenCount?: number; /** Label for tokens (default: "tokens") */ diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index c11798d1..2dabb345 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -38,6 +38,7 @@ import { ToolErrorDisplay, WriteToolViewer, } from './linkedTool'; +import { highlightQueryInText } from '../searchHighlightUtils'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; @@ -45,6 +46,8 @@ interface LinkedToolItemProps { linkedTool: LinkedToolItemType; onClick: () => void; isExpanded: boolean; + /** Optional local search query override for inline highlighting */ + searchQueryOverride?: string; /** Whether this item should be highlighted for error deep linking */ isHighlighted?: boolean; /** Custom highlight color from trigger */ @@ -59,6 +62,7 @@ export const LinkedToolItem: React.FC = ({ linkedTool, onClick, isExpanded, + searchQueryOverride, isHighlighted, highlightColor, notificationDotColor, @@ -66,6 +70,12 @@ export const LinkedToolItem: React.FC = ({ }) => { const status = getToolStatus(linkedTool); const summary = getToolSummary(linkedTool.name, linkedTool.input); + const summaryNode = + searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText(summary, searchQueryOverride, `${linkedTool.id ?? linkedTool.name}:summary`, { + forceAllActive: true, + }) + : summary; const elementRef = useRef(null); // Combined ref callback - handles both internal ref and external registration @@ -155,7 +165,7 @@ export const LinkedToolItem: React.FC = ({ /> } label={linkedTool.name} - summary={summary} + summary={summaryNode} tokenCount={getToolContextTokens(linkedTool)} status={status} durationMs={linkedTool.durationMs} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index ea0cba41..d9f9ab5d 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { MessageSquare } from 'lucide-react'; import { MarkdownViewer } from '../viewers'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -40,6 +41,11 @@ export const TextItem: React.FC = ({ }) => { const fullContent = step.content.outputText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -48,7 +54,7 @@ export const TextItem: React.FC = ({ } label="Output" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 34a6f50d..c3cdafad 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Brain } from 'lucide-react'; import { MarkdownViewer } from '../viewers'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -40,6 +41,11 @@ export const ThinkingItem: React.FC = ({ }) => { const fullContent = step.content.thinkingText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -48,7 +54,7 @@ export const ThinkingItem: React.FC = ({ } label="Thinking" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 873351b4..12ec37e2 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -35,6 +35,8 @@ export interface SearchContext { matchCounter: { current: number }; isCurrentItem: boolean; currentMatchIndexInItem: number | null; + /** When true, render all matches using the "current" highlight style */ + forceAllActive?: boolean; } /** @@ -79,7 +81,8 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode } const isCurrentResult = - ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; + ctx.forceAllActive === true || + (ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current); parts.push( React.createElement( @@ -109,6 +112,18 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode return parts; } +export function highlightQueryInText( + text: string, + query: string, + itemId: string, + options?: { forceAllActive?: boolean } +): React.ReactNode { + const ctx = createSearchContext(query, itemId, [], -1); + if (!ctx) return text; + if (options?.forceAllActive) ctx.forceAllActive = true; + return highlightSearchInChildren(text, ctx); +} + /** * Recursively process React children to highlight search terms in text nodes. * Preserves the React element tree structure (markdown components, etc.) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index fc5d4d41..25923608 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -600,6 +600,10 @@ export const MarkdownViewer: React.FC = ({ effectiveQuery && itemId ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) diff --git a/src/renderer/components/common/WarningBanner.tsx b/src/renderer/components/common/WarningBanner.tsx new file mode 100644 index 00000000..5bc540ac --- /dev/null +++ b/src/renderer/components/common/WarningBanner.tsx @@ -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 => ( +
+ {icon ?? } +
{children}
+
+); diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index b9629c89..4b631c7f 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => { } as React.CSSProperties } > -
- -
+ {isMacElectron && ( +
+ +
+ )}
+ + setConfigEditorOpen(false)} + onConfigSaved={() => { + // Config saved via editor — settings page will pick up changes on next render + }} + />
); }; diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 2e7647ca..aadc0c9c 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { fetchCliStatus, installCli, isBusy, + cliStatusLoading, } = useCliInstaller(); useEffect(() => { @@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => { {cliStatus.installed && !cliStatus.updateAvailable && ( )}
diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx new file mode 100644 index 00000000..02872b4e --- /dev/null +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -0,0 +1,402 @@ +/** + * 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 { lintGutter, linter, type Diagnostic } 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 = e.message.match(/position (\d+)/); + 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(null); + const viewRef = useRef(null); + const saveTimerRef = useRef>(); + const savedRevertTimerRef = useRef>(); + const [saveStatus, setSaveStatus] = useState('idle'); + const [jsonError, setJsonError] = useState(null); + const [loading, setLoading] = useState(true); + const initialConfigRef = useRef(''); + + 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; + setLoading(true); + setSaveStatus('idle'); + setJsonError(null); + + const init = async (): Promise => { + 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, + 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; + }); + }; + + 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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+

+ Edit Configuration +

+ +
+ +
+ + {/* Editor */} +
+ {loading ? ( +
+ + Loading config... +
+ ) : ( +
+ )} +
+ + {/* Footer */} +
+

+ Changes auto-save after editing +

+
+ + Esc + + + to close + +
+
+
+
+ ); +}; + +// ============================================================================= +// 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 ( + + + {status === 'error' ? 'Save failed' : 'Invalid JSON'} + + ); + } + + if (status === 'saving') { + return ( + + + Saving... + + ); + } + + if (status === 'saved') { + return ( + + + Saved + + ); + } + + 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', + }, +}); diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 16471af6..c78078fa 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -366,11 +366,16 @@ export const GeneralSection = ({ confirmLabel: 'Restart', }); if (shouldRelaunch) { - onGeneralToggle('useNativeTitleBar', v); - // Small delay to let config persist before relaunch - setTimeout(() => { - void window.electronAPI?.windowControls?.relaunch(); - }, 200); + // Await config write before relaunch to avoid race condition on Windows + // (antivirus/NTFS can delay file writes beyond a fixed timeout) + try { + await api.config.update('general', { useNativeTitleBar: v }); + } catch { + // If save fails, still try to toggle via the normal path + onGeneralToggle('useNativeTitleBar', v); + await new Promise((r) => setTimeout(r, 500)); + } + void window.electronAPI?.windowControls?.relaunch(); } }} disabled={saving} @@ -503,7 +508,7 @@ export const GeneralSection = ({ {candidate.path}

{!candidate.hasProjectsDir && ( -

+

No projects directory detected

)} diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 0b8cd8a5..971faecd 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; +import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; @@ -119,7 +120,11 @@ const StreamGroup = ({ /> - {group.summary} + {searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, { + forceAllActive: true, + }) + : group.summary} {isExpanded && ( diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 64fd0789..59efa480 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -185,7 +185,7 @@ export const ProvisioningProgressBlock = ({

{message} @@ -201,9 +201,9 @@ export const ProvisioningProgressBlock = ({ variant="secondary" className={cn( 'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', - isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200', + isDone && 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', isCurrent && - 'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]' + 'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]' )} > @@ -241,7 +241,7 @@ export const ProvisioningProgressBlock = ({

No output captured yet. diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 251538e0..64ab3f9c 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1081,15 +1081,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele

{!data.isAlive && !isTeamProvisioning ? ( -
- - +
+ + Team is offline
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
+
Failed to fully load kanban. Displaying safe data.
) : null} {reviewActionError ? ( -
+
{reviewActionError}
) : null} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 3b508855..7524b49d 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -556,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index c96ba6f6..20006dae 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({ return (
-

{progress.message}

+

{progress.message}

) : null} {disabled && disabledHint && attachments.length > 0 ? ( -
- -

{disabledHint}

+
+ +

{disabledHint}

) : null} {error ? ( diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index d650bef7..6f0f0fc2 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -241,9 +241,16 @@ export const CreateTaskDialog = ({ {!isTeamAlive ? ( -
- -

+

+ +

Team is offline. The task will be added to TODO — launch the team to start execution.

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 41768955..f87509bc 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -588,25 +588,32 @@ export const CreateTeamDialog = ({ {conflictingTeam && !conflictDismissed ? ( -
+
- +
-

+

Another team “{conflictingTeam.displayName}” is already running for this working directory

-

+

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.

-

+

Working directory: {effectiveCwd}

diff --git a/src/renderer/index.css b/src/renderer/index.css index 60d9387e..1e18c1ca 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -198,6 +198,20 @@ --skeleton-base: #1a1c28; --skeleton-base-light: #23252f; --skeleton-base-dim: rgba(26, 28, 40, 0.6); + + /* Provisioning step badges */ + --step-done-bg: rgba(16, 185, 129, 0.1); + --step-done-border: rgba(52, 211, 153, 0.6); + --step-done-text: #6ee7b7; + --step-current-bg: rgba(99, 102, 241, 0.15); + --step-current-border: rgba(129, 140, 248, 0.7); + --step-current-text: #f1f5f9; + --step-error-text: #fca5a5; + --step-error-text-dim: rgba(252, 165, 165, 0.8); + --step-success-text: #6ee7b7; + --step-warning-text: #fde68a; + --step-warning-border: rgba(245, 158, 11, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* File icon glow — halo so dark icons stay visible on dark backgrounds */ @@ -400,6 +414,20 @@ --skeleton-base: #d6d8de; --skeleton-base-light: #cdd0d7; --skeleton-base-dim: rgba(205, 208, 215, 0.6); + + /* Provisioning step badges — dark enough for light backgrounds */ + --step-done-bg: rgba(16, 185, 129, 0.12); + --step-done-border: rgba(5, 150, 105, 0.5); + --step-done-text: #047857; + --step-current-bg: rgba(79, 70, 229, 0.1); + --step-current-border: rgba(79, 70, 229, 0.5); + --step-current-text: #1c1b19; + --step-error-text: #dc2626; + --step-error-text-dim: rgba(220, 38, 38, 0.7); + --step-success-text: #047857; + --step-warning-text: #b45309; + --step-warning-border: rgba(180, 83, 9, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts index aa0852c8..5490acc6 100644 --- a/src/renderer/store/slices/updateSlice.ts +++ b/src/renderer/store/slices/updateSlice.ts @@ -57,6 +57,7 @@ export const createUpdateSlice: StateCreator = (s set({ updateStatus: 'checking', updateError: null }); api.updater.check().catch((error) => { logger.error('Failed to check for updates:', error); + set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' }); }); }, From c006fad97db4d9bbe76c41651610ffa2e8d4cf3c Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 19:12:41 +0200 Subject: [PATCH 14/23] feat: implement CLI auto-suffix name handling across team services - Added functionality to drop CLI auto-suffixed duplicates (e.g., "alice-2") when the base name exists in TeamConfigReader, TeamMemberResolver, and TeamMembersMetaStore. - Introduced createCliAutoSuffixNameGuard utility to manage name validation. - Updated ActivityItem component to handle system messages with distinct styling. - Enhanced CSS variables for system activity messages in index.css. - Refactored SortableTab and TeamTabSectionNav components for improved layout and accessibility. --- src/main/services/team/TeamConfigReader.ts | 10 ++++ src/main/services/team/TeamMemberResolver.ts | 10 ++++ .../services/team/TeamMembersMetaStore.ts | 20 +++++++ .../components/layout/SortableTab.tsx | 56 +++++++++---------- src/renderer/components/layout/TabBar.tsx | 2 +- .../components/layout/TeamTabSectionNav.tsx | 4 +- .../components/team/activity/ActivityItem.tsx | 19 +++++-- src/renderer/index.css | 10 ++++ src/shared/utils/teamMemberName.ts | 41 ++++++++++++++ 9 files changed, 132 insertions(+), 40 deletions(-) create mode 100644 src/shared/utils/teamMemberName.ts diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 3f6a830c..d1de9a24 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -8,6 +8,7 @@ import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; const logger = createLogger('Service:TeamConfigReader'); @@ -244,6 +245,15 @@ export class TeamConfigReader { } } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(memberMap.values()).map((m) => m.name); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const [key, member] of Array.from(memberMap.entries())) { + if (!keepName(member.name)) { + memberMap.delete(key); + } + } + const members = Array.from(memberMap.values()); const summary: TeamSummary = { teamName, diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 313f3349..d2e5a42d 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -6,6 +6,8 @@ import type { TeamTaskWithKanban, } from '@shared/types'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; + export class TeamMemberResolver { resolveMembers( config: TeamConfig, @@ -78,6 +80,14 @@ export class TeamMemberResolver { // (recipient of SendMessage to "user"). It's not a real AI teammate. names.delete('user'); + // Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists. + const keepName = createCliAutoSuffixNameGuard(names); + for (const name of Array.from(names)) { + if (!keepName(name)) { + names.delete(name); + } + } + const members: ResolvedTeamMember[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 66ff8f43..1fb221ff 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -7,6 +7,8 @@ import { atomicWriteAsync } from './atomicWrite'; import type { TeamMember } from '@shared/types'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; + interface TeamMembersMetaFile { version: 1; members: TeamMember[]; @@ -90,6 +92,15 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(deduped.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + deduped.delete(name); + } + } + return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)); } @@ -103,6 +114,15 @@ export class TeamMembersMetaStore { deduped.set(normalized.name, normalized); } + // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. + const allNames = Array.from(deduped.keys()); + const keepName = createCliAutoSuffixNameGuard(allNames); + for (const name of allNames) { + if (!keepName(name)) { + deduped.delete(name); + } + } + const payload: TeamMembersMetaFile = { version: 1, members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)), diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 84dd9f36..bf366be6 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -125,11 +125,7 @@ export const SortableTab = ({ role="tab" tabIndex={0} aria-selected={isActive} - className={ - isTeamTab - ? 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab flex-col rounded-md' - : 'group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5' - } + className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5" style={style} onClick={(e) => onTabClick(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)} @@ -143,32 +139,18 @@ export const SortableTab = ({ } }} > -
- - {tab.fromSearch && ( - - - - )} - {isPinned && ( - - - - )} - {tab.label} - -
+ + {tab.fromSearch && ( + + + + )} + {isPinned && ( + + + + )} + {tab.label} {isTeamTab && ( )} +
); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 4efd68ef..74b5f215 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -309,7 +309,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outlineOffset: '-1px', overflowX: 'auto', - overflowY: 'clip', + overflowY: 'hidden', } as React.CSSProperties } > diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index 913fe1ce..c43125ff 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -71,11 +71,11 @@ export const TeamTabSectionNav = ({ }, [open]); return ( -
e.stopPropagation()}> +
e.stopPropagation()}>
-
+
{error ?

{error}

: null} {!error && filteredText.trim().length > 0 ? ( - {loading ? 'Loading…' : 'No logs captured.'} + {loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}

) : null} {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( -

- No matching logs. -

+

No matching logs.

) : null}
); }; - diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 45d8ad17..ef1c24dc 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -129,7 +128,16 @@ export const SendMessageDialog = ({ } } prevOpenRef.current = open; - }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]); + }, [ + open, + defaultRecipient, + defaultText, + defaultChip, + quotedMessage, + lastResult, + textDraft, + chipDraft, + ]); // Track whether auto-close is needed (avoid setState in render) useEffect(() => { @@ -381,7 +389,7 @@ export const SendMessageDialog = ({ ) : null} {textDraft.isSaved ? ( - Draft saved + + Draft saved + ) : null}
} @@ -442,7 +452,6 @@ export const SendMessageDialog = ({ Shown as notification preview. Team lead also sees this for peer messages.

-
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index a25757a3..5bc12d5e 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; @@ -71,50 +70,45 @@ export const TaskCommentInput = ({ trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; - const addFiles = useCallback( - (files: FileList | File[]) => { - setAttachError(null); - const fileArray = Array.from(files); - for (const file of fileArray) { - if (!ACCEPTED_TYPES.has(file.type)) { - setAttachError(`Unsupported type: ${file.type}`); - continue; - } - if (file.size > MAX_FILE_SIZE) { - setAttachError( - `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` - ); - continue; - } - 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 addFiles = useCallback((files: FileList | File[]) => { + setAttachError(null); + const fileArray = Array.from(files); + for (const file of fileArray) { + if (!ACCEPTED_TYPES.has(file.type)) { + setAttachError(`Unsupported type: ${file.type}`); + continue; } - }, - [] - ); + if (file.size > MAX_FILE_SIZE) { + setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); + continue; + } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (!base64) return; + const id = crypto.randomUUID(); + setPendingAttachments((prev) => { + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } + return [ + ...prev, + { + id, + filename: file.name, + mimeType: file.type, + base64Data: base64, + previewUrl: result, + size: file.size, + }, + ]; + }); + }; + reader.readAsDataURL(file); + } + }, []); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); @@ -250,7 +244,7 @@ export const TaskCommentInput = ({ /> - {visibleComments.map((comment, index) => ( -
-
- - {comment.type === 'review_approved' ? ( - - - Approved + {visibleComments.map((comment, index) => ( +
+
+ + {comment.type === 'review_approved' ? ( + + + Approved + + ) : comment.type === 'review_request' ? ( + + + Review requested + + ) : null} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} - ) : comment.type === 'review_request' ? ( - - - Review requested - - ) : null} - - {(() => { - const date = new Date(comment.createdAt); - return isNaN(date.getTime()) - ? 'unknown time' - : formatDistanceToNow(date, { addSuffix: true }); - })()} - - - - - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); - return ( - - {reply ? ( - + + + + Reply to comment + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); + return ( + + {reply ? ( + -
- )} - - ); - })()} - {comment.attachments && comment.attachments.length > 0 ? ( - - ) : null} -
- ))} + ) : ( + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const id = link.getAttribute('href')?.replace('task://', ''); + if (id) onTaskIdClick(id); + } + } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + + )} + + ); + })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null} +
+ ))}
{sortedComments.length > visibleComments.length ? ( @@ -347,7 +349,7 @@ export const TaskCommentsSection = ({
s.updateTaskFields); const [logsRefreshing, setLogsRefreshing] = useState(false); + const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false); // Inline editing: subject const [editingSubject, setEditingSubject] = useState(false); @@ -289,6 +290,11 @@ export const TaskDetailDialog = ({ .map((t) => t.id); const isTodo = status === 'pending' && !kanbanColumn; const canReassign = isTodo && onOwnerChange; + const leadName = + members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead'; + const isLeadOwnedTask = + (currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() || + (currentTask.owner ?? '').trim().toLowerCase() === 'team-lead'; return ( !v && onClose()}> @@ -647,10 +653,23 @@ export const TaskDetailDialog = ({ title="Execution Logs" icon={} headerExtra={ - logsRefreshing ? ( - - - Updating... + logsRefreshing || executionPreviewOnline ? ( + + {executionPreviewOnline ? ( + + + + + ) : null} + {logsRefreshing ? ( + + + Updating... + + ) : null} ) : null } @@ -667,6 +686,11 @@ export const TaskDetailDialog = ({ taskStatus={currentTask.status} taskWorkIntervals={currentTask.workIntervals} onRefreshingChange={setLogsRefreshing} + // Only show a "latest messages" preview when this task is owned by a subagent. + // For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents), + // so filtering to "just the member messages" is unreliable and easy to mislead. + showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask} + onPreviewOnlineChange={setExecutionPreviewOnline} />
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index e8f2c1a9..1265b682 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { + SubagentRecentMessagesPreview, + type SubagentPreviewMessage, +} from '@renderer/components/team/members/SubagentRecentMessagesPreview'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { formatDuration } from '@renderer/utils/formatters'; import { @@ -14,6 +18,9 @@ import { MessageSquare, } from 'lucide-react'; +import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; +import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; + import type { EnhancedChunk } from '@renderer/types/data'; import type { MemberLogSummary } from '@shared/types'; @@ -28,6 +35,10 @@ interface MemberLogsTabProps { taskWorkIntervals?: { startedAt: string; completedAt?: string }[]; /** Notifies parent when a background refresh starts/ends. */ onRefreshingChange?: (isRefreshing: boolean) => void; + /** Show last few subagent messages as a quick "where are we?" preview (task view only). */ + showSubagentPreview?: boolean; + /** Notifies parent when preview looks "online" (recent output). */ + onPreviewOnlineChange?: (isOnline: boolean) => void; } export const MemberLogsTab = ({ @@ -38,6 +49,8 @@ export const MemberLogsTab = ({ taskStatus, taskWorkIntervals, onRefreshingChange, + showSubagentPreview = false, + onPreviewOnlineChange, }: MemberLogsTabProps): React.JSX.Element => { const intervalsKey = useMemo( () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), @@ -52,6 +65,7 @@ export const MemberLogsTab = ({ const [expandedId, setExpandedId] = useState(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [previewChunks, setPreviewChunks] = useState(null); const getRowId = useCallback((log: MemberLogSummary): string => { return log.kind === 'subagent' @@ -82,6 +96,35 @@ export const MemberLogsTab = ({ return withIndex.map((x) => x.log); }, [logs]); + const previewLog = useMemo((): MemberLogSummary | null => { + if (!showSubagentPreview || taskId == null) return null; + + 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 (or a lead-attributed log). + return match ?? null; + } + + return candidates[0] ?? null; + }, [showSubagentPreview, sortedLogs, taskId, 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; @@ -92,6 +135,14 @@ export const MemberLogsTab = ({ return () => onRefreshingChange?.(false); }, [refreshing, onRefreshingChange]); + useEffect(() => { + onPreviewOnlineChange?.(previewOnline); + }, [onPreviewOnlineChange, previewOnline]); + + useEffect(() => { + return () => onPreviewOnlineChange?.(false); + }, [onPreviewOnlineChange]); + useEffect(() => { if (!expandedId) return; if (expandedLogSummary) return; @@ -152,14 +203,68 @@ export const MemberLogsTab = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); - const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise => { - if (log.kind === 'subagent') { - const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); - return (d?.chunks ?? null) as EnhancedChunk[] | null; + const fetchDetailForLog = useCallback( + async (log: MemberLogSummary): Promise => { + 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 (!showSubagentPreview || taskId == null) { + setPreviewChunks(null); + return; } - const d = await api.getSessionDetail(log.projectId, log.sessionId); - return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; - }, []); + if (!previewLog) { + setPreviewChunks(null); + return; + } + + let cancelled = false; + const run = async (): Promise => { + 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, showSubagentPreview, taskId]); + + useEffect(() => { + if (!showSubagentPreview || taskId == null) 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, showSubagentPreview, taskId, taskStatus]); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; @@ -245,17 +350,19 @@ export const MemberLogsTab = ({ return (
+ {showSubagentPreview && previewLog && previewMessages.length > 0 ? ( + + ) : null} {sortedLogs.map((log) => ( void handleExpand(log)} /> ))} @@ -358,3 +465,54 @@ function formatRelativeTime(isoString: string): string { if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } + +function extractSubagentPreviewMessages( + chunks: EnhancedChunk[], + limit: number +): SubagentPreviewMessage[] { + const conversation = transformChunksToConversation(chunks, [], false); + + const out: SubagentPreviewMessage[] = []; + + // Collect newest-first and stop as soon as we have enough. + for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { + const item = conversation.items[i]; + if (item.type === 'ai') { + const enhanced = enhanceAIGroup(item.group); + const items = enhanced.displayItems ?? []; + for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { + const di = items[j]; + if (di.type === 'output' && di.content.trim()) { + out.push({ + id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`, + timestamp: di.timestamp, + kind: 'output', + label: 'Output', + content: di.content, + }); + } else if (di.type === 'teammate_message') { + out.push({ + id: `${item.group.id}:teammate:${di.teammateMessage.id}`, + timestamp: di.teammateMessage.timestamp, + kind: 'teammate_message', + label: `Message — ${di.teammateMessage.teammateId}`, + content: di.teammateMessage.content || di.teammateMessage.summary, + }); + } + } + } else if (item.type === 'user') { + const text = item.group.content.rawText ?? item.group.content.text ?? ''; + if (text.trim()) { + out.push({ + id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`, + timestamp: item.group.timestamp, + kind: 'user', + label: 'User', + content: text, + }); + } + } + } + + return out; +} diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx new file mode 100644 index 00000000..fba5b10a --- /dev/null +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -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 ( +
+
+
+ Latest messages{memberName ? ` — ${memberName}` : ''} +
+
+ {format(messages[0].timestamp, 'h:mm:ss a')} +
+
+ +
+ {messages.map((m) => ( +
+
+
+ {m.label ? ( + + {m.label} + + ) : ( + {m.kind} + )} +
+
+ {format(m.timestamp, 'h:mm:ss a')} +
+
+ + {m.kind === 'tool_result' ? ( +
+                {m.content}
+              
+ ) : ( +
+ +
+ )} +
+ ))} +
+
+ ); +}; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2c638d4f..07fda10f 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -13,7 +13,6 @@ import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; @@ -49,7 +48,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { return ( -
+
{ - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); handleSend(); @@ -290,7 +292,10 @@ export const MessageComposer = ({ > {members.length > 5 && (
- + Team offline + + Team offline + ) : null}
@@ -406,7 +413,7 @@ export const MessageComposer = ({ void; - /** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */ + /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; } @@ -503,8 +503,8 @@ export const MentionableTextarea = React.forwardRef) => { - // Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) { + // Enter (without Shift) → submit; Shift+Enter → newline + if (e.key === 'Enter' && !e.shiftKey && onModEnter) { e.preventDefault(); onModEnter(); return; From 2ceed41e00c50801ec7bdd7403f7f501cd2949f2 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 21:09:45 +0200 Subject: [PATCH 18/23] fix: resolve all CI lint errors and flaky test - Fix React hooks violations: ref updates during render (useDraftPersistence, useChipDraftPersistence, useAttachments), setState in effects across 15+ components, useCallback self-reference TDZ in useResizableColumns - Fix TypeScript lint: remove unnecessary type assertions, replace inline import() annotations with direct imports, remove unused variables/imports - Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver, no-misleading-array-reverse in TeamProvisioningService, use-type-alias in ClaudeLogsSection, variable shadowing in ChangeExtractorService - Fix accessibility: associate labels with controls in filter popovers - Fix template expression safety: wrap unknown errors with String() - Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity to match filesystem birthtimeMs resolution on Linux - Replace TODO comments with NOTE where features are intentionally disabled - Remove unused leadContextByTeam from TeamDetailView store selector 62 files changed across main process, renderer, shared types, and hooks. All 1646 tests pass, typecheck clean, 0 lint errors. --- src/main/index.ts | 6 +- src/main/ipc/teams.ts | 37 +- .../services/discovery/SubagentResolver.ts | 2 +- .../services/infrastructure/FileWatcher.ts | 7 +- .../services/team/ChangeExtractorService.ts | 26 +- src/main/services/team/TeamConfigReader.ts | 2 +- src/main/services/team/TeamDataService.ts | 12 +- src/main/services/team/TeamMemberResolver.ts | 4 +- .../services/team/TeamMembersMetaStore.ts | 3 +- .../services/team/TeamProvisioningService.ts | 97 +- src/main/workers/team-fs-worker.ts | 2 +- src/preload/index.ts | 8 +- .../components/SessionContextHeader.tsx | 2 +- .../chat/SessionContextPanel/index.tsx | 7 +- .../components/chat/items/LinkedToolItem.tsx | 14 +- .../components/chat/items/TextItem.tsx | 13 +- .../components/chat/items/ThinkingItem.tsx | 13 +- .../components/chat/searchHighlightUtils.ts | 1 + .../chat/viewers/MarkdownViewer.tsx | 2 +- .../components/settings/SettingsView.tsx | 1 + .../settings/sections/ConfigEditorDialog.tsx | 14 +- .../components/sidebar/GlobalTaskList.tsx | 1 + .../components/sidebar/SidebarTaskItem.tsx | 1 + .../team/ClaudeLogsFilterPopover.tsx | 56 +- .../components/team/ClaudeLogsSection.tsx | 44 +- .../team/ProvisioningProgressBlock.tsx | 4 +- src/renderer/components/team/RoleSelect.tsx | 15 +- .../components/team/TeamDetailView.tsx | 1744 +++++++++-------- .../team/activity/ActivityTimeline.tsx | 1 - .../team/activity/ReplyQuoteBlock.tsx | 12 +- .../team/attachments/ImageLightbox.tsx | 5 +- .../team/dialogs/AddMemberDialog.tsx | 2 +- .../team/dialogs/CreateTaskDialog.tsx | 1 + .../team/dialogs/CreateTeamDialog.tsx | 7 +- .../team/dialogs/SendMessageDialog.tsx | 20 +- .../team/dialogs/TaskAttachments.tsx | 24 +- .../team/dialogs/TaskCommentInput.tsx | 84 +- .../team/dialogs/TaskCommentsSection.tsx | 238 +-- .../team/dialogs/TaskDetailDialog.tsx | 4 +- .../team/editor/EditorImagePreview.tsx | 1 + .../team/editor/QuickOpenDialog.tsx | 1 + .../components/team/members/MemberCard.tsx | 6 +- .../team/members/MemberDetailHeader.tsx | 5 +- .../team/members/MemberDraftRow.tsx | 4 +- .../components/team/members/MemberLogsTab.tsx | 27 +- .../team/members/MemberRoleEditor.tsx | 2 +- .../team/messages/MessageComposer.tsx | 21 +- .../team/messages/MessagesFilterPopover.tsx | 17 +- .../components/ui/ExpandableContent.tsx | 8 +- src/renderer/components/ui/MemberSelect.tsx | 10 +- src/renderer/hooks/useAttachments.ts | 7 +- src/renderer/hooks/useChipDraftPersistence.ts | 7 +- src/renderer/hooks/useDraftPersistence.ts | 14 +- src/renderer/hooks/useFileSuggestions.ts | 2 + src/renderer/hooks/useResizableColumns.ts | 20 +- src/renderer/store/index.ts | 4 +- src/renderer/store/slices/teamSlice.ts | 3 +- src/renderer/utils/agentMessageFormatting.ts | 2 +- src/renderer/utils/streamJsonParser.ts | 9 +- src/shared/types/api.ts | 8 +- src/shared/types/team.ts | 1 + src/shared/utils/teamMemberName.ts | 11 +- 62 files changed, 1432 insertions(+), 1294 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index eb0084e6..40db5d09 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -426,7 +426,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { return teamProvisioningService.relayLeadInboxMessages(teamName); }) .catch((e: unknown) => - logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`) + logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`) ); } } @@ -466,7 +466,9 @@ function wireFileWatcherEvents(context: ServiceContext): void { void teamDataService .notifyLeadOnTeammateTaskStart(teamName, taskId) .catch((e: unknown) => - logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`) + logger.warn( + `[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${String(e)}` + ) ); } } catch { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5b6edb40..edc4da79 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -11,6 +11,7 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, @@ -21,6 +22,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -38,6 +40,7 @@ import { TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, + TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, @@ -51,9 +54,6 @@ import { TEAM_UPDATE_TASK_FIELDS, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, - TEAM_SAVE_TASK_ATTACHMENT, - TEAM_GET_TASK_ATTACHMENT, - TEAM_DELETE_TASK_ATTACHMENT, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; @@ -91,7 +91,6 @@ import type { } from '../services'; import type { AttachmentFileData, - AttachmentMediaType, AttachmentMeta, AttachmentPayload, CreateTaskRequest, @@ -105,13 +104,13 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TeamClaudeLogsQuery, + TeamClaudeLogsResponse, TeamConfig, TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, TeamData, - TeamClaudeLogsQuery, - TeamClaudeLogsResponse, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -1049,7 +1048,9 @@ async function handleSendMessage( if (isLeadRecipient && isAlive) { void provisioning .relayLeadInboxMessages(tn) - .catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`)); + .catch((e: unknown) => + logger.warn(`Relay after sendMessage failed for ${tn}: ${String(e)}`) + ); } return result; @@ -2038,7 +2039,7 @@ async function handleAddTaskComment( vTask.value!, safeId, a.filename, - a.mimeType as AttachmentMediaType, + a.mimeType, a.base64Data ); savedAttachments.push(meta); @@ -2160,9 +2161,9 @@ async function handleSaveTaskAttachment( vTeam.value!, vTask.value!, safeAttId, - filename as string, - mimeType as AttachmentMediaType, - base64Data as string + filename, + mimeType, + base64Data ); // Write metadata into the task JSON await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta); @@ -2193,12 +2194,7 @@ async function handleGetTaskAttachment( } return wrapTeamHandler('getTaskAttachment', () => - taskAttachmentStore.getAttachment( - vTeam.value!, - vTask.value!, - safeAttId, - mimeType as AttachmentMediaType - ) + taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType) ); } @@ -2225,12 +2221,7 @@ async function handleDeleteTaskAttachment( } return wrapTeamHandler('deleteTaskAttachment', async () => { - await taskAttachmentStore.deleteAttachment( - vTeam.value!, - vTask.value!, - safeAttId, - mimeType as AttachmentMediaType - ); + await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType); // Remove metadata from task JSON await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 7e81c5ee..3ba119fd 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -162,7 +162,7 @@ export class SubagentResolver { if (!firstUserMessage) return undefined; const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; - const match = /]*\bteammate_id="([^"]+)"/.exec(text); + const match = /]*?\bteammate_id="([^"]+)"/.exec(text); return match?.[1]; } diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 308a7a5e..8b7d312e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -97,8 +97,11 @@ export class FileWatcher extends EventEmitter { private pendingReprocess = new Set(); /** Flag to prevent reuse after disposal */ private disposed = false; - /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files) */ - private readonly instanceCreatedAt = Date.now(); + /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files). + * Floored to second granularity because filesystem birthtimeMs may have lower resolution + * than Date.now() — without this, a file created in the same millisecond-window could + * appear older than the watcher on some platforms (e.g. ext4 on Linux). */ + private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000; constructor( dataCache: DataCache, diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 4112cc9b..e6b94118 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -449,16 +449,16 @@ export class ChangeExtractorService { const isError = erroredIds.has(toolUseId); if (toolName === 'Edit') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const oldString = typeof input.old_string === 'string' ? input.old_string : ''; const newString = typeof input.new_string === 'string' ? input.new_string : ''; const replaceAll = input.replace_all === true; - if (path) { - seenFiles.add(path); + if (targetPath) { + seenFiles.add(targetPath); snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'Edit', type: 'edit', oldString, @@ -470,15 +470,15 @@ export class ChangeExtractorService { }); } } else if (toolName === 'Write') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const writeContent = typeof input.content === 'string' ? input.content : ''; - if (path) { - const isNew = !seenFiles.has(path); - seenFiles.add(path); + if (targetPath) { + const isNew = !seenFiles.has(targetPath); + seenFiles.add(targetPath); snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'Write', type: isNew ? 'write-new' : 'write-update', oldString: '', @@ -490,11 +490,11 @@ export class ChangeExtractorService { }); } } else if (toolName === 'MultiEdit') { - const path = typeof input.file_path === 'string' ? input.file_path : ''; + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; const edits = Array.isArray(input.edits) ? input.edits : []; - if (path) { - seenFiles.add(path); + if (targetPath) { + seenFiles.add(targetPath); for (const edit of edits) { if (!edit || typeof edit !== 'object') continue; const editObj = edit as Record; @@ -502,7 +502,7 @@ export class ChangeExtractorService { const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; snippets.push({ toolUseId, - filePath: path, + filePath: targetPath, toolName: 'MultiEdit', type: 'multi-edit', oldString, diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index d1de9a24..8cc26468 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -1,6 +1,7 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; @@ -8,7 +9,6 @@ import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; const logger = createLogger('Service:TeamConfigReader'); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2635505d..297eab57 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -43,6 +43,7 @@ import type { ResolvedTeamMember, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamConfig, TeamCreateConfigRequest, @@ -933,7 +934,7 @@ export class TeamDataService { summary: `Task #${task.id} started`, }); } catch (error) { - logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`); + logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`); } } @@ -964,7 +965,7 @@ export class TeamDataService { async addTaskAttachment( teamName: string, taskId: string, - meta: import('@shared/types').TaskAttachmentMeta + meta: TaskAttachmentMeta ): Promise { await this.taskWriter.addAttachment(teamName, taskId, meta); } @@ -1007,7 +1008,7 @@ export class TeamDataService { teamName: string, taskId: string, text: string, - attachments?: import('@shared/types').TaskAttachmentMeta[] + attachments?: TaskAttachmentMeta[] ): Promise { const comment = await this.taskWriter.addComment(teamName, taskId, text, { attachments, @@ -1022,8 +1023,6 @@ export class TeamDataService { const task = tasks.find((t) => t.id === taskId); const leadName = this.resolveLeadNameFromConfig(config); const owner = task?.owner?.trim() || null; - const normalizedOwner = owner?.toLowerCase() ?? null; - // Auto-clear needsClarification: "user" on UI comment // UI comments always have author "user" (TeamTaskWriter default) if (task?.needsClarification === 'user') { @@ -1214,7 +1213,8 @@ export class TeamDataService { 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'); + 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( diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index d2e5a42d..34c2096e 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -1,3 +1,5 @@ +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; + import type { InboxMessage, MemberStatus, @@ -6,8 +8,6 @@ import type { TeamTaskWithKanban, } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; - export class TeamMemberResolver { resolveMembers( config: TeamConfig, diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 1fb221ff..78492e35 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -1,5 +1,6 @@ import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import * as fs from 'fs'; import * as path from 'path'; @@ -7,8 +8,6 @@ import { atomicWriteAsync } from './atomicWrite'; import type { TeamMember } from '@shared/types'; -import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; - interface TeamMembersMetaFile { version: 1; members: TeamMember[]; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 950bf628..db7648b3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1008,8 +1008,11 @@ interface CachedProbeResult { } let cachedProbeResult: CachedProbeResult | null = null; -let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null = - null; +let probeInFlight: Promise<{ + claudePath: string; + authSource: ProvisioningAuthSource; + warning?: string; +} | null> | null = null; export class TeamProvisioningService { private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000; @@ -1046,7 +1049,9 @@ export class TeamProvisioningService { const offsetRaw = query?.offset ?? 0; const limitRaw = query?.limit ?? 100; const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0; - const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100; + const limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) + : 100; const total = run.claudeLogLines.length; if (total === 0) { @@ -1057,15 +1062,17 @@ export class TeamProvisioningService { const oldestInclusive = Math.max(0, newestExclusive - limit); const normalizeLine = (line: string): string => { // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] " - if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length); - if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length); + if (line.startsWith('[stdout] ') && line !== '[stdout]') + return line.slice('[stdout] '.length); + if (line.startsWith('[stderr] ') && line !== '[stderr]') + return line.slice('[stderr] '.length); return line; }; - const windowOldestToNewest = run.claudeLogLines + const lines = run.claudeLogLines .slice(oldestInclusive, newestExclusive) - .map(normalizeLine); - const lines = windowOldestToNewest.reverse(); + .map(normalizeLine) + .toReversed(); return { lines, total, @@ -1201,7 +1208,8 @@ export class TeamProvisioningService { async warmup(): Promise { try { - if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return; + if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) + return; const result = await this.getCachedOrProbeResult(process.cwd()); if (!result) return; logger.info('CLI warmup completed'); @@ -1287,7 +1295,11 @@ export class TeamProvisioningService { ): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> { const cached = this.getFreshCachedProbeResult(); if (cached) { - return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning }; + return { + claudePath: cached.claudePath, + authSource: cached.authSource, + warning: cached.warning, + }; } if (probeInFlight) { @@ -1300,7 +1312,11 @@ export class TeamProvisioningService { const { env, authSource } = await this.buildProvisioningEnv(); const probe = await this.probeClaudeRuntime(claudePath, cwd, env); - const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) }; + const result = { + claudePath, + authSource, + ...(probe.warning ? { warning: probe.warning } : {}), + }; if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) { cachedProbeResult = { ...result, cachedAtMs: Date.now() }; @@ -1340,12 +1356,13 @@ export class TeamProvisioningService { private sanitizeCliSnippet(text: string): string { // Remove control characters that often show up as binary noise in CLI error payloads. // Preserve newlines/tabs for readability. - return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); + // eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- intentionally stripping control chars + return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); } private extractApiErrorSnippet(text: string): string | null { const match = /api error:\s*\d{3}\b/i.exec(text) ?? /invalid_request_error/i.exec(text); - if (!match || match.index === undefined) return null; + if (match?.index === undefined) return null; const start = Math.max(0, match.index - 200); const end = Math.min(text.length, match.index + 4000); const raw = text.slice(start, end).trim(); @@ -2505,7 +2522,7 @@ export class TeamProvisioningService { void this.sentMessagesStore .appendMessage(teamName, relayMsg) .catch((e: unknown) => - logger.warn(`[${teamName}] sentMessagesStore persist failed: ${e}`) + logger.warn(`[${teamName}] sentMessagesStore persist failed: ${String(e)}`) ); this.teamChangeEmitter?.({ type: 'inbox', @@ -2726,7 +2743,7 @@ export class TeamProvisioningService { .appendMessage(run.teamName, msg) .catch((e: unknown) => logger.warn( - `[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${e}` + `[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${String(e)}` ) ); this.teamChangeEmitter?.({ @@ -2831,7 +2848,10 @@ export class TeamProvisioningService { run.leadTextPushedInCurrentTurn = true; const now = Date.now(); - if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) { + if ( + now - run.lastLeadTextEmitMs >= + TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS + ) { run.lastLeadTextEmitMs = now; this.teamChangeEmitter?.({ type: 'inbox', @@ -3030,7 +3050,7 @@ export class TeamProvisioningService { void this.sentMessagesStore .appendMessage(run.teamName, replyMsg) .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) + logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${String(e)}`) ); this.teamChangeEmitter?.({ type: 'inbox', @@ -3053,7 +3073,7 @@ export class TeamProvisioningService { void this.sentMessagesStore .appendMessage(run.teamName, fallbackMsg) .catch((e: unknown) => - logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`) + logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${String(e)}`) ); this.teamChangeEmitter?.({ type: 'inbox', @@ -3121,14 +3141,10 @@ export class TeamProvisioningService { } // Extract compact metadata for the system message - const meta = (msg as Record).compact_metadata as - | Record - | undefined; + const meta = msg.compact_metadata as Record | undefined; const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto'; const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null; - const tokenInfo = preTokens - ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` - : ''; + const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : ''; const compactMsg: InboxMessage = { from: 'system', @@ -3160,7 +3176,12 @@ export class TeamProvisioningService { private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { // Guard: must be set synchronously BEFORE any await to prevent // double-invocation from filesystem monitor + stream-json racing. - if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed') + if ( + run.provisioningComplete || + run.cancelRequested || + run.processKilled || + run.progress.state === 'failed' + ) return; // Prevent false "ready" when auth failure was printed as assistant text or logs @@ -3172,7 +3193,11 @@ export class TeamProvisioningService { .filter(Boolean) .join('\n') .trim(); - if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) { + if ( + preCompleteText && + this.hasApiError(preCompleteText) && + !this.isAuthFailureWarning(preCompleteText) + ) { this.failProvisioningWithApiError(run, preCompleteText); return; } @@ -3235,7 +3260,7 @@ export class TeamProvisioningService { // Pick up any direct messages that arrived before/while reconnecting. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => - logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`) + logger.warn(`[${run.teamName}] post-reconnect relay failed: ${String(e)}`) ); // Solo teams have no teammate processes to resume work; kick off task execution @@ -3320,7 +3345,7 @@ export class TeamProvisioningService { // Pick up any direct messages that arrived during provisioning. void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) => - logger.warn(`[${run.teamName}] post-provisioning relay failed: ${e}`) + logger.warn(`[${run.teamName}] post-provisioning relay failed: ${String(e)}`) ); } @@ -3962,7 +3987,7 @@ export class TeamProvisioningService { private async cleanupCliAutoSuffixedMembers(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); - let removedFromConfig: string[] = []; + const removedFromConfig: string[] = []; try { const raw = await tryReadRegularFileUtf8(configPath, { timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, @@ -3976,7 +4001,9 @@ export class TeamProvisioningService { if (membersRaw.length > 0) { const teammateNames = membersRaw .map((m) => (typeof m.name === 'string' ? m.name.trim() : '')) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'); + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); const keepName = createCliAutoSuffixNameGuard(teammateNames); const nextMembers: Record[] = []; @@ -4008,14 +4035,16 @@ export class TeamProvisioningService { // best-effort } - let activeNamesForInboxCleanup: Set = new Set(); + let activeNamesForInboxCleanup = new Set(); try { const metaMembers = await this.membersMetaStore.getMembers(teamName); if (metaMembers.length > 0) { const activeNames = metaMembers .filter((m) => !m.removedAt) .map((m) => m.name.trim()) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'); + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ); const keepName = createCliAutoSuffixNameGuard(activeNames); const removedFromMeta: string[] = []; @@ -4042,7 +4071,9 @@ export class TeamProvisioningService { nextMeta .filter((m) => !m.removedAt) .map((m) => m.name.trim()) - .filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user') + .filter( + (n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user' + ) ); } } catch { diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index ba9568f7..117ad118 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -258,7 +258,7 @@ function dropCliAutoSuffixedMembers( for (const key of keys) { const member = memberMap.get(key); const name = member?.name ?? ''; - const match = name.trim().match(/^(.+)-(\d+)$/); + const match = /^(.+)-(\d+)$/.exec(name.trim()); if (!match?.[1] || !match[2]) continue; const suffix = Number(match[2]); if (!Number.isFinite(suffix) || suffix < 2) continue; diff --git a/src/preload/index.ts b/src/preload/index.ts index 863c48d0..687132ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -67,6 +67,7 @@ import { TEAM_CREATE, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, @@ -77,6 +78,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, + TEAM_GET_TASK_ATTACHMENT, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -91,12 +93,10 @@ import { TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, - TEAM_SAVE_TASK_ATTACHMENT, - TEAM_GET_TASK_ATTACHMENT, - TEAM_DELETE_TASK_ATTACHMENT, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, + TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, @@ -168,6 +168,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CommentAttachmentPayload, ConflictCheckResult, ContextInfo, CreateTaskRequest, @@ -193,7 +194,6 @@ import type { SshConnectionConfig, SshConnectionStatus, SshLastConnection, - CommentAttachmentPayload, TaskAttachmentMeta, TaskChangeSetV2, TaskComment, diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 748b55cc..352cde15 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -12,11 +12,11 @@ import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, } from '@renderer/constants/cssVariables'; +import { formatPercentOfTotal } from '@renderer/utils/contextMath'; import { formatCostUsd } from '@shared/utils/costFormatting'; import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react'; import { formatTokens } from '../utils/formatting'; -import { formatPercentOfTotal } from '@renderer/utils/contextMath'; import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 6866f0ad..2c956ef5 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -11,6 +11,7 @@ import { COLOR_SURFACE_OVERLAY, COLOR_TEXT_MUTED, } from '@renderer/constants/cssVariables'; +import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection'; import { FlatInjectionList } from './components/FlatInjectionList'; @@ -29,7 +30,6 @@ import { SECTION_TOOL_OUTPUTS, SECTION_USER_MESSAGES, } from './types'; -import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types'; import type { @@ -133,10 +133,7 @@ export const SessionContextPanel = ({ }, [injections]); // Calculate total tokens - const totalTokens = useMemo( - () => sumContextInjectionTokens(injections), - [injections] - ); + const totalTokens = useMemo(() => sumContextInjectionTokens(injections), [injections]); // Section token counts const claudeMdTokens = useMemo( diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 2dabb345..033d4c7f 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -28,6 +28,8 @@ import { } from '@shared/constants/triggerColors'; import { Wrench } from 'lucide-react'; +import { highlightQueryInText } from '../searchHighlightUtils'; + import { BaseItem, StatusDot } from './BaseItem'; import { formatDuration } from './baseItemHelpers'; import { @@ -38,7 +40,6 @@ import { ToolErrorDisplay, WriteToolViewer, } from './linkedTool'; -import { highlightQueryInText } from '../searchHighlightUtils'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; @@ -72,9 +73,14 @@ export const LinkedToolItem: React.FC = ({ const summary = getToolSummary(linkedTool.name, linkedTool.input); const summaryNode = searchQueryOverride && searchQueryOverride.trim().length > 0 - ? highlightQueryInText(summary, searchQueryOverride, `${linkedTool.id ?? linkedTool.name}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + summary, + searchQueryOverride, + `${linkedTool.id ?? linkedTool.name}:summary`, + { + forceAllActive: true, + } + ) : summary; const elementRef = useRef(null); diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index d9f9ab5d..2e0c88dd 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { MessageSquare } from 'lucide-react'; -import { MarkdownViewer } from '../viewers'; import { highlightQueryInText } from '../searchHighlightUtils'; +import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -42,9 +42,14 @@ export const TextItem: React.FC = ({ const fullContent = step.content.outputText ?? preview; const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index c3cdafad..c74742ee 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { Brain } from 'lucide-react'; -import { MarkdownViewer } from '../viewers'; import { highlightQueryInText } from '../searchHighlightUtils'; +import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -42,9 +42,14 @@ export const ThinkingItem: React.FC = ({ const fullContent = step.content.thinkingText ?? preview; const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) + ? highlightQueryInText( + truncatedPreview, + searchQueryOverride, + `${markdownItemId ?? step.id}:summary`, + { + forceAllActive: true, + } + ) : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 12ec37e2..34684f96 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -112,6 +112,7 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode return parts; } +// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types export function highlightQueryInText( text: string, query: string, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 25923608..460640b0 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -4,7 +4,6 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CODE_BG, CODE_BORDER, @@ -24,6 +23,7 @@ import { PROSE_TABLE_BORDER, PROSE_TABLE_HEADER_BG, } from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { FileText } from 'lucide-react'; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx index d7206e22..cf66c06f 100644 --- a/src/renderer/components/settings/SettingsView.tsx +++ b/src/renderer/components/settings/SettingsView.tsx @@ -26,6 +26,7 @@ export const SettingsView = (): React.JSX.Element | null => { // Consume pending section (avoid setState during render) useEffect(() => { if (pendingSettingsSection) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setActiveSection(pendingSettingsSection as SettingsSection); clearPendingSettingsSection(); } diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx index 02872b4e..6284dc42 100644 --- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -9,8 +9,14 @@ 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 { lintGutter, linter, type Diagnostic } from '@codemirror/lint'; +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'; @@ -45,7 +51,7 @@ const jsonLinter = linter((view: EditorView) => { JSON.parse(text); } catch (e) { if (e instanceof SyntaxError) { - const match = e.message.match(/position (\d+)/); + const match = /position (\d+)/.exec(e.message); const pos = match ? parseInt(match[1], 10) : 0; const safePos = Math.min(pos, text.length); diagnostics.push({ @@ -163,6 +169,7 @@ export const ConfigEditorDialog = ({ 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); @@ -203,6 +210,7 @@ export const ConfigEditorDialog = ({ 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(); diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index b160e5d8..f32108a1 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -250,6 +250,7 @@ export const GlobalTaskList = ({ // Reset showArchived when archive becomes empty useEffect(() => { if (showArchived && !hasArchivedTasks) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setShowArchived(false); } }, [showArchived, hasArchivedTasks]); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 6e72fe9e..2fc40d9b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -96,6 +96,7 @@ export const SidebarTaskItem = ({ // Reset edit value when renaming starts useEffect(() => { if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setEditValue(displaySubject); } }, [isRenaming, displaySubject]); diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx index 179d8c9b..aeeb70e6 100644 --- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx +++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx @@ -127,12 +127,26 @@ export const ClaudeLogsFilterPopover = ({ Stream

-
@@ -143,16 +157,37 @@ export const ClaudeLogsFilterPopover = ({ Content

-
@@ -176,4 +211,3 @@ export const ClaudeLogsFilterPopover = ({ ); }; - diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 39dc5742..b2209df0 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -5,20 +5,19 @@ import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { Search, Terminal, X } from 'lucide-react'; -import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import { CliLogsRichView } from './CliLogsRichView'; -import { - ClaudeLogsFilterPopover, - DEFAULT_CLAUDE_LOGS_FILTER, -} from './ClaudeLogsFilterPopover'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; -import type { TeamClaudeLogsResponse } from '@shared/types'; 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; } @@ -37,9 +36,9 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { const chronological = [...linesNewestFirst].reverse(); const out: string[] = []; - let lastStream: 'stdout' | 'stderr' | null = null; + let lastStream: StreamType | null = null; - const pushMarker = (stream: 'stdout' | 'stderr'): void => { + const pushMarker = (stream: StreamType): void => { if (lastStream === stream) return; lastStream = stream; out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); @@ -84,8 +83,8 @@ function filterStreamJsonText( const q = queryRaw.trim().toLowerCase(); const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); - let currentStream: 'stdout' | 'stderr' | null = null; - let lastEmittedStream: 'stdout' | 'stderr' | null = null; + let currentStream: StreamType | null = null; + let lastEmittedStream: StreamType | null = null; const out: string[] = []; const emitMarker = (): void => { @@ -108,7 +107,10 @@ function filterStreamJsonText( return null; }; - const writeBlocks = (parsed: Record, blocks: AssistantContentBlock[]): Record => { + const writeBlocks = ( + parsed: Record, + blocks: AssistantContentBlock[] + ): Record => { if (Array.isArray(parsed.content)) { return { ...parsed, content: blocks }; } @@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J useEffect(() => { let cancelled = false; - const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => { + const computeNewCount = ( + committed: TeamClaudeLogsResponse, + latest: TeamClaudeLogsResponse + ): number => { if (committed.lines.length === 0) return latest.lines.length; 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); + const diff = + (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); return Math.max(0, diff); }; @@ -386,12 +392,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
-
+
{error ?

{error}

: null} {!error && filteredText.trim().length > 0 ? ( ) : null} {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( -

- No matching logs. -

+

No matching logs.

) : null}
); }; - diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 59efa480..b168d71d 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -49,6 +49,7 @@ function useElapsedTimer(startedAt?: string, isRunning = true): string | null { useEffect(() => { if (!startedAt) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setElapsedSeconds(null); return; } @@ -201,7 +202,8 @@ export const ProvisioningProgressBlock = ({ variant="secondary" className={cn( 'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', - isDone && 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', + isDone && + 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', isCurrent && 'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]' )} diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx index e7f70a28..4e091f14 100644 --- a/src/renderer/components/team/RoleSelect.tsx +++ b/src/renderer/components/team/RoleSelect.tsx @@ -3,17 +3,7 @@ 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 { 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'; @@ -61,13 +51,14 @@ const roleOptions: ComboboxOption[] = [ { 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; + : (ROLE_ICONS[option.value] ?? null); return ( <> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e9efd55e..fe8b7bb9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,9 +1,9 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; +import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { confirm } from '@renderer/components/common/ConfirmDialog'; import { Button } from '@renderer/components/ui/button'; -import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { Dialog, DialogContent, @@ -15,10 +15,10 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useBranchSync } from '@renderer/hooks/useBranchSync'; +import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { useTabUI } from '@renderer/hooks/useTabUI'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; @@ -73,8 +73,8 @@ import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; -import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ClaudeLogsSection } from './ClaudeLogsSection'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -222,7 +222,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError, isTeamProvisioning, leadActivityByTeam, - leadContextByTeam, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, @@ -265,7 +264,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, - leadContextByTeam: s.leadContextByTeam, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -366,15 +364,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele // Lead session context panel (reuses the same session context pipeline for exact stats) const leadSessionId = data?.config.leadSessionId ?? null; - const leadTabData = useStore( - useShallow((s) => (tabId ? s.tabSessionData[tabId] : null)) - ); + const leadTabData = useStore(useShallow((s) => (tabId ? s.tabSessionData[tabId] : null))); const leadSessionDetail = leadTabData?.sessionDetail ?? null; const leadConversation = leadTabData?.conversation ?? null; const leadSessionContextStats = leadTabData?.sessionContextStats ?? null; const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null; const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false; - const leadSessionLoaded = Boolean(leadSessionId && leadSessionDetail?.session?.id === leadSessionId); + const leadSessionLoaded = Boolean( + leadSessionId && leadSessionDetail?.session?.id === leadSessionId + ); const leadSubagentCostUsd = useMemo(() => { const processes = leadSessionDetail?.processes; @@ -382,8 +380,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0); return total > 0 ? total : undefined; }, [leadSessionDetail?.processes]); - const leadContextPercent = leadContextByTeam[teamName]?.percent; - const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) { return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; @@ -405,7 +401,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (!targetAiGroupId) { const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai'); if (lastAiItem?.type !== 'ai') { - return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + return { + allContextInjections: [] as ContextInjection[], + lastAiGroupTotalTokens: undefined, + }; } targetAiGroupId = lastAiItem.group.id; } @@ -435,7 +434,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; - }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]); + }, [ + leadSessionLoaded, + leadSessionContextStats, + leadConversation, + selectedContextPhase, + leadSessionPhaseInfo, + ]); const visibleContextTokens = useMemo( () => sumContextInjectionTokens(allContextInjections), @@ -927,438 +932,864 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
)} -
- {headerColorSet ? ( -
- ) : null}
-
-

- {data.config.name} -

-
-
- {data.isAlive && ( + {headerColorSet ? ( +
+ ) : null} +
+
+

+ {data.config.name} +

+
+
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + Edit team + - Stop team + Delete team - )} - - - - - Edit team - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} - {(data.config.projectPath || leadBranch) && ( -
- {data.config.projectPath && ( - - - - {formatProjectPath(data.config.projectPath)} - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} - {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
- )} - {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - + {data.config.description} +

+ )} + {(data.config.projectPath || leadBranch) && ( +
+ {data.config.projectPath && ( + + + + {formatProjectPath(data.config.projectPath)} + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + + + {leadBranch} + + )} + {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )}
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( -
- - - Team is offline - - + )} + {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()}
- ) : null} -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. -
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ - - } - > - + + Team is offline + + +
+ ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ + + } + > + { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={(member) => { + openCreateTaskDialog('', '', member.name); + }} + onOpenTask={(task) => setSelectedTask(task)} + /> + + + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + + setKanbanSearch(e.target.value)} + className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" + /> + {kanbanSearch && ( + + + + + Clear search + + )} +
+ } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTask(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task #${taskId} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task #${taskId} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.add('ring-2', 'ring-blue-400/50'); + setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); + } + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + + + } + badge={filteredMessages.length} + secondaryBadge={ + filteredMessages.length > 0 && messagesUnreadCount > 0 + ? messagesUnreadCount + : undefined + } + headerExtra={ + + + + + Desktop notifications plugin + + } + defaultOpen + action={ +
+ {messagesUnreadCount > 0 && ( + + + + + Mark all as read + + )} +
+ + setMessagesSearchQuery(e.target.value)} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {messagesSearchQuery && ( + + )} +
+ +
+ } + > + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + }); + }} + /> + + + { + openCreateTaskDialog(subject, description); + }} + onReplyToMessage={(message) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }} + onMessageVisible={handleMessageVisible} + onRestartTeam={() => setLaunchDialogOpen(true)} + onTaskIdClick={(taskId) => { + const task = taskMap.get(taskId); + if (task) setSelectedTask(task); + }} + /> +
+ + setRequestChangesTaskId(null)} + onSubmit={(comment) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + { - setSendDialogRecipient(member.name); + onClose={() => setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); setSendDialogDefaultText(undefined); setSendDialogDefaultChip(undefined); setReplyQuote(undefined); setSendDialogOpen(true); }} - onAssignTask={(member) => { - openCreateTaskDialog('', '', member.name); + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + onTaskClick={(task) => { + setSelectedMember(null); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); }} - onOpenTask={(task) => setSelectedTask(task)} /> - - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ - - } - > - - - setKanbanSearch(e.target.value)} - className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none" - /> - {kanbanSearch && ( - - - - - Clear search - - )} -
- } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTask(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task #${taskId} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + tasks={data.tasks} + isTeamAlive={data.isAlive && !isTeamProvisioning} + defaultSubject={createTaskDialog.defaultSubject} + defaultDescription={createTaskDialog.defaultDescription} + defaultOwner={createTaskDialog.defaultOwner} + defaultStartImmediately={createTaskDialog.defaultStartImmediately} + defaultChip={createTaskDialog.defaultChip} + onClose={closeCreateTaskDialog} + onSubmit={handleCreateTask} + submitting={creatingTask} + /> - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task #${taskId} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task #${taskId} cancelled`, - }); - } catch { - // best-effort - } - } + setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${taskId} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + m.name)} + existingMembers={data.members} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(name, role, workflow) => { + setAddingMemberLoading(true); void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await addMember(teamName, { name, role, workflow }); + setAddMemberDialogOpen(false); } catch { - // error via store + // error shown via store + } finally { + setAddingMemberLoading(false); } })(); }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages will + be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void (async () => { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + await sendTeamMessage(teamName, { member, text, summary, attachments }); + } catch { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + })(); + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -1366,476 +1797,55 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store + } + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} /> - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open ? {} : { initialFilePath: undefined }), + })) } - defaultOpen - > - - - )} - - - - } - badge={filteredMessages.length} - secondaryBadge={ - filteredMessages.length > 0 && messagesUnreadCount > 0 ? messagesUnreadCount : undefined - } - headerExtra={ - - - - - Desktop notifications plugin - - } - defaultOpen - action={ -
- {messagesUnreadCount > 0 && ( - - - - - Mark all as read - - )} -
- - setMessagesSearchQuery(e.target.value)} - onPointerDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" - /> - {messagesSearchQuery && ( - - )} -
- -
- } - > - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - }); - }} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} /> - - - { - openCreateTaskDialog(subject, description); - }} - onReplyToMessage={(message) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }} - onMessageVisible={handleMessageVisible} - onRestartTeam={() => setLaunchDialogOpen(true)} - onTaskIdClick={(taskId) => { - const task = taskMap.get(taskId); - if (task) setSelectedTask(task); - }} - /> -
- - setRequestChangesTaskId(null)} - onSubmit={(comment) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - setSelectedMember(null)} - onSendMessage={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - setSelectedMember(null); - openCreateTaskDialog('', '', name); - }} - onTaskClick={(task) => { - setSelectedMember(null); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - setSelectedMember(null); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - setEditDialogOpen(false)} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={data.members} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(name, role, workflow) => { - setAddingMemberLoading(true); - void (async () => { - try { - await addMember(teamName, { name, role, workflow }); - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> - - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages will be - preserved, but this name cannot be reused. - - - - - - - - - - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All team - data and tasks will be deleted. - - - - - - - - - - setLaunchDialogOpen(false)} - onLaunch={async (request) => { - await launchTeam(request); - }} - /> - - { - void (async () => { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - await sendTeamMessage(teamName, { member, text, summary, attachments }); - } catch { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - })(); - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} - /> - - setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.add('ring-2', 'ring-blue-400/50'); - setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open ? {} : { initialFilePath: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - projectPath={data.config.projectPath} - onEditorAction={handleEditorAction} - />
{/* Context panel sidebar */} @@ -1876,7 +1886,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele

- {leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'} + {leadSessionLoading + ? 'Loading context…' + : 'Open the team lead session to view context.'}

diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 46de0418..60111ff2 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { parseStructuredAgentMessage } from '@renderer/utils/agentMessageFormatting'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 6949671b..7483c8f2 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -42,14 +42,8 @@ export const ReplyQuoteBlock = ({
{/* Quote text */} -
- +
+
{/* More/less toggle */} diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index 51e3fd47..f5733612 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -1,11 +1,12 @@ +import 'yet-another-react-lightbox/styles.css'; +import 'yet-another-react-lightbox/plugins/counter.css'; + 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 'yet-another-react-lightbox/styles.css'; -import 'yet-another-react-lightbox/plugins/counter.css'; import type { Plugin, Slide } from 'yet-another-react-lightbox'; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 1549cd70..5e2aeb6d 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -12,7 +13,6 @@ import { import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 6f0f0fc2..3096f38d 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -94,6 +94,7 @@ export const CreateTaskDialog = ({ // Reset form when dialog opens (avoid setState during render) useEffect(() => { if (open && !prevOpenRef.current) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setSubject(defaultSubject); if (defaultChip) { const token = chipToken(defaultChip); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index f87509bc..0b7391d5 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -559,6 +559,7 @@ export const CreateTeamDialog = ({ 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); @@ -636,7 +637,11 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 45d8ad17..d644c89c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, @@ -25,8 +25,8 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; +import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; import { MemberBadge } from '../MemberBadge'; @@ -129,7 +129,16 @@ export const SendMessageDialog = ({ } } prevOpenRef.current = open; - }, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]); + }, [ + open, + defaultRecipient, + defaultText, + defaultChip, + quotedMessage, + lastResult, + textDraft, + chipDraft, + ]); // Track whether auto-close is needed (avoid setState in render) useEffect(() => { @@ -421,7 +430,9 @@ export const SendMessageDialog = ({ ) : null} {textDraft.isSaved ? ( - Draft saved + + Draft saved + ) : null}
} @@ -442,7 +453,6 @@ export const SendMessageDialog = ({ Shown as notification preview. Team lead also sees this for peer messages.

-
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx index 3a59306f..39863d44 100644 --- a/src/renderer/components/team/dialogs/TaskAttachments.tsx +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; -import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; - -import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; +import { File, ImagePlus, Loader2, Trash2 } from 'lucide-react'; import type { TaskAttachmentMeta } from '@shared/types'; @@ -105,7 +104,10 @@ export const TaskAttachments = ({ setError('Attachment file not found'); return; } - const mime = att.mimeType && typeof att.mimeType === 'string' ? att.mimeType : 'application/octet-stream'; + const mime = + att.mimeType && typeof att.mimeType === 'string' + ? att.mimeType + : 'application/octet-stream'; const dataUrl = `data:${mime};base64,${base64}`; const blob = await fetch(dataUrl).then((r) => r.blob()); const url = URL.createObjectURL(blob); @@ -212,8 +214,14 @@ export const TaskAttachments = ({ teamName={teamName} taskId={taskId} isDeleting={deletingId === att.id} - onPreview={() => void handlePreview(att)} - onDelete={() => void handleDelete(att.id, att.mimeType)} + onPreview={() => { + // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise + void handlePreview(att); + }} + onDelete={() => { + // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise + void handleDelete(att.id, att.mimeType); + }} onDataLoaded={handleThumbLoaded} /> ))} @@ -327,7 +335,7 @@ const AttachmentThumbnail = ({ return (
{isImageMimeType(attachment.mimeType) ? ( @@ -381,7 +389,7 @@ function fileToBase64(file: File): Promise { 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); }); } diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index a25757a3..b99cb61b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -71,50 +71,45 @@ export const TaskCommentInput = ({ trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; - const addFiles = useCallback( - (files: FileList | File[]) => { - setAttachError(null); - const fileArray = Array.from(files); - for (const file of fileArray) { - if (!ACCEPTED_TYPES.has(file.type)) { - setAttachError(`Unsupported type: ${file.type}`); - continue; - } - if (file.size > MAX_FILE_SIZE) { - setAttachError( - `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` - ); - continue; - } - 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 addFiles = useCallback((files: FileList | File[]) => { + setAttachError(null); + const fileArray = Array.from(files); + for (const file of fileArray) { + if (!ACCEPTED_TYPES.has(file.type)) { + setAttachError(`Unsupported type: ${file.type}`); + continue; } - }, - [] - ); + if (file.size > MAX_FILE_SIZE) { + setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); + continue; + } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (!base64) return; + const id = crypto.randomUUID(); + setPendingAttachments((prev) => { + if (prev.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + return prev; + } + return [ + ...prev, + { + id, + filename: file.name, + mimeType: file.type, + base64Data: base64, + previewUrl: result, + size: file.size, + }, + ]; + }); + }; + reader.readAsDataURL(file); + } + }, []); const removeAttachment = useCallback((id: string) => { setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); @@ -131,7 +126,7 @@ export const TaskCommentInput = ({ ? pendingAttachments.map((a) => ({ id: a.id, filename: a.filename, - mimeType: a.mimeType as CommentAttachmentPayload['mimeType'], + mimeType: a.mimeType, base64Data: a.base64Data, })) : undefined; @@ -245,6 +240,7 @@ export const TaskCommentInput = ({ className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); + // eslint-disable-next-line no-param-reassign -- reset file input to allow re-selecting same file e.target.value = ''; }} /> diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index f4fcf71d..fe4ba277 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; -import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; +import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -11,8 +11,8 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead'; import { useStore } from '@renderer/store'; import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting'; -import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { isImageMimeType } from '@renderer/utils/attachmentUtils'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; @@ -92,6 +92,7 @@ export const TaskCommentsSection = ({ // Reset local UI state when team/task changes. useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setVisibleCount(INITIAL_VISIBLE_COMMENTS); setReplyTo(null); setPreviewImageUrl(null); @@ -169,125 +170,128 @@ export const TaskCommentsSection = ({ ) : null}
- {visibleComments.map((comment, index) => ( -
-
- - {comment.type === 'review_approved' ? ( - - - Approved + {visibleComments.map((comment, index) => ( +
+
+ + {comment.type === 'review_approved' ? ( + + + Approved + + ) : comment.type === 'review_request' ? ( + + + Review requested + + ) : null} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} - ) : comment.type === 'review_request' ? ( - - - Review requested - - ) : null} - - {(() => { - const date = new Date(comment.createdAt); - return isNaN(date.getTime()) - ? 'unknown time' - : formatDistanceToNow(date, { addSuffix: true }); - })()} - - - - - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); - return ( - - {reply ? ( - + + + + Reply to comment + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); + return ( + + {reply ? ( + -
- )} - - ); - })()} - {comment.attachments && comment.attachments.length > 0 ? ( - - ) : null} -
- ))} + ) : ( + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const id = link.getAttribute('href')?.replace('task://', ''); + if (id) onTaskIdClick(id); + } + } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + + )} + + ); + })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null} +
+ ))}
{sortedComments.length > visibleComments.length ? ( diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index f36254b3..3b5b9195 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -6,8 +6,6 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; -import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; -import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Dialog, DialogContent, @@ -16,7 +14,9 @@ import { DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; +import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; import { Input } from '@renderer/components/ui/input'; +import { MemberSelect } from '@renderer/components/ui/MemberSelect'; import { Textarea } from '@renderer/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { markAsRead } from '@renderer/services/commentReadStorage'; diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx index 3c051cb6..44c74435 100644 --- a/src/renderer/components/team/editor/EditorImagePreview.tsx +++ b/src/renderer/components/team/editor/EditorImagePreview.tsx @@ -35,6 +35,7 @@ export const EditorImagePreview = ({ // Reset state when filePath changes useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); setError(null); setDataUrl(null); diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index b2a237ae..f8c8082b 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -41,6 +41,7 @@ export const QuickOpenDialog = ({ useEffect(() => { let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setLoading(true); window.electronAPI.editor .listFiles() diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 79a430a4..54b4ed63 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,7 +1,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display +// import { useStore } from '@renderer/store'; // NOTE: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -40,7 +40,7 @@ export const MemberCard = ({ onSendMessage, onAssignTask, }: MemberCardProps): React.JSX.Element => { - // TODO: lead context display disabled — usage formula is inaccurate + // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined @@ -184,7 +184,7 @@ export const MemberCard = ({ />
)} - {/* TODO: lead context bar disabled — usage formula is inaccurate */} + {/* NOTE: lead context bar disabled — usage formula is inaccurate */}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 8ac7e03c..24bd84fd 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -// import { useStore } from '@renderer/store'; // TODO: disabled — lead context display import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -31,7 +30,7 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); - // TODO: lead context display disabled — usage formula is inaccurate + // NOTE: lead context display disabled — usage formula is inaccurate // const teamName = useStore((s) => s.selectedTeamName); // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined @@ -102,7 +101,7 @@ export const MemberDetailHeader = ({ > {presenceLabel} - {/* TODO: lead context token display disabled — usage formula is inaccurate */} + {/* NOTE: lead context token display disabled — usage formula is inaccurate */} )}
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 469bd7c6..cf4cc36d 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { RoleSelect } from '@renderer/components/team/RoleSelect'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; @@ -144,7 +144,7 @@ export const MemberDraftRow = ({ onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)} triggerClassName="h-8 text-xs" inputClassName="h-8 text-xs" -/> + />
{showWorkflow && onWorkflowChange ? ( diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index e8f2c1a9..8a987feb 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -152,14 +152,17 @@ export const MemberLogsTab = ({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops }, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]); - const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise => { - 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; - }, []); + const fetchDetailForLog = useCallback( + async (log: MemberLogSummary): Promise => { + if (log.kind === 'subagent') { + const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId); + return d?.chunks ?? null; + } + const d = await api.getSessionDetail(log.projectId, log.sessionId); + return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null; + }, + [] + ); useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; @@ -250,12 +253,8 @@ export const MemberLogsTab = ({ key={getRowId(log)} log={log} expanded={expandedId === getRowId(log)} - detailChunks={ - expandedId === getRowId(log) ? detailChunks : null - } - detailLoading={ - expandedId === getRowId(log) && detailLoading - } + detailChunks={expandedId === getRowId(log) ? detailChunks : null} + detailLoading={expandedId === getRowId(log) && detailLoading} onToggle={() => void handleExpand(log)} /> ))} diff --git a/src/renderer/components/team/members/MemberRoleEditor.tsx b/src/renderer/components/team/members/MemberRoleEditor.tsx index 9aa170d1..2aff2f77 100644 --- a/src/renderer/components/team/members/MemberRoleEditor.tsx +++ b/src/renderer/components/team/members/MemberRoleEditor.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Button } from '@renderer/components/ui/button'; import { RoleSelect } from '@renderer/components/team/RoleSelect'; +import { Button } from '@renderer/components/ui/button'; import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { Check, Loader2, X } from 'lucide-react'; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 2c638d4f..11badd7e 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -37,7 +37,7 @@ interface MessageComposerProps { const MAX_MESSAGE_LENGTH = 4000; /** Circular progress indicator for lead context usage. */ -const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { +const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { const size = 26; const stroke = 2.5; const radius = (size - stroke) / 2; @@ -49,7 +49,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { return ( -
+
m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; - // TODO: lead context ring disabled — usage formula is inaccurate + // NOTE: lead context ring disabled — usage formula is inaccurate // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const leadContext = useStore((s) => // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined @@ -290,7 +293,10 @@ export const MessageComposer = ({ > {members.length > 5 && (
- + )}
+ {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} {(() => { const query = recipientSearch.toLowerCase().trim(); const filtered = query @@ -392,7 +399,9 @@ export const MessageComposer = ({ ) : null} {!isTeamAlive ? ( - Team offline + + Team offline + ) : null}
@@ -421,7 +430,7 @@ export const MessageComposer = ({ disabled={sending} cornerAction={
- {/* TODO: ContextRing disabled — usage formula is inaccurate */} + {/* NOTE: ContextRing disabled — usage formula is inaccurate */}
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */}
); diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 1549cd70..14a51489 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -170,7 +170,7 @@ export const AddMemberDialog = ({ onValueChange={handleWorkflowChange} suggestions={mentionSuggestions} projectPath={projectPath ?? undefined} - placeholder="How this agent should behave, what tasks it handles. Use @ to mention teammates or add files." + placeholder="How this agent should behave, what tasks it handles..." footerRight={ workflowDraft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3a296265..adfadeec 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -362,7 +362,11 @@ export const LaunchTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} @@ -406,7 +410,7 @@ export const LaunchTeamDialog = ({ chips={chipDraft.chips} onChipRemove={chipDraft.removeChip} onFileChipInsert={chipDraft.addChip} - placeholder="Instructions for team lead... Use @ to mention team members." + placeholder="Instructions for team lead..." footerRight={ promptDraft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 469bd7c6..d235fc40 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -144,7 +144,7 @@ export const MemberDraftRow = ({ onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)} triggerClassName="h-8 text-xs" inputClassName="h-8 text-xs" -/> + />
{showWorkflow && onWorkflowChange ? ( @@ -191,7 +191,7 @@ export const MemberDraftRow = ({ onChipRemove={handleChipRemove} projectPath={projectPath ?? undefined} onFileChipInsert={handleFileChipInsert} - placeholder="How this agent should behave, interact with others. Use @ to mention teammates or add files." + placeholder="How this agent should behave, interact with others..." footerRight={ workflowDraft.isSaved ? ( Draft saved diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index a9cf4e19..ab0ee71f 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -196,6 +196,8 @@ export interface InboxMessage { messageId?: string; source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent'; attachments?: AttachmentMeta[]; + /** Lead session ID that produced this message (for session boundary detection). */ + leadSessionId?: string; } export interface SendMessageRequest { From 17775274a0c8b14f7754376ccb33aa05e50618a6 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 22:16:57 +0200 Subject: [PATCH 23/23] feat: add source field to messages for system notifications - Enhanced TeamDataService to include a 'source' field in message payloads, specifically for system notifications. - Updated InboxMessage type to accommodate the new 'system_notification' source. - Modified TeamInboxWriter to conditionally include the source field in the message payload. - Added tests to verify the inclusion and omission of the source field based on request parameters. --- src/main/services/team/TeamDataService.ts | 8 ++++ src/main/services/team/TeamInboxWriter.ts | 1 + .../components/team/TeamDetailView.tsx | 46 ++++++++++--------- .../team/activity/ActivityTimeline.tsx | 7 ++- src/shared/types/team.ts | 3 +- .../services/team/TeamInboxWriter.test.ts | 24 ++++++++++ 6 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index e44851fd..ec3c8a43 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -862,6 +862,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), summary: `New task #${task.id} assigned`, + source: 'system_notification', }); } } catch { @@ -906,6 +907,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), summary: `Task #${task.id} started`, + source: 'system_notification', }); } } catch { @@ -961,6 +963,7 @@ export class TeamDataService { from: last.actor, text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, summary: `Task #${task.id} started`, + source: 'system_notification', }); } catch (error) { logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${String(error)}`); @@ -1072,6 +1075,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), summary: `Comment on #${taskId}`, + source: 'system_notification', }); } else if (task && owner && this.isLeadOwner(owner, leadName)) { // Notify lead about user's comment on their own task. @@ -1088,6 +1092,7 @@ export class TeamDataService { from: 'user', text: parts.join('\n'), summary: `Comment on #${taskId}`, + source: 'system_notification', }); } } catch { @@ -1208,6 +1213,7 @@ export class TeamDataService { `node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."\n` + AGENT_BLOCK_CLOSE, summary: `Review request for #${taskId}`, + source: 'system_notification', }); } catch (error) { await this.kanbanManager @@ -1307,6 +1313,7 @@ export class TeamDataService { for (const msg of messages) { if (!msg.messageId || !msg.summary || msg.from === 'user') continue; if (msg.source === 'lead_session' || msg.source === 'lead_process') continue; + if (msg.source === 'system_notification') continue; if (isAutomatedCommentNotification(msg)) continue; const textKey = `${msg.from}\0${msg.text}`; @@ -1490,6 +1497,7 @@ export class TeamDataService { `${patch.comment?.trim() || 'Reviewer requested changes.'}\n\n` + `Please fix and mark it as completed when ready.`, summary: `Fix request for #${taskId}`, + source: 'system_notification', }); } catch (error) { await this.taskWriter diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index fd04a317..a0776db4 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -29,6 +29,7 @@ export class TeamInboxWriter { summary: request.summary, messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, + ...(request.source && { source: request.source }), }; await withInboxLock(inboxPath, async () => { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fe8b7bb9..7b6cb95d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1416,28 +1416,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele : undefined } headerExtra={ - - - - - Desktop notifications plugin - - } - defaultOpen - action={ -
+ <> + + + + + Desktop notifications plugin + {messagesUnreadCount > 0 && ( @@ -1455,6 +1452,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele Mark all as read )} + + } + defaultOpen + action={ +
+
- Новая сессия + New session
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index a1f00964..06220230 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -195,7 +195,7 @@ export interface InboxMessage { summary?: string; color?: string; messageId?: string; - source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent'; + source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent' | 'system_notification'; attachments?: AttachmentMeta[]; /** Lead session ID that produced this message (for session boundary detection). */ leadSessionId?: string; @@ -207,6 +207,7 @@ export interface SendMessageRequest { summary?: string; from?: string; attachments?: AttachmentPayload[]; + source?: InboxMessage['source']; } export interface SendMessageResult { diff --git a/test/main/services/team/TeamInboxWriter.test.ts b/test/main/services/team/TeamInboxWriter.test.ts index 5bf0efef..fd44cfd4 100644 --- a/test/main/services/team/TeamInboxWriter.test.ts +++ b/test/main/services/team/TeamInboxWriter.test.ts @@ -130,4 +130,28 @@ describe('TeamInboxWriter', () => { expect(persisted).toHaveLength(2); expect(persisted.map((row) => row.text).sort()).toEqual(['first', 'second']); }); + + it('includes source field in payload when provided in request', async () => { + await writer.sendMessage('my-team', { + member: 'alice', + text: 'task assigned', + summary: 'New task #1 assigned', + source: 'system_notification', + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(persisted).toHaveLength(1); + expect(persisted[0].source).toBe('system_notification'); + }); + + it('omits source field from payload when not provided in request', async () => { + await writer.sendMessage('my-team', { + member: 'alice', + text: 'hello', + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(persisted).toHaveLength(1); + expect(persisted[0]).not.toHaveProperty('source'); + }); });