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 { 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)

View file

@ -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.
}

View file

@ -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<SendMessageResult> {
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,

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 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: "<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:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional 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: "<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 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 #<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").
@ -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<string, unknown>[]): void {
for (const part of content) {
@ -4166,10 +4185,39 @@ export class TeamProvisioningService {
const inp = input as Record<string, unknown>;
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;
}
}
}
}

View file

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

View file

@ -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={<ImageIcon size={14} />}
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 ? (
<SourceMessageAttachments
teamName={teamName}
sourceMessageId={currentTask.sourceMessageId}
sourceMessage={currentTask.sourceMessage}
/>
) : null}
<TaskAttachments
teamName={teamName}
taskId={currentTask.id}

View file

@ -11,7 +11,7 @@ import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/membe
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
isTaskSummaryCacheableForOptions,
canDisplayTaskChangesForOptions,
} from '@renderer/utils/taskChangeRequest';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
@ -240,13 +240,11 @@ export const KanbanTaskCard = ({
const hasBlockedBy = blockedByIds.length > 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 ? (
<TaskActionIconButton
label="Changes"
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"
onClick={(e) => {
e.stopPropagation();
onViewChanges(task.id);
onViewChanges!(task.id);
}}
/>
) : null}

View file

@ -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 = ({
)}
>
<span className="min-w-0 truncate text-left">
{selectedOption ? selectedOption.label : placeholder}
{selectedOption
? (renderTriggerLabel?.(selectedOption) ?? selectedOption.label)
: placeholder}
</span>
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
</button>

View file

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

View file

@ -130,3 +130,12 @@ export function isTaskSummaryCacheableForOptions(
): boolean {
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);
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?',
undefined,
undefined,
undefined
undefined,
expect.any(String)
);
});