feat: enhance notification handling and improve task comment protocols
- Added debug logging for test notification handling in the NotificationManager, improving traceability during notification operations. - Implemented unescaping of literal newline sequences in task descriptions and comments across various components, ensuring proper formatting. - Updated task comment handling logic to clarify awaiting replies from task responders, enhancing user awareness of task interactions. - Refined UI components to improve user experience in settings and task management, including adjustments to the NotificationsSection and TeamDetailView.
This commit is contained in:
parent
cb6a41d899
commit
f5efa17b1a
20 changed files with 453 additions and 85 deletions
|
|
@ -194,8 +194,11 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise<number>
|
||||||
*/
|
*/
|
||||||
function handleTestNotification(_event: IpcMainInvokeEvent): { success: boolean; error?: string } {
|
function handleTestNotification(_event: IpcMainInvokeEvent): { success: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
|
logger.debug('Handling notifications:testNotification request');
|
||||||
const manager = NotificationManager.getInstance();
|
const manager = NotificationManager.getInstance();
|
||||||
return manager.sendTestNotification();
|
const result = manager.sendTestNotification();
|
||||||
|
logger.debug(`notifications:testNotification result: success=${String(result.success)}`);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in notifications:testNotification:', error);
|
logger.error('Error in notifications:testNotification:', error);
|
||||||
return { success: false, error: getErrorMessage(error) };
|
return { success: false, error: getErrorMessage(error) };
|
||||||
|
|
|
||||||
|
|
@ -495,11 +495,13 @@ export class NotificationManager extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
sendTestNotification(): { success: boolean; error?: string } {
|
sendTestNotification(): { success: boolean; error?: string } {
|
||||||
if (!this.isNativeNotificationSupported()) {
|
if (!this.isNativeNotificationSupported()) {
|
||||||
|
logger.warn('[test-notification] native notifications not supported');
|
||||||
return { success: false, error: 'Native notifications are not supported on this platform' };
|
return { success: false, error: 'Native notifications are not supported on this platform' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const iconPath = isMac ? undefined : getAppIconPath();
|
const iconPath = isMac ? undefined : getAppIconPath();
|
||||||
|
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: 'Test Notification',
|
title: 'Test Notification',
|
||||||
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
|
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,7 @@ After member_briefing succeeds:
|
||||||
- Then wait for task assignments.
|
- Then wait for task assignments.
|
||||||
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
||||||
- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.
|
- If a newly assigned task cannot be started immediately because you are still busy on another task, leave a short task comment on that waiting task right away with the reason and your best ETA, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.
|
||||||
|
- 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.
|
||||||
|
|
@ -490,6 +491,7 @@ ${actionModeProtocol}
|
||||||
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
||||||
- Before you start any needsFix or pending task, call task_get for that specific task.
|
- Before you start any needsFix or pending task, call task_get for that specific task.
|
||||||
- If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin.
|
- If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin.
|
||||||
|
- 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.
|
||||||
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
|
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
|
||||||
- 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.
|
||||||
|
|
@ -531,6 +533,7 @@ ${actionModeProtocol}
|
||||||
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
|
||||||
- Before you start any needsFix or pending task, call task_get for that specific task.
|
- Before you start any needsFix or pending task, call task_get for that specific task.
|
||||||
- If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin.
|
- If a newly assigned needsFix or pending task must wait because you are still finishing another task, leave a short task comment on that waiting task with the reason and your best ETA, keep it in pending/TODO (use task_set_status pending if needed), and only run task_start when you truly begin.
|
||||||
|
- 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.
|
||||||
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
|
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
|
||||||
- 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.
|
||||||
|
|
@ -638,6 +641,9 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
|
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
|
||||||
7. To reply to a comment on a task, use MCP tool task_add_comment:
|
7. To reply to a comment on a task, use MCP tool task_add_comment:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", text: "<your reply>", from: "<your-name>" }
|
{ teamName: "${teamName}", taskId: "<taskId>", text: "<your reply>", from: "<your-name>" }
|
||||||
|
- If a user, lead, or teammate comments on a task you own, are reviewing, or are actively handling, you MUST reply on that task. Never leave task comments unanswered.
|
||||||
|
- If more work is needed, reply in the task comments FIRST, then reopen/start the task if needed, do the work, and finish with another task comment.
|
||||||
|
- Direct messages and status changes are optional supplements only; they NEVER replace the required on-task reply.
|
||||||
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.
|
||||||
|
|
@ -754,14 +760,15 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
`Notification policy:`,
|
`Notification policy:`,
|
||||||
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra context that is not already on the task.`,
|
`- Task assignment notifications are handled by the board runtime, so do NOT send a separate SendMessage for the same assignment unless you have extra 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.`,
|
`- 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 the task as the source of truth and prefer replying via task_add_comment instead of continuing the same task discussion 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, respond on-task first; use direct messages only for urgent wake-up pings or clearly non-task coordination.`,
|
`- 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.`,
|
||||||
`- 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.`,
|
||||||
``,
|
``,
|
||||||
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
|
||||||
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage.`,
|
`- When a teammate needs clarification (needsClarification: "lead"), you MUST reply via task comment first. This is the durable answer, auto-clears the flag, and wakes the owner.`,
|
||||||
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
|
`- If you also send a SendMessage for urgency/visibility, treat it as an extra notification only — never as a substitute for the task-comment reply.`,
|
||||||
|
`- If you somehow reply via SendMessage before commenting, add the missing task comment immediately, and if needed also clear the flag manually:`,
|
||||||
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
` task_set_clarification { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }`,
|
||||||
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
|
||||||
` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`,
|
` 1) FIRST, set the flag to "user" via MCP tool task_set_clarification (this updates the task board):`,
|
||||||
|
|
@ -3710,7 +3717,7 @@ export class TeamProvisioningService {
|
||||||
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
|
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
|
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
|
||||||
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", treat it as a task-comment notification. Prefer replying on the task via task_add_comment rather than continuing the same task discussion in direct messages.`,
|
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", treat it as a task-comment notification that REQUIRES an on-task reply via task_add_comment. Do NOT treat a direct message as a sufficient substitute.`,
|
||||||
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
|
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
|
||||||
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
|
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,23 @@ import type {
|
||||||
TaskRef,
|
TaskRef,
|
||||||
TaskWorkInterval,
|
TaskWorkInterval,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise escaped newline sequences (`\\n`) that some MCP/CLI sources
|
||||||
|
* write as literal two-character strings instead of real line-breaks.
|
||||||
|
* Also handles `\\t` for consistency. Only operates on isolated escape
|
||||||
|
* sequences — already-real newlines are left untouched.
|
||||||
|
*/
|
||||||
|
function unescapeLiteralNewlines(text: string): string {
|
||||||
|
// Replace literal two-char sequences \n and \t with real control chars.
|
||||||
|
// The regex matches a single backslash followed by 'n' or 't'.
|
||||||
|
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||||
|
}
|
||||||
|
|
||||||
function isValidMimeTypeString(value: unknown): value is string {
|
function isValidMimeTypeString(value: unknown): value is string {
|
||||||
if (typeof value !== 'string') return false;
|
if (typeof value !== 'string') return false;
|
||||||
const v = value.trim();
|
const v = value.trim();
|
||||||
|
|
@ -170,7 +181,10 @@ export class TeamTaskReader {
|
||||||
: ''
|
: ''
|
||||||
),
|
),
|
||||||
subject,
|
subject,
|
||||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
description:
|
||||||
|
typeof parsed.description === 'string'
|
||||||
|
? unescapeLiteralNewlines(parsed.description)
|
||||||
|
: undefined,
|
||||||
descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs),
|
descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs),
|
||||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined,
|
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined,
|
||||||
|
|
@ -209,6 +223,7 @@ export class TeamTaskReader {
|
||||||
)
|
)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
text: unescapeLiteralNewlines(c.text),
|
||||||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||||
? c.type
|
? c.type
|
||||||
: ('regular' as const),
|
: ('regular' as const),
|
||||||
|
|
@ -369,7 +384,10 @@ export class TeamTaskReader {
|
||||||
: ''
|
: ''
|
||||||
),
|
),
|
||||||
subject,
|
subject,
|
||||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
description:
|
||||||
|
typeof parsed.description === 'string'
|
||||||
|
? unescapeLiteralNewlines(parsed.description)
|
||||||
|
: undefined,
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
status: 'deleted',
|
status: 'deleted',
|
||||||
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ function deriveTaskDisplayId(taskId: string): string {
|
||||||
return UUID_TASK_ID_PATTERN.test(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
|
return UUID_TASK_ID_PATTERN.test(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise escaped newline sequences (`\\n`) that some MCP/CLI sources
|
||||||
|
* write as literal two-character strings instead of real line-breaks.
|
||||||
|
*/
|
||||||
|
function unescapeLiteralNewlines(text: string): string {
|
||||||
|
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Diagnostic types
|
// Diagnostic types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -549,7 +557,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id as string,
|
id: c.id as string,
|
||||||
author: c.author as string,
|
author: c.author as string,
|
||||||
text: c.text as string,
|
text: unescapeLiteralNewlines(c.text as string),
|
||||||
createdAt: c.createdAt as string,
|
createdAt: c.createdAt as string,
|
||||||
taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined,
|
taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined,
|
||||||
type:
|
type:
|
||||||
|
|
@ -651,7 +659,10 @@ async function readTasksDirForTeam(
|
||||||
: ''
|
: ''
|
||||||
),
|
),
|
||||||
subject,
|
subject,
|
||||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
description:
|
||||||
|
typeof parsed.description === 'string'
|
||||||
|
? unescapeLiteralNewlines(parsed.description)
|
||||||
|
: undefined,
|
||||||
descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs)
|
descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs)
|
||||||
? (parsed.descriptionTaskRefs as unknown[])
|
? (parsed.descriptionTaskRefs as unknown[])
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,11 @@ export const NotificationsSection = ({
|
||||||
setTestError(result.error ?? 'Unknown error');
|
setTestError(result.error ?? 'Unknown error');
|
||||||
setTimeout(() => setTestStatus('idle'), 5000);
|
setTimeout(() => setTestStatus('idle'), 5000);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[notifications] testNotification failed:', err);
|
||||||
setTestStatus('error');
|
setTestStatus('error');
|
||||||
setTestError('Failed to send test notification');
|
const message = err instanceof Error ? err.message : 'Failed to send test notification';
|
||||||
|
setTestError(message);
|
||||||
setTimeout(() => setTestStatus('idle'), 5000);
|
setTimeout(() => setTestStatus('idle'), 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -361,7 +363,7 @@ export const NotificationsSection = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="text-sm font-medium"
|
className="text-sm font-medium"
|
||||||
|
|
@ -373,13 +375,11 @@ export const NotificationsSection = ({
|
||||||
Which target statuses trigger a notification
|
Which target statuses trigger a notification
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">
|
<StatusCheckboxGroup
|
||||||
<StatusCheckboxGroup
|
selected={safeConfig.notifications.statusChangeStatuses}
|
||||||
selected={safeConfig.notifications.statusChangeStatuses}
|
onChange={onStatusChangeStatusesUpdate}
|
||||||
onChange={onStatusChangeStatusesUpdate}
|
disabled={saving}
|
||||||
disabled={saving}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,11 @@ import {
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
|
||||||
Square,
|
Square,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -64,6 +62,7 @@ import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
|
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
|
||||||
import { TrashDialog } from './kanban/TrashDialog';
|
import { TrashDialog } from './kanban/TrashDialog';
|
||||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
|
|
||||||
|
|
@ -1432,33 +1431,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onFilterChange={setKanbanFilter}
|
onFilterChange={setKanbanFilter}
|
||||||
onSortChange={setKanbanSort}
|
onSortChange={setKanbanSort}
|
||||||
toolbarLeft={
|
toolbarLeft={
|
||||||
<div className="relative max-w-[240px]">
|
<KanbanSearchInput
|
||||||
<Search
|
value={kanbanSearch}
|
||||||
size={14}
|
onChange={setKanbanSearch}
|
||||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
tasks={filteredTasks}
|
||||||
/>
|
members={activeMembers}
|
||||||
<input
|
/>
|
||||||
type="text"
|
|
||||||
placeholder="Search tasks… (#id or text)"
|
|
||||||
value={kanbanSearch}
|
|
||||||
onChange={(e) => setKanbanSearch(e.target.value)}
|
|
||||||
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
{kanbanSearch && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
onClick={() => setKanbanSearch('')}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">Clear search</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
onRequestReview={(taskId) => {
|
onRequestReview={(taskId) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,7 @@ export const CreateTeamDialog = ({
|
||||||
prompt: prompt.trim() || undefined,
|
prompt: prompt.trim() || undefined,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
|
limitContext,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
extraCliArgs: customArgs.trim() || undefined,
|
extraCliArgs: customArgs.trim() || undefined,
|
||||||
|
|
@ -588,6 +589,7 @@ export const CreateTeamDialog = ({
|
||||||
prompt,
|
prompt,
|
||||||
effectiveModel,
|
effectiveModel,
|
||||||
selectedEffort,
|
selectedEffort,
|
||||||
|
limitContext,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
worktreeEnabled,
|
worktreeEnabled,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
prompt: promptDraft.value.trim() || undefined,
|
||||||
model: computeEffectiveTeamModel(selectedModel, limitContext),
|
model: computeEffectiveTeamModel(selectedModel, limitContext),
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
|
limitContext,
|
||||||
clearContext: clearContext || undefined,
|
clearContext: clearContext || undefined,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,11 @@ import {
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
|
||||||
|
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
ArrowLeftFromLine,
|
ArrowLeftFromLine,
|
||||||
|
|
@ -510,10 +511,14 @@ export const TaskDetailDialog = ({
|
||||||
{formatTaskDisplayLabel(currentTask)}
|
{formatTaskDisplayLabel(currentTask)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
|
||||||
currentTask.reviewer ? (
|
currentTask.reviewer &&
|
||||||
|
currentTask.reviewer !== 'user' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const reviewerColor = colorMap.get(currentTask.reviewer);
|
const reviewerColor = colorMap.get(currentTask.reviewer);
|
||||||
const colors = getTeamColorSet(reviewerColor ?? '');
|
const colors =
|
||||||
|
currentTask.reviewState === 'review'
|
||||||
|
? getTeamColorSet('blue')
|
||||||
|
: getTeamColorSet(reviewerColor ?? '');
|
||||||
const reviewerBadgeStyle = {
|
const reviewerBadgeStyle = {
|
||||||
backgroundColor: getThemedBadge(colors, isLight),
|
backgroundColor: getThemedBadge(colors, isLight),
|
||||||
color: getThemedText(colors, isLight),
|
color: getThemedText(colors, isLight),
|
||||||
|
|
@ -521,7 +526,21 @@ export const TaskDetailDialog = ({
|
||||||
borderRight: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
borderRight: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||||
borderBottom: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
borderBottom: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||||
};
|
};
|
||||||
return (
|
const reviewEventType =
|
||||||
|
currentTask.reviewState === 'approved' ? 'review_approved' : 'review_requested';
|
||||||
|
const lastReviewEvent = currentTask.historyEvents
|
||||||
|
?.filter((e) => e.type === reviewEventType)
|
||||||
|
.at(-1);
|
||||||
|
const reviewDate = lastReviewEvent
|
||||||
|
? new Date(lastReviewEvent.timestamp)
|
||||||
|
: undefined;
|
||||||
|
const reviewTimeLabel =
|
||||||
|
reviewDate && !isNaN(reviewDate.getTime())
|
||||||
|
? Date.now() - reviewDate.getTime() < 24 * 60 * 60 * 1000
|
||||||
|
? formatDistanceToNow(reviewDate, { addSuffix: true })
|
||||||
|
: format(reviewDate, 'MMM d, yyyy HH:mm')
|
||||||
|
: undefined;
|
||||||
|
const badge = (
|
||||||
<span className="inline-flex items-stretch">
|
<span className="inline-flex items-stretch">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-l-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
className={`inline-flex items-center rounded-l-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
||||||
|
|
@ -542,6 +561,14 @@ export const TaskDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
return reviewTimeLabel ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{reviewTimeLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
badge
|
||||||
|
);
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
|
@ -566,7 +593,10 @@ export const TaskDetailDialog = ({
|
||||||
value={subjectDraft}
|
value={subjectDraft}
|
||||||
onChange={(e) => setSubjectDraft(e.target.value)}
|
onChange={(e) => setSubjectDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') void saveSubject();
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
void saveSubject();
|
||||||
|
}
|
||||||
if (e.key === 'Escape') setEditingSubject(false);
|
if (e.key === 'Escape') setEditingSubject(false);
|
||||||
}}
|
}}
|
||||||
onBlur={() => void saveSubject()}
|
onBlur={() => void saveSubject()}
|
||||||
|
|
@ -796,7 +826,14 @@ export const TaskDetailDialog = ({
|
||||||
) : currentTask.description ? (
|
) : currentTask.description ? (
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<ExpandableContent collapsedHeight={200}>
|
<ExpandableContent collapsedHeight={200}>
|
||||||
<MarkdownViewer content={currentTask.description} maxHeight="max-h-none" bare />
|
<MarkdownViewer
|
||||||
|
content={linkifyTaskIdsInMarkdown(
|
||||||
|
currentTask.description,
|
||||||
|
currentTask.descriptionTaskRefs
|
||||||
|
)}
|
||||||
|
maxHeight="max-h-none"
|
||||||
|
bare
|
||||||
|
/>
|
||||||
</ExpandableContent>
|
</ExpandableContent>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
284
src/renderer/components/team/kanban/KanbanSearchInput.tsx
Normal file
284
src/renderer/components/team/kanban/KanbanSearchInput.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { Hash, Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
interface KanbanSearchInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
tasks: TeamTask[];
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SUGGESTIONS = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanban search input with task autocomplete dropdown.
|
||||||
|
* When user types `#`, shows a filterable list of tasks by displayId.
|
||||||
|
* Selecting a task inserts `#<displayId>` into the search field.
|
||||||
|
*/
|
||||||
|
export const KanbanSearchInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tasks,
|
||||||
|
members,
|
||||||
|
}: KanbanSearchInputProps): React.JSX.Element => {
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
/** Prevents the useEffect from reopening the dropdown right after a selection. */
|
||||||
|
const suppressReopenRef = useRef(false);
|
||||||
|
|
||||||
|
// Detect `#` trigger and extract filter text after it
|
||||||
|
const hashMatch = useMemo(() => {
|
||||||
|
const match = value.match(/#(\S*)$/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const isHashMode = hashMatch !== null;
|
||||||
|
|
||||||
|
// Filter tasks by displayId when in hash mode
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
if (!isHashMode) return [];
|
||||||
|
const filter = hashMatch.toLowerCase();
|
||||||
|
const filtered = tasks.filter((t) => {
|
||||||
|
const displayId = getTaskDisplayId(t).toLowerCase();
|
||||||
|
return filter === '' || displayId.includes(filter);
|
||||||
|
});
|
||||||
|
return filtered.slice(0, MAX_SUGGESTIONS);
|
||||||
|
}, [isHashMode, hashMatch, tasks]);
|
||||||
|
|
||||||
|
// Show dropdown when in hash mode with suggestions
|
||||||
|
useEffect(() => {
|
||||||
|
if (suppressReopenRef.current) {
|
||||||
|
suppressReopenRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isHashMode && suggestions.length > 0) {
|
||||||
|
setShowDropdown(true);
|
||||||
|
setActiveIndex(0);
|
||||||
|
} else {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
}, [isHashMode, suggestions.length]);
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDropdown) return;
|
||||||
|
const handleClickOutside = (e: MouseEvent): void => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showDropdown]);
|
||||||
|
|
||||||
|
// Scroll active item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showDropdown || !listRef.current) return;
|
||||||
|
const activeEl = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
||||||
|
activeEl?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [activeIndex, showDropdown]);
|
||||||
|
|
||||||
|
const selectTask = useCallback(
|
||||||
|
(task: TeamTask) => {
|
||||||
|
const displayId = getTaskDisplayId(task);
|
||||||
|
// Replace the `#<partial>` at end of input with the full `#<displayId>`
|
||||||
|
const newValue = value.replace(/#\S*$/, `#${displayId}`);
|
||||||
|
suppressReopenRef.current = true;
|
||||||
|
onChange(newValue);
|
||||||
|
setShowDropdown(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!showDropdown || suggestions.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i + 1) % suggestions.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectTask(suggestions[activeIndex]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showDropdown, suggestions, activeIndex, selectTask]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative max-w-[240px]">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tasks... (#id or text)"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Clear search</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown */}
|
||||||
|
{showDropdown && suggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="absolute left-0 top-full z-50 mt-1 max-h-[280px] w-[360px] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1 shadow-xl shadow-black/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||||
|
<Hash size={10} className="text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Tasks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{suggestions.map((task, index) => (
|
||||||
|
<TaskSuggestionItem
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
members={members}
|
||||||
|
isActive={index === activeIndex}
|
||||||
|
onSelect={() => selectTask(task)}
|
||||||
|
onHover={() => setActiveIndex(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Individual task suggestion row
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TaskSuggestionItemProps {
|
||||||
|
task: TeamTask;
|
||||||
|
index: number;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onHover: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
|
||||||
|
task,
|
||||||
|
index,
|
||||||
|
members,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: TaskSuggestionItemProps): React.JSX.Element {
|
||||||
|
const displayId = getTaskDisplayId(task);
|
||||||
|
const statusLabel = TASK_STATUS_LABELS[task.status] ?? task.status;
|
||||||
|
const memberColorMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const m of members) {
|
||||||
|
if (m.color) map.set(m.name, m.color);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
|
const createdAgo = useMemo(() => {
|
||||||
|
if (!task.createdAt) return null;
|
||||||
|
const date = new Date(task.createdAt);
|
||||||
|
return isNaN(date.getTime()) ? null : formatDistanceToNowStrict(date, { addSuffix: true });
|
||||||
|
}, [task.createdAt]);
|
||||||
|
|
||||||
|
const updatedAgo = useMemo(() => {
|
||||||
|
if (!task.updatedAt) return null;
|
||||||
|
const date = new Date(task.updatedAt);
|
||||||
|
return isNaN(date.getTime()) ? null : formatDistanceToNowStrict(date, { addSuffix: true });
|
||||||
|
}, [task.updatedAt]);
|
||||||
|
|
||||||
|
const statusStyle = useMemo(() => {
|
||||||
|
switch (task.status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-zinc-500/15 text-zinc-400';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'bg-blue-500/15 text-blue-400';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-emerald-500/15 text-emerald-400';
|
||||||
|
case 'deleted':
|
||||||
|
return 'bg-red-500/15 text-red-400';
|
||||||
|
default:
|
||||||
|
return 'bg-zinc-500/15 text-zinc-400';
|
||||||
|
}
|
||||||
|
}, [task.status]);
|
||||||
|
|
||||||
|
const zebraBg = index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-start gap-2 px-3 py-1.5 text-left transition-colors hover:!bg-[var(--color-surface-raised)]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? 'var(--color-surface-raised)' : zebraBg,
|
||||||
|
}}
|
||||||
|
onClick={onSelect}
|
||||||
|
onMouseEnter={onHover}
|
||||||
|
>
|
||||||
|
{/* Left column: ID + status */}
|
||||||
|
<div className="flex shrink-0 flex-col items-start gap-0.5">
|
||||||
|
<span className="font-mono text-[11px] font-medium text-[var(--color-text)]">
|
||||||
|
#{displayId}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded px-1 py-px text-[9px] font-medium ${statusStyle}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Right column: subject + metadata */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[11px] text-[var(--color-text-secondary)]">{task.subject}</p>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
|
{task.createdBy && (
|
||||||
|
<MemberBadge
|
||||||
|
name={task.createdBy}
|
||||||
|
color={memberColorMap.get(task.createdBy)}
|
||||||
|
size="xs"
|
||||||
|
hideAvatar
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{createdAgo && (
|
||||||
|
<span className="text-[9px] text-[var(--color-text-muted)]">created {createdAgo}</span>
|
||||||
|
)}
|
||||||
|
{updatedAgo && updatedAgo !== createdAgo && (
|
||||||
|
<span className="text-[9px] text-[var(--color-text-muted)]">updated {updatedAgo}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -229,11 +229,13 @@ export const ChangeReviewDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
totalFilesCount: sortedFiles.length,
|
||||||
|
readyFilesCount: sortedFiles.filter((file) => file.filePath in fileContents).length,
|
||||||
loadingFilesCount: loadingFiles.length,
|
loadingFilesCount: loadingFiles.length,
|
||||||
snippetCount,
|
snippetCount,
|
||||||
activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath,
|
activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath,
|
||||||
};
|
};
|
||||||
}, [activeFilePath, loadingFiles]);
|
}, [activeFilePath, loadingFiles, sortedFiles, fileContents]);
|
||||||
|
|
||||||
// File paths for viewed tracking
|
// File paths for viewed tracking
|
||||||
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
|
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ interface ContinuousScrollViewProps {
|
||||||
fileContents: Record<string, FileChangeWithContent>;
|
fileContents: Record<string, FileChangeWithContent>;
|
||||||
fileContentsLoading: Record<string, boolean>;
|
fileContentsLoading: Record<string, boolean>;
|
||||||
globalDiffLoadingState?: {
|
globalDiffLoadingState?: {
|
||||||
|
totalFilesCount: number;
|
||||||
|
readyFilesCount: number;
|
||||||
loadingFilesCount: number;
|
loadingFilesCount: number;
|
||||||
snippetCount: number;
|
snippetCount: number;
|
||||||
activeFileName?: string;
|
activeFileName?: string;
|
||||||
|
|
@ -242,6 +244,8 @@ export const ContinuousScrollView = ({
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
||||||
{globalDiffLoadingState ? (
|
{globalDiffLoadingState ? (
|
||||||
<FullDiffLoadingBanner
|
<FullDiffLoadingBanner
|
||||||
|
totalFilesCount={globalDiffLoadingState.totalFilesCount}
|
||||||
|
readyFilesCount={globalDiffLoadingState.readyFilesCount}
|
||||||
loadingFilesCount={globalDiffLoadingState.loadingFilesCount}
|
loadingFilesCount={globalDiffLoadingState.loadingFilesCount}
|
||||||
snippetCount={globalDiffLoadingState.snippetCount}
|
snippetCount={globalDiffLoadingState.snippetCount}
|
||||||
activeFileName={globalDiffLoadingState.activeFileName}
|
activeFileName={globalDiffLoadingState.activeFileName}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@ import React from 'react';
|
||||||
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
|
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface FullDiffLoadingBannerProps {
|
interface FullDiffLoadingBannerProps {
|
||||||
|
totalFilesCount: number;
|
||||||
|
readyFilesCount: number;
|
||||||
loadingFilesCount: number;
|
loadingFilesCount: number;
|
||||||
snippetCount: number;
|
snippetCount: number;
|
||||||
activeFileName?: string;
|
activeFileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FullDiffLoadingBanner = ({
|
export const FullDiffLoadingBanner = ({
|
||||||
|
totalFilesCount,
|
||||||
|
readyFilesCount,
|
||||||
loadingFilesCount,
|
loadingFilesCount,
|
||||||
snippetCount,
|
snippetCount,
|
||||||
activeFileName,
|
activeFileName,
|
||||||
|
|
@ -21,6 +25,9 @@ export const FullDiffLoadingBanner = ({
|
||||||
? `Finalizing the exact editor diff for ${activeFileName}.`
|
? `Finalizing the exact editor diff for ${activeFileName}.`
|
||||||
: 'Finalizing the exact editor diff for the current file.'
|
: 'Finalizing the exact editor diff for the current file.'
|
||||||
: 'Resolving exact before/after baselines for the files currently loading.';
|
: 'Resolving exact before/after baselines for the files currently loading.';
|
||||||
|
const showFileProgress = totalFilesCount > 1;
|
||||||
|
const progressPercent =
|
||||||
|
totalFilesCount > 0 ? Math.max(0, Math.min(100, (readyFilesCount / totalFilesCount) * 100)) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface/95 border-b border-border px-4 py-3">
|
<div className="bg-surface/95 border-b border-border px-4 py-3">
|
||||||
|
|
@ -60,19 +67,32 @@ export const FullDiffLoadingBanner = ({
|
||||||
<FileDiff className="size-3.5" strokeWidth={1.8} />
|
<FileDiff className="size-3.5" strokeWidth={1.8} />
|
||||||
{loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
|
{loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
|
||||||
</span>
|
</span>
|
||||||
|
{showFileProgress ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-surface-sidebar px-2 py-1 text-[11px] text-text-secondary">
|
||||||
|
<FileDiff className="size-3.5" strokeWidth={1.8} />
|
||||||
|
{readyFilesCount}/{totalFilesCount} files ready
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pb-3">
|
<div className="px-3 pb-3">
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sidebar">
|
<div className="h-2 overflow-hidden rounded-full bg-surface-sidebar">
|
||||||
<div
|
<div
|
||||||
className="h-full w-1/3 rounded-full bg-gradient-to-r from-emerald-400/20 via-emerald-300/80 to-emerald-400/20"
|
className="relative h-full rounded-full bg-emerald-500/20 transition-[width] duration-500 ease-out"
|
||||||
style={{ animation: 'full-diff-loader-slide 1.6s ease-in-out infinite' }}
|
style={{ width: `${showFileProgress ? progressPercent : 100}%` }}
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full bg-gradient-to-r from-emerald-400/20 via-emerald-300/80 to-emerald-400/20"
|
||||||
|
style={{ animation: 'full-diff-loader-slide 1.6s ease-in-out infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-text-muted">
|
<p className="mt-2 text-[11px] text-text-muted">
|
||||||
Snippet previews stay visible below while the exact baselines are reconstructed.
|
{showFileProgress
|
||||||
|
? `${readyFilesCount} ready, ${loadingFilesCount} still loading. Snippet previews stay visible below while the remaining baselines are reconstructed.`
|
||||||
|
: 'Snippet previews stay visible below while the exact baseline is reconstructed.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -835,6 +835,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
// Enter (without Shift) → submit; Shift+Enter → newline
|
// Enter (without Shift) → submit; Shift+Enter → newline
|
||||||
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
dismiss();
|
dismiss();
|
||||||
onModEnter();
|
onModEnter();
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const DialogContent = React.forwardRef<
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid w-full max-w-lg gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
'grid w-full max-w-lg gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||||
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||||
|
'focus:outline-none',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@ export function useMentionDetection({
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
if (!e.shiftKey) {
|
if (!e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onSelectSuggestion(selectedIndex);
|
onSelectSuggestion(selectedIndex);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -598,16 +598,12 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse raw model string from TeamCreateRequest/TeamLaunchRequest.
|
* Extract the base model name from the raw model string sent to CLI.
|
||||||
* E.g. 'opus[1m]' → { model: 'opus', limitContext: false }
|
* E.g. 'opus[1m]' → 'opus', 'sonnet' → 'sonnet', undefined → undefined.
|
||||||
* 'sonnet' → { model: 'sonnet', limitContext: true }
|
|
||||||
* Models without [1m] suffix mean context is limited to 200K.
|
|
||||||
*/
|
*/
|
||||||
function parseModelString(raw?: string): { model?: string; limitContext: boolean } {
|
function extractBaseModel(raw?: string): string | undefined {
|
||||||
if (!raw) return { limitContext: true };
|
if (!raw) return undefined;
|
||||||
const match = raw.match(/^(\w+)\[1m\]$/);
|
return raw.replace(/\[1m\]$/, '') || undefined;
|
||||||
if (match) return { model: match[1], limitContext: false };
|
|
||||||
return { model: raw, limitContext: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadToolApprovalSettings(): ToolApprovalSettings {
|
function loadToolApprovalSettings(): ToolApprovalSettings {
|
||||||
|
|
@ -1516,11 +1512,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
|
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
|
||||||
|
|
||||||
// Persist per-team launch params (model, effort, limit context)
|
// Persist per-team launch params (model, effort, limit context)
|
||||||
const { model: baseModel, limitContext } = parseModelString(request.model);
|
const baseModel = extractBaseModel(request.model);
|
||||||
const params: TeamLaunchParams = {
|
const params: TeamLaunchParams = {
|
||||||
model: baseModel || 'default',
|
model: baseModel || 'default',
|
||||||
effort: request.effort,
|
effort: request.effort,
|
||||||
limitContext,
|
limitContext: request.limitContext ?? false,
|
||||||
};
|
};
|
||||||
saveLaunchParams(request.teamName, params);
|
saveLaunchParams(request.teamName, params);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|
@ -1653,11 +1649,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
||||||
|
|
||||||
// Persist per-team launch params (model, effort, limit context)
|
// Persist per-team launch params (model, effort, limit context)
|
||||||
const { model: baseModel, limitContext } = parseModelString(request.model);
|
const baseModel = extractBaseModel(request.model);
|
||||||
const params: TeamLaunchParams = {
|
const params: TeamLaunchParams = {
|
||||||
model: baseModel || 'default',
|
model: baseModel || 'default',
|
||||||
effort: request.effort,
|
effort: request.effort,
|
||||||
limitContext,
|
limitContext: request.limitContext ?? false,
|
||||||
};
|
};
|
||||||
saveLaunchParams(request.teamName, params);
|
saveLaunchParams(request.teamName, params);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ const NO_AWAITING: AwaitingReplyResult = {
|
||||||
* Logic:
|
* Logic:
|
||||||
* 1. Find the latest comment authored by "user".
|
* 1. Find the latest comment authored by "user".
|
||||||
* 2. Collect the set of expected responders (task owner + task creator), deduplicated.
|
* 2. Collect the set of expected responders (task owner + task creator), deduplicated.
|
||||||
* 3. For each responder, check if they posted a comment AFTER the user's latest comment.
|
* 3. If ANY responder posted a comment AFTER the user's latest comment → not awaiting.
|
||||||
* Any comment type counts as a response (regular, review_approved, review_request).
|
* Any comment type counts as a response (regular, review_approved, review_request).
|
||||||
* 4. If at least one responder has NOT replied → isAwaiting = true.
|
* 4. If NO responder has replied → isAwaiting = true, awaitingFrom lists all responders.
|
||||||
*
|
*
|
||||||
* Edge cases:
|
* Edge cases:
|
||||||
* - No user comments → not awaiting.
|
* - No user comments → not awaiting.
|
||||||
|
|
@ -55,24 +55,20 @@ export function computeAwaitingReply(
|
||||||
}
|
}
|
||||||
if (latestUserCommentMs === 0) return NO_AWAITING;
|
if (latestUserCommentMs === 0) return NO_AWAITING;
|
||||||
|
|
||||||
// Check which responders have NOT replied after the user's comment
|
// Check if ANY responder has replied after the user's comment.
|
||||||
const awaitingFrom: string[] = [];
|
// The indicator hides as soon as at least one responder (owner OR lead) replies.
|
||||||
for (const responder of responders) {
|
for (const responder of responders) {
|
||||||
const hasReplied = comments.some((c) => {
|
const hasReplied = comments.some((c) => {
|
||||||
if (c.author !== responder) return false;
|
if (c.author !== responder) return false;
|
||||||
const ts = Date.parse(c.createdAt);
|
const ts = Date.parse(c.createdAt);
|
||||||
return Number.isFinite(ts) && ts > latestUserCommentMs;
|
return Number.isFinite(ts) && ts > latestUserCommentMs;
|
||||||
});
|
});
|
||||||
if (!hasReplied) {
|
if (hasReplied) return NO_AWAITING;
|
||||||
awaitingFrom.push(responder);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (awaitingFrom.length === 0) return NO_AWAITING;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAwaiting: true,
|
isAwaiting: true,
|
||||||
awaitingFrom,
|
awaitingFrom: Array.from(responders),
|
||||||
userCommentAtMs: latestUserCommentMs,
|
userCommentAtMs: latestUserCommentMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,8 @@ export interface TeamLaunchRequest {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||||
|
limitContext?: boolean;
|
||||||
/** When true, skip --resume and start a fresh session (clears context memory). */
|
/** When true, skip --resume and start a fresh session (clears context memory). */
|
||||||
clearContext?: boolean;
|
clearContext?: boolean;
|
||||||
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
|
|
@ -523,6 +525,8 @@ export interface TeamCreateRequest {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||||
|
limitContext?: boolean;
|
||||||
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
skipPermissions?: boolean;
|
skipPermissions?: boolean;
|
||||||
/** Worktree name — CLI: --worktree <name>. */
|
/** Worktree name — CLI: --worktree <name>. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue