From 48b485c63740d508900b661997b489a6a2d15dbc Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 16 Mar 2026 16:48:28 +0200 Subject: [PATCH] feat: enhance message handling and UI components for task management - Introduced stable messageId generation for reliable task creation and message tracking. - Updated TeamAttachmentStore with a TODO for attachment cleanup on failed sends. - Enhanced task notifications and UI components to improve user experience, including custom role rendering in RoleSelect and improved task detail displays. - Refactored task change handling logic to support better visibility of changes across task states. - Added support for displaying source message attachments in TaskDetailDialog. - Improved overall code structure and documentation for better maintainability. --- src/main/ipc/teams.ts | 14 +++- src/main/services/team/TeamAttachmentStore.ts | 3 + src/main/services/team/TeamDataService.ts | 11 ++- .../services/team/TeamProvisioningService.ts | 78 +++++++++++++++---- src/renderer/components/team/RoleSelect.tsx | 19 +++++ .../attachments/SourceMessageAttachments.tsx | 72 +++++++++++++++++ .../team/dialogs/CreateTeamDialog.tsx | 39 +++++++++- .../team/dialogs/TaskDetailDialog.tsx | 32 ++++++-- .../components/team/kanban/KanbanTaskCard.tsx | 23 +++--- src/renderer/components/ui/combobox.tsx | 7 +- .../store/slices/changeReviewSlice.ts | 6 +- src/renderer/utils/taskChangeRequest.ts | 9 +++ src/shared/utils/taskChangeState.ts | 18 +++++ test/main/ipc/teams.test.ts | 3 +- 14 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 src/renderer/components/team/attachments/SourceMessageAttachments.tsx diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 8a041b60..05abaf4c 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath } from '@main/utils/pathDecoder'; @@ -1204,12 +1206,19 @@ async function handleSendMessage( // Smart routing: lead + alive → stdin direct, else → inbox if (isLeadRecipient && isAlive) { const resolvedLeadName = leadName ?? memberName; + // Pre-generate stable messageId so both stdin and persistence use the same identity. + // This allows the lead to call task_create_from_message with the exact messageId. + const preGeneratedMessageId = crypto.randomUUID(); // Separate try blocks: stdin delivery vs persistence // If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) // Wrap with instructions so lead responds with visible text (not just agent-only blocks) const wrappedText = [ `You received a direct message from the user.`, `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, + AGENT_BLOCK_OPEN, + `MessageId: ${preGeneratedMessageId}`, + `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, + AGENT_BLOCK_CLOSE, ``, `Message from user:`, buildMessageDeliveryText(payload.text!, { @@ -1252,11 +1261,12 @@ async function handleSendMessage( payload.text!, payload.summary, attachmentMeta, - validatedTaskRefs.value + validatedTaskRefs.value, + preGeneratedMessageId ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); - result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; + result = { deliveredToInbox: false, messageId: preGeneratedMessageId }; } // Save attachment binary data to disk (best-effort) diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index a91d9128..998139f4 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -101,4 +101,7 @@ export class TeamAttachmentStore { return result; } + + // TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends. + // Best-effort removal of the attachment JSON file — useful for retry/cancel flows. } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 5b6b7519..2a1304f9 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -873,12 +873,15 @@ export class TeamDataService { // Skip inbox notification when lead starts their own task (solo teams) if (!this.isLeadOwner(task.owner, leadName)) { - const parts = [`**started task** ${this.getTaskLabel(task)} "${task.subject}"`]; + const parts = [ + `**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`, + ]; if (task.description?.trim()) { parts.push(`\nDetails:\n${task.description.trim()}`); } parts.push( `\n${AGENT_BLOCK_OPEN}`, + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, `Update task status using the board MCP tools:`, `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, AGENT_BLOCK_CLOSE @@ -888,7 +891,7 @@ export class TeamDataService { from: leadName, text: parts.join('\n'), taskRefs: task.descriptionTaskRefs, - summary: `Task ${this.getTaskLabel(task)} started`, + summary: `Start working on ${this.getTaskLabel(task)}`, source: 'system_notification', }); } @@ -1510,7 +1513,8 @@ export class TeamDataService { text: string, summary?: string, attachments?: AttachmentMeta[], - taskRefs?: TaskRef[] + taskRefs?: TaskRef[], + messageId?: string ): Promise { let leadSessionId: string | undefined; try { @@ -1529,6 +1533,7 @@ export class TeamDataService { source: 'user_sent', attachments: attachments?.length ? attachments : undefined, leadSessionId, + ...(messageId ? { messageId } : {}), }) as InboxMessage; return { deliveredToInbox: false, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b593ee33..be7fa65c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -466,7 +466,7 @@ After member_briefing succeeds: - CRITICAL: If someone comments on your task, you MUST reply on that same task via task_add_comment. Never leave a user/lead/teammate task comment unanswered, even if the reply is only a short acknowledgement or status update. Do NOT treat status changes or direct messages as a substitute for an on-task reply. - CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle. - Direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. -- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. +- If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. ${buildTeammateAgentBlockReminder()} ${actionModeProtocol}`; } @@ -545,7 +545,7 @@ ${actionModeProtocol} - Only then run task_start when you truly begin. - If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on it, FIRST leave a short task comment saying what you are about to do, THEN run task_start, then do the work, and when finished leave a short result comment and run task_complete again. Never skip this comment -> reopen -> work -> comment -> done cycle. - Direct messages to your team lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. - - If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. + - If a task-scoped update is already recorded in a task comment, do NOT send a duplicate SendMessage to the lead with the same content unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. - If you have no tasks, wait for new assignments.`; } @@ -640,6 +640,12 @@ function buildTaskStatusProtocol(teamName: string): string { - After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified. - After that, run task_complete again before your reply. - Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved. + - After task_complete, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer: + { teamName: "${teamName}", taskId: "", from: "", reviewer: "" } + Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists. + If no team member has a reviewer role, skip review_request — the task stays completed.` + + // No reviewer in team → user can manually drag to review from kanban if needed + ` 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } 5. If review fails and changes are needed, use MCP tool review_request_changes: @@ -654,7 +660,7 @@ function buildTaskStatusProtocol(teamName: string): string { 8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment: { teamName: "${teamName}", taskId: "", text: "", from: "" } Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. - Do NOT send a duplicate SendMessage to the lead for the same task-scoped update unless you need urgent non-task attention. + Do NOT send a duplicate SendMessage to the lead for the same task-scoped update unless you need urgent non-task attention. When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. Direct messages to the lead are only for urgent attention, no-task situations, or when the lead explicitly asked for a direct reply. 9. When sending a message about a specific task, include its short display label like # in your SendMessage summary field for traceability. 10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234"). @@ -663,8 +669,10 @@ function buildTaskStatusProtocol(teamName: string): string { - If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task). - Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED. - Typical flow: - a) Owner finishes work on #X -> task_complete #X - b) Reviewer accepts -> review_approve #X + a) Owner finishes work on #X -> task_complete #X -> review_request #X (moves to review column, notifies reviewer) + b) Reviewer receives notification, reviews the work + c) Reviewer accepts -> review_approve #X + d) Reviewer rejects -> review_request_changes #X 12. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY): When you are blocked and need information to continue a task, you MUST do ALL steps below — skipping the board update or comment breaks traceability: a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification: @@ -770,6 +778,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Review requests are also handled by the board runtime: review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request unless you are adding materially new context that is not already on the task.`, `- If you receive a task-scoped system notification like "Comment on #...", treat it as requiring an on-task reply. Reply via task_add_comment on that task; do NOT continue the same discussion only in direct messages.`, `- Teammate task comments are auto-forwarded to you. When that happens, you MUST reply on-task first. Direct messages are allowed only as an additional urgent wake-up ping or clearly non-task coordination, never as the only reply to the task comment.`, + `- When you skip sending a message because it would be a duplicate or was already delivered, produce NO text output about it. Do not write meta-commentary like "(Already relayed…)", "(No additional relay needed…)", or similar. Just silently move on.`, `- Ownership must reflect the person actually doing the implementation/fix work. If someone takes over execution, update the owner immediately before they start. Do NOT leave the lead/planner as owner when another member is doing the work.`, `- Set createdBy when creating tasks so workflow history shows who created the task.`, ``, @@ -845,7 +854,7 @@ Constraints: - Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). - Do NOT shut down, terminate, or clean up the team or its members. - Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate. -- Keep assistant text minimal. +- Keep assistant text minimal. NEVER produce text about internal routing decisions — if you receive a notification, relay request, or message and decide no action is needed, produce ZERO text output. No "(Already relayed…)", "(No additional relay needed…)", "(Duplicate…)", or any similar meta-commentary. If there is nothing to do, say nothing. - 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. @@ -1020,7 +1029,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { Pick the best default owner, create an investigation/triage task for that teammate immediately, and note assumptions in the task description or a task comment. That teammate should inspect the codebase, refine scope, and create follow-up tasks if needed. - If that teammate already has another in_progress task, create/keep the new task in pending/TODO. Do NOT mark it in_progress for them yet. - - Avoid duplicate notifications for the same assignment (one message per member per topic is enough). + - Avoid duplicate notifications for the same assignment (one message per member per topic is enough). When skipping a message, stay silent — never output meta-commentary about skipped or already-delivered messages. - When tasks have natural ordering (e.g. setup -> implementation -> testing), use blockedBy relationships. - If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress. - Review guidance: @@ -1031,15 +1040,24 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task_start / task_set_status in_progress) when ready. - Use related to connect tasks working on the same feature without blocking.`; + const bootstrapEnabledForCreate = isMemberBriefingBootstrapEnabled(); const step2Block = isSolo ? '2) Skip — this is a solo team with no teammates to spawn.' - : `2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown: + : `2) Spawn each member as a live teammate using the Task tool: + - team_name: “${request.teamName}” + - name: the member's name (see per-member list below) + - subagent_type: “general-purpose” + - IMPORTANT: Use the exact prompt shown for each member. + ${ + bootstrapEnabledForCreate + ? 'With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.' + : 'This prompt includes the full durable teammate rules directly.' + } -// IMPORTANT: Use the exact prompt shown for each member. -// With member_briefing bootstrap enabled, the teammate will fetch durable task/process rules after spawn. + Per-member spawn instructions: ${request.members .map( - (m) => ` For “${m.name}”: + (m) => ` For “${m.name}” (name: “${m.name}”): - prompt: ${buildMemberSpawnPrompt( m, @@ -4157,6 +4175,7 @@ export class TeamProvisioningService { /** * Intercept Task tool_use blocks that spawn team members. * Sets member spawn status to 'spawning' when the lead issues a Task call with team_name + name. + * Detects bad spawns (missing team_name/name) and marks them as 'error'. */ private captureTeamSpawnEvents(run: ProvisioningRun, content: Record[]): void { for (const part of content) { @@ -4166,10 +4185,39 @@ export class TeamProvisioningService { const inp = input as Record; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; - if (!teamName || !memberName) continue; - // Only track spawns for this team - if (teamName !== run.teamName) continue; - this.setMemberSpawnStatus(run, memberName, 'spawning'); + + if (teamName && memberName) { + // Good spawn — has both required params + if (teamName !== run.teamName) continue; + this.setMemberSpawnStatus(run, memberName, 'spawning'); + continue; + } + + // Bad spawn — Task tool_use without team_name or name. + // Try to identify which member this was supposed to be from prompt/description text. + if (run.expectedMembers.length === 0) continue; + const desc = typeof inp.description === 'string' ? inp.description : ''; + const prompt = typeof inp.prompt === 'string' ? inp.prompt : ''; + const haystack = `${desc}\n${prompt}`.toLowerCase(); + for (const expected of run.expectedMembers) { + if (haystack.includes(expected.toLowerCase())) { + const missing = !teamName && !memberName + ? 'team_name and name' + : !teamName + ? 'team_name' + : 'name'; + logger.warn( + `[${run.teamName}] Bad teammate spawn for "${expected}": missing ${missing} param(s) in Task tool_use` + ); + this.setMemberSpawnStatus( + run, + expected, + 'error', + `Teammate spawned without ${missing} — will not join the team. Restart the team to fix.` + ); + break; + } + } } } diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx index 4e091f14..d33ab0a5 100644 --- a/src/renderer/components/team/RoleSelect.tsx +++ b/src/renderer/components/team/RoleSelect.tsx @@ -119,6 +119,24 @@ export const RoleSelect = ({ return opt?.label; }, [value]); + const renderTriggerLabel = useCallback( + (option: ComboboxOption) => { + const Icon = + option.value === CUSTOM_ROLE + ? CUSTOM_ICON + : option.value === NO_ROLE + ? null + : (ROLE_ICONS[option.value] ?? null); + return ( + + {Icon ? : null} + {option.label} + + ); + }, + [] + ); + return (
{value === CUSTOM_ROLE && onCustomRoleChange ? (
diff --git a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx new file mode 100644 index 00000000..89788db9 --- /dev/null +++ b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx @@ -0,0 +1,72 @@ +import { Info } from 'lucide-react'; + +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; + +import { AttachmentDisplay } from './AttachmentDisplay'; + +import type { AttachmentMeta, SourceMessageSnapshot } from '@shared/types'; + +interface SourceMessageAttachmentsProps { + teamName: string; + sourceMessageId: string; + sourceMessage: SourceMessageSnapshot; +} + +export const SourceMessageAttachments = ({ + teamName, + sourceMessageId, + sourceMessage, +}: SourceMessageAttachmentsProps): React.JSX.Element | null => { + if (!sourceMessage.attachments?.length) return null; + + const attachments: AttachmentMeta[] = sourceMessage.attachments.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + })); + + const truncatedText = + sourceMessage.text.length > 300 + ? sourceMessage.text.slice(0, 297) + '...' + : sourceMessage.text; + + const formattedDate = (() => { + try { + return new Date(sourceMessage.timestamp).toLocaleString(); + } catch { + return sourceMessage.timestamp; + } + })(); + + return ( +
+
+ + From original message + + + + + + +

+ {sourceMessage.from} · {formattedDate} +

+

+ {truncatedText} +

+
+
+
+ +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 4fa59aec..8e47bb52 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -107,10 +107,21 @@ const DEV_DEFAULT_TEAM = { teamName: 'signal-ops', } as const; -const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [ - { name: 'alice', roleSelection: 'reviewer' }, +const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [ + { + name: 'alice', + roleSelection: 'reviewer', + workflow: + 'Review every completed task in the project. Read the code changes, check for correctness, style, and potential issues. Approve the task or request changes with clear feedback.', + }, + { + name: 'tom', + roleSelection: 'reviewer', + workflow: + 'Review every completed task in the project. Read the code changes, check for correctness, style, and potential issues. Approve the task or request changes with clear feedback.', + }, { name: 'bob', roleSelection: 'developer' }, - { name: 'carol', roleSelection: 'developer' }, + { name: 'jack', roleSelection: 'developer' }, ]; /** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */ @@ -425,6 +436,25 @@ export const CreateTeamDialog = ({ if (cancelled) { return; } + + // If defaultProjectPath is set but not in the fetched list (e.g. new project + // without Claude sessions), add it as a synthetic entry so the Combobox can + // display and select it. + if ( + defaultProjectPath && + !nextProjects.some((p) => normalizePath(p.path) === defaultProjectPath) + ) { + const folderName = + defaultProjectPath.split(/[/\\]/).filter(Boolean).pop() ?? defaultProjectPath; + nextProjects.unshift({ + id: defaultProjectPath.replace(/[/\\]/g, '-'), + path: defaultProjectPath, + name: folderName, + sessions: [], + createdAt: Date.now(), + }); + } + setProjects(nextProjects); } catch (error) { if (cancelled) { @@ -442,7 +472,7 @@ export const CreateTeamDialog = ({ return () => { cancelled = true; }; - }, [open]); + }, [open, defaultProjectPath]); useEffect(() => { if (!open) { @@ -479,6 +509,7 @@ export const CreateTeamDialog = ({ createMemberDraft({ name: member.name, roleSelection: member.roleSelection, + workflow: member.workflow, }) ) ); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index c745aff5..a86c5e50 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -48,7 +48,7 @@ import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/ import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; -import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState'; +import { canDisplayTaskChanges } from '@shared/utils/taskChangeState'; import { deriveTaskDisplayId, formatTaskDisplayLabel, @@ -80,6 +80,7 @@ import { import { WorkflowTimeline } from './StatusHistoryTimeline'; import { TaskAttachments } from './TaskAttachments'; +import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments'; import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -297,8 +298,13 @@ export const TaskDetailDialog = ({ return result; }, [currentTask?.comments]); - // Lazy-load task changes only for terminal, cacheable states. - const canShowTaskChanges = currentTask ? isTaskChangeSummaryCacheable(currentTask) : false; + const sourceAttachmentCount = + currentTask?.sourceMessageId && currentTask?.sourceMessage?.attachments?.length + ? currentTask.sourceMessage.attachments.length + : 0; + + // Lazy-load task changes for any displayable state (in_progress, review, approved, completed). + const canShowTaskChanges = currentTask ? canDisplayTaskChanges(currentTask) : false; const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]); const taskChangeRequestOptions = useMemo( () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), @@ -915,17 +921,31 @@ export const TaskDetailDialog = ({ title="Attachments" icon={} badge={ - (currentTask.attachments?.length ?? 0) + commentImageAttachments.length > 0 - ? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length + (currentTask.attachments?.length ?? 0) + + commentImageAttachments.length + + sourceAttachmentCount > + 0 + ? (currentTask.attachments?.length ?? 0) + + commentImageAttachments.length + + sourceAttachmentCount : undefined } contentClassName="pl-2.5" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" defaultOpen={ - (currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0 + (currentTask.attachments?.length ?? 0) > 0 || + commentImageAttachments.length > 0 || + sourceAttachmentCount > 0 } > + {currentTask.sourceMessageId && currentTask.sourceMessage ? ( + + ) : null} 0; const hasBlocks = blocksIds.length > 0; - // Lazy-check if task has file changes (only for done/review/approved columns) - const showChangesColumn = - (columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges; + // Lazy-check if task has file changes const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); - const useTerminalSummaryCache = useMemo( - () => isTaskSummaryCacheableForOptions(taskChangeRequestOptions), - [taskChangeRequestOptions] + const canDisplay = useMemo( + () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, + [taskChangeRequestOptions, onViewChanges] ); const cacheKey = useMemo( () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), @@ -256,12 +254,11 @@ export const KanbanTaskCard = ({ const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); useEffect(() => { - if (showChangesColumn && useTerminalSummaryCache && taskHasChanges !== true) { + if (canDisplay && taskHasChanges === undefined) { void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); } }, [ - showChangesColumn, - useTerminalSummaryCache, + canDisplay, task.id, teamName, taskHasChanges, @@ -269,10 +266,10 @@ export const KanbanTaskCard = ({ taskChangeRequestOptions, ]); - const isReviewManual = columnId === 'review' && !hasReviewers; + const isReviewManual = columnId === 'review' && !hasReviewers && !task.reviewer; const metaActions = ( <> - {showChangesColumn && taskHasChanges === true ? ( + {canDisplay && taskHasChanges === true ? ( } @@ -280,7 +277,7 @@ export const KanbanTaskCard = ({ className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" onClick={(e) => { e.stopPropagation(); - onViewChanges(task.id); + onViewChanges!(task.id); }} /> ) : null} diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index 616cae52..f84a293a 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -24,6 +24,8 @@ interface ComboboxProps { disabled?: boolean; className?: string; renderOption?: (option: ComboboxOption, isSelected: boolean, query: string) => React.ReactNode; + /** Custom label renderer for the trigger button (closed state). */ + renderTriggerLabel?: (option: ComboboxOption) => React.ReactNode; /** Label for the reset item shown at the top of the dropdown. */ resetLabel?: string; /** Called when the user clicks the reset item. */ @@ -40,6 +42,7 @@ export const Combobox = ({ disabled = false, className, renderOption, + renderTriggerLabel, resetLabel, onReset, }: ComboboxProps): React.JSX.Element => { @@ -64,7 +67,9 @@ export const Combobox = ({ )} > - {selectedOption ? selectedOption.label : placeholder} + {selectedOption + ? (renderTriggerLabel?.(selectedOption) ?? selectedOption.label) + : placeholder} diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index c2c652f4..aceba6dd 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -1314,10 +1314,8 @@ export const createChangeReviewSlice: StateCreator { 'Can you review the approach?', undefined, undefined, - undefined + undefined, + expect.any(String) ); });