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.
This commit is contained in:
iliya 2026-03-16 16:48:28 +02:00
parent 2131ad32d0
commit 48b485c637
14 changed files with 285 additions and 49 deletions

View file

@ -1,3 +1,5 @@
import crypto from 'crypto';
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
import { getAppIconPath } from '@main/utils/appIcon'; import { getAppIconPath } from '@main/utils/appIcon';
import { getAppDataPath } from '@main/utils/pathDecoder'; import { getAppDataPath } from '@main/utils/pathDecoder';
@ -1204,12 +1206,19 @@ async function handleSendMessage(
// Smart routing: lead + alive → stdin direct, else → inbox // Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) { if (isLeadRecipient && isAlive) {
const resolvedLeadName = leadName ?? memberName; 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 // Separate try blocks: stdin delivery vs persistence
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) // 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) // Wrap with instructions so lead responds with visible text (not just agent-only blocks)
const wrappedText = [ const wrappedText = [
`You received a direct message from the user.`, `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.`, `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:`, `Message from user:`,
buildMessageDeliveryText(payload.text!, { buildMessageDeliveryText(payload.text!, {
@ -1252,11 +1261,12 @@ async function handleSendMessage(
payload.text!, payload.text!,
payload.summary, payload.summary,
attachmentMeta, attachmentMeta,
validatedTaskRefs.value validatedTaskRefs.value,
preGeneratedMessageId
); );
} catch (persistError) { } catch (persistError) {
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` }; result = { deliveredToInbox: false, messageId: preGeneratedMessageId };
} }
// Save attachment binary data to disk (best-effort) // Save attachment binary data to disk (best-effort)

View file

@ -101,4 +101,7 @@ export class TeamAttachmentStore {
return result; 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.
} }

View file

@ -873,12 +873,15 @@ export class TeamDataService {
// Skip inbox notification when lead starts their own task (solo teams) // Skip inbox notification when lead starts their own task (solo teams)
if (!this.isLeadOwner(task.owner, leadName)) { 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()) { if (task.description?.trim()) {
parts.push(`\nDetails:\n${task.description.trim()}`); parts.push(`\nDetails:\n${task.description.trim()}`);
} }
parts.push( parts.push(
`\n${AGENT_BLOCK_OPEN}`, `\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:`, `Update task status using the board MCP tools:`,
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
AGENT_BLOCK_CLOSE AGENT_BLOCK_CLOSE
@ -888,7 +891,7 @@ export class TeamDataService {
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
taskRefs: task.descriptionTaskRefs, taskRefs: task.descriptionTaskRefs,
summary: `Task ${this.getTaskLabel(task)} started`, summary: `Start working on ${this.getTaskLabel(task)}`,
source: 'system_notification', source: 'system_notification',
}); });
} }
@ -1510,7 +1513,8 @@ export class TeamDataService {
text: string, text: string,
summary?: string, summary?: string,
attachments?: AttachmentMeta[], attachments?: AttachmentMeta[],
taskRefs?: TaskRef[] taskRefs?: TaskRef[],
messageId?: string
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
let leadSessionId: string | undefined; let leadSessionId: string | undefined;
try { try {
@ -1529,6 +1533,7 @@ export class TeamDataService {
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined, attachments: attachments?.length ? attachments : undefined,
leadSessionId, leadSessionId,
...(messageId ? { messageId } : {}),
}) as InboxMessage; }) as InboxMessage;
return { return {
deliveredToInbox: false, deliveredToInbox: false,

View file

@ -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 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. - 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. - 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()} ${buildTeammateAgentBlockReminder()}
${actionModeProtocol}`; ${actionModeProtocol}`;
} }
@ -545,7 +545,7 @@ ${actionModeProtocol}
- Only then run task_start when you truly begin. - 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. - 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. - 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.`; - 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 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. - 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. - 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: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
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: 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: "<taskId>", note?: "<optional note>", notifyOwner: true } { teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
5. If review fails and changes are needed, use MCP tool review_request_changes: 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: 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: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" } { teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. 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. 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 #<displayId> in your SendMessage summary field for traceability. 9. When sending a message about a specific task, include its short display label like #<displayId> 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"). 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). - 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. - 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: - Typical flow:
a) Owner finishes work on #X -> task_complete #X a) Owner finishes work on #X -> task_complete #X -> review_request #X (moves to review column, notifies reviewer)
b) Reviewer accepts -> review_approve #X 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): 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: 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: 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.`, `- 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.`, `- 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.`, `- 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.`, `- 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.`, `- 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 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 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. - 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. - 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. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work. - 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. 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. 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. - 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. - 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. - 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: - 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. - 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.`; - Use related to connect tasks working on the same feature without blocking.`;
const bootstrapEnabledForCreate = isMemberBriefingBootstrapEnabled();
const step2Block = isSolo const step2Block = isSolo
? '2) Skip — this is a solo team with no teammates to spawn.' ? '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. Per-member spawn instructions:
// With member_briefing bootstrap enabled, the teammate will fetch durable task/process rules after spawn.
${request.members ${request.members
.map( .map(
(m) => ` For “${m.name}”: (m) => ` For “${m.name} (name: “${m.name}”):
- prompt: - prompt:
${buildMemberSpawnPrompt( ${buildMemberSpawnPrompt(
m, m,
@ -4157,6 +4175,7 @@ export class TeamProvisioningService {
/** /**
* Intercept Task tool_use blocks that spawn team members. * 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. * 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<string, unknown>[]): void { private captureTeamSpawnEvents(run: ProvisioningRun, content: Record<string, unknown>[]): void {
for (const part of content) { for (const part of content) {
@ -4166,10 +4185,39 @@ export class TeamProvisioningService {
const inp = input as Record<string, unknown>; const inp = input as Record<string, unknown>;
const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : '';
const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : '';
if (!teamName || !memberName) continue;
// Only track spawns for this team if (teamName && memberName) {
if (teamName !== run.teamName) continue; // Good spawn — has both required params
this.setMemberSpawnStatus(run, memberName, 'spawning'); 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;
}
}
} }
} }

View file

@ -119,6 +119,24 @@ export const RoleSelect = ({
return opt?.label; return opt?.label;
}, [value]); }, [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 (
<span className="flex items-center gap-1.5">
{Icon ? <Icon className="size-3 text-[var(--color-text-muted)]" /> : null}
{option.label}
</span>
);
},
[]
);
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Combobox <Combobox
@ -131,6 +149,7 @@ export const RoleSelect = ({
disabled={disabled} disabled={disabled}
className={triggerClassName} className={triggerClassName}
renderOption={renderRoleOption} renderOption={renderRoleOption}
renderTriggerLabel={renderTriggerLabel}
/> />
{value === CUSTOM_ROLE && onCustomRoleChange ? ( {value === CUSTOM_ROLE && onCustomRoleChange ? (
<div> <div>

View file

@ -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 (
<div className="mb-2">
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[11px] font-medium text-[var(--color-text-muted)]">
From original message
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info
size={12}
className="cursor-help text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
/>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-[11px] font-medium">
{sourceMessage.from} &middot; {formattedDate}
</p>
<p className="mt-0.5 whitespace-pre-wrap text-[11px] text-[var(--color-text-secondary)]">
{truncatedText}
</p>
</TooltipContent>
</Tooltip>
</div>
<AttachmentDisplay
teamName={teamName}
messageId={sourceMessageId}
attachments={attachments}
/>
</div>
);
};

View file

@ -107,10 +107,21 @@ const DEV_DEFAULT_TEAM = {
teamName: 'signal-ops', teamName: 'signal-ops',
} as const; } as const;
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [ const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }[] = [
{ name: 'alice', roleSelection: 'reviewer' }, {
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: 'bob', roleSelection: 'developer' },
{ name: 'carol', roleSelection: 'developer' }, { name: 'jack', roleSelection: 'developer' },
]; ];
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */ /** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
@ -425,6 +436,25 @@ export const CreateTeamDialog = ({
if (cancelled) { if (cancelled) {
return; 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); setProjects(nextProjects);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
@ -442,7 +472,7 @@ export const CreateTeamDialog = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open]); }, [open, defaultProjectPath]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -479,6 +509,7 @@ export const CreateTeamDialog = ({
createMemberDraft({ createMemberDraft({
name: member.name, name: member.name,
roleSelection: member.roleSelection, roleSelection: member.roleSelection,
workflow: member.workflow,
}) })
) )
); );

View file

@ -48,7 +48,7 @@ import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState'; import { canDisplayTaskChanges } from '@shared/utils/taskChangeState';
import { import {
deriveTaskDisplayId, deriveTaskDisplayId,
formatTaskDisplayLabel, formatTaskDisplayLabel,
@ -80,6 +80,7 @@ import {
import { WorkflowTimeline } from './StatusHistoryTimeline'; import { WorkflowTimeline } from './StatusHistoryTimeline';
import { TaskAttachments } from './TaskAttachments'; import { TaskAttachments } from './TaskAttachments';
import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments';
import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply'; import { TaskCommentAwaitingReply } from './TaskCommentAwaitingReply';
import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentInput } from './TaskCommentInput';
import { TaskCommentsSection } from './TaskCommentsSection'; import { TaskCommentsSection } from './TaskCommentsSection';
@ -297,8 +298,13 @@ export const TaskDetailDialog = ({
return result; return result;
}, [currentTask?.comments]); }, [currentTask?.comments]);
// Lazy-load task changes only for terminal, cacheable states. const sourceAttachmentCount =
const canShowTaskChanges = currentTask ? isTaskChangeSummaryCacheable(currentTask) : false; 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 taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
const taskChangeRequestOptions = useMemo( const taskChangeRequestOptions = useMemo(
() => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null),
@ -915,17 +921,31 @@ export const TaskDetailDialog = ({
title="Attachments" title="Attachments"
icon={<ImageIcon size={14} />} icon={<ImageIcon size={14} />}
badge={ badge={
(currentTask.attachments?.length ?? 0) + commentImageAttachments.length > 0 (currentTask.attachments?.length ?? 0) +
? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length commentImageAttachments.length +
sourceAttachmentCount >
0
? (currentTask.attachments?.length ?? 0) +
commentImageAttachments.length +
sourceAttachmentCount
: undefined : undefined
} }
contentClassName="pl-2.5" contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]" headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6" headerContentClassName="pl-6"
defaultOpen={ defaultOpen={
(currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0 (currentTask.attachments?.length ?? 0) > 0 ||
commentImageAttachments.length > 0 ||
sourceAttachmentCount > 0
} }
> >
{currentTask.sourceMessageId && currentTask.sourceMessage ? (
<SourceMessageAttachments
teamName={teamName}
sourceMessageId={currentTask.sourceMessageId}
sourceMessage={currentTask.sourceMessage}
/>
) : null}
<TaskAttachments <TaskAttachments
teamName={teamName} teamName={teamName}
taskId={currentTask.id} taskId={currentTask.id}

View file

@ -11,7 +11,7 @@ import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/membe
import { import {
buildTaskChangePresenceKey, buildTaskChangePresenceKey,
buildTaskChangeRequestOptions, buildTaskChangeRequestOptions,
isTaskSummaryCacheableForOptions, canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
@ -240,13 +240,11 @@ export const KanbanTaskCard = ({
const hasBlockedBy = blockedByIds.length > 0; const hasBlockedBy = blockedByIds.length > 0;
const hasBlocks = blocksIds.length > 0; const hasBlocks = blocksIds.length > 0;
// Lazy-check if task has file changes (only for done/review/approved columns) // Lazy-check if task has file changes
const showChangesColumn =
(columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges;
const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]);
const useTerminalSummaryCache = useMemo( const canDisplay = useMemo(
() => isTaskSummaryCacheableForOptions(taskChangeRequestOptions), () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges,
[taskChangeRequestOptions] [taskChangeRequestOptions, onViewChanges]
); );
const cacheKey = useMemo( const cacheKey = useMemo(
() => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions),
@ -256,12 +254,11 @@ export const KanbanTaskCard = ({
const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges);
useEffect(() => { useEffect(() => {
if (showChangesColumn && useTerminalSummaryCache && taskHasChanges !== true) { if (canDisplay && taskHasChanges === undefined) {
void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions);
} }
}, [ }, [
showChangesColumn, canDisplay,
useTerminalSummaryCache,
task.id, task.id,
teamName, teamName,
taskHasChanges, taskHasChanges,
@ -269,10 +266,10 @@ export const KanbanTaskCard = ({
taskChangeRequestOptions, taskChangeRequestOptions,
]); ]);
const isReviewManual = columnId === 'review' && !hasReviewers; const isReviewManual = columnId === 'review' && !hasReviewers && !task.reviewer;
const metaActions = ( const metaActions = (
<> <>
{showChangesColumn && taskHasChanges === true ? ( {canDisplay && taskHasChanges === true ? (
<TaskActionIconButton <TaskActionIconButton
label="Changes" label="Changes"
icon={<FileCode className="size-2.5" />} icon={<FileCode className="size-2.5" />}
@ -280,7 +277,7 @@ export const KanbanTaskCard = ({
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onViewChanges(task.id); onViewChanges!(task.id);
}} }}
/> />
) : null} ) : null}

View file

@ -24,6 +24,8 @@ interface ComboboxProps {
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
renderOption?: (option: ComboboxOption, isSelected: boolean, query: string) => React.ReactNode; 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. */ /** Label for the reset item shown at the top of the dropdown. */
resetLabel?: string; resetLabel?: string;
/** Called when the user clicks the reset item. */ /** Called when the user clicks the reset item. */
@ -40,6 +42,7 @@ export const Combobox = ({
disabled = false, disabled = false,
className, className,
renderOption, renderOption,
renderTriggerLabel,
resetLabel, resetLabel,
onReset, onReset,
}: ComboboxProps): React.JSX.Element => { }: ComboboxProps): React.JSX.Element => {
@ -64,7 +67,9 @@ export const Combobox = ({
)} )}
> >
<span className="min-w-0 truncate text-left"> <span className="min-w-0 truncate text-left">
{selectedOption ? selectedOption.label : placeholder} {selectedOption
? (renderTriggerLabel?.(selectedOption) ?? selectedOption.label)
: placeholder}
</span> </span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button> </button>

View file

@ -1314,10 +1314,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const summaryCacheable = isTaskSummaryCacheableForOptions(options); const summaryCacheable = isTaskSummaryCacheableForOptions(options);
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return; if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return;
if (taskChangesCheckInFlight.has(cacheKey)) return; if (taskChangesCheckInFlight.has(cacheKey)) return;
if (summaryCacheable) { const negativeTs = taskChangesNegativeCache.get(cacheKey);
const negativeTs = taskChangesNegativeCache.get(cacheKey); if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return;
}
taskChangesCheckInFlight.add(cacheKey); taskChangesCheckInFlight.add(cacheKey);
try { try {

View file

@ -130,3 +130,12 @@ export function isTaskSummaryCacheableForOptions(
): boolean { ): boolean {
return isTaskChangeSummaryCacheable(getTaskChangeStateBucketFromOptions(options)); return isTaskChangeSummaryCacheable(getTaskChangeStateBucketFromOptions(options));
} }
export function canDisplayTaskChangesForOptions(
options: TaskChangeRequestOptions | null | undefined
): boolean {
const bucket = getTaskChangeStateBucketFromOptions(options);
if (bucket !== 'active') return true;
// 'active' bucket includes both pending and in_progress — show for in_progress only
return options?.status === 'in_progress';
}

View file

@ -46,3 +46,21 @@ export function isTaskChangeSummaryCacheable(
typeof taskOrBucket === 'string' ? taskOrBucket : getTaskChangeStateBucket(taskOrBucket); typeof taskOrBucket === 'string' ? taskOrBucket : getTaskChangeStateBucket(taskOrBucket);
return bucket === 'completed' || bucket === 'approved'; return bucket === 'completed' || bucket === 'approved';
} }
/**
* Whether a task can display its file changes in the UI.
* Unlike `isTaskChangeSummaryCacheable` (permanent-cache gate for terminal states),
* this returns true for any task that could plausibly have changes:
* in_progress, review, approved, completed everything except pending/backlog.
*/
export function canDisplayTaskChanges(
taskOrBucket: TaskChangeStateLike | TaskChangeStateBucket
): boolean {
if (typeof taskOrBucket === 'string') {
return taskOrBucket !== 'active';
}
const bucket = getTaskChangeStateBucket(taskOrBucket);
if (bucket !== 'active') return true;
// 'active' bucket includes both pending and in_progress — show for in_progress only
return taskOrBucket.status === 'in_progress';
}

View file

@ -268,7 +268,8 @@ describe('ipc teams handlers', () => {
'Can you review the approach?', 'Can you review the approach?',
undefined, undefined,
undefined, undefined,
undefined undefined,
expect.any(String)
); );
}); });