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:
parent
2131ad32d0
commit
48b485c637
14 changed files with 285 additions and 49 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} · {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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,8 @@ describe('ipc teams handlers', () => {
|
|||
'Can you review the approach?',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
undefined,
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue