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:
iliya 2026-03-15 14:18:33 +02:00
parent cb6a41d899
commit f5efa17b1a
20 changed files with 453 additions and 85 deletions

View file

@ -194,8 +194,11 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise<number>
*/
function handleTestNotification(_event: IpcMainInvokeEvent): { success: boolean; error?: string } {
try {
logger.debug('Handling notifications:testNotification request');
const manager = NotificationManager.getInstance();
return manager.sendTestNotification();
const result = manager.sendTestNotification();
logger.debug(`notifications:testNotification result: success=${String(result.success)}`);
return result;
} catch (error) {
logger.error('Error in notifications:testNotification:', error);
return { success: false, error: getErrorMessage(error) };

View file

@ -495,11 +495,13 @@ export class NotificationManager extends EventEmitter {
*/
sendTestNotification(): { success: boolean; error?: string } {
if (!this.isNativeNotificationSupported()) {
logger.warn('[test-notification] native notifications not supported');
return { success: false, error: 'Native notifications are not supported on this platform' };
}
const isMac = process.platform === 'darwin';
const iconPath = isMac ? undefined : getAppIconPath();
logger.debug(`[test-notification] creating Notification (platform=${process.platform})`);
const notification = new Notification({
title: 'Test Notification',
...(isMac ? { subtitle: 'Claude Agent Teams UI' } : {}),

View file

@ -456,6 +456,7 @@ After member_briefing succeeds:
- 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.
- 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.
- 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.
@ -490,6 +491,7 @@ ${actionModeProtocol}
- 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.
- 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.
- 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.
@ -531,6 +533,7 @@ ${actionModeProtocol}
- 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.
- 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.
- 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.
@ -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.
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>" }
- 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:
{ 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.
@ -754,14 +760,15 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`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.`,
`- 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.`,
`- 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.`,
`- 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.`,
`- 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.`,
``,
`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.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
`- 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 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" }`,
`- 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):`,
@ -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.`,
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.`,
`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.`,
`NEVER set recipient="cross_team_send" or to="cross_team_send". "cross_team_send" is a tool name, not a teammate.`,
AGENT_BLOCK_CLOSE,

View file

@ -16,12 +16,23 @@ import type {
TaskRef,
TaskWorkInterval,
TeamTask,
TeamTaskStatus,
} from '@shared/types';
const logger = createLogger('Service:TeamTaskReader');
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 {
if (typeof value !== 'string') return false;
const v = value.trim();
@ -170,7 +181,10 @@ export class TeamTaskReader {
: ''
),
subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined,
description:
typeof parsed.description === 'string'
? unescapeLiteralNewlines(parsed.description)
: undefined,
descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs),
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined,
@ -209,6 +223,7 @@ export class TeamTaskReader {
)
.map((c) => ({
...c,
text: unescapeLiteralNewlines(c.text),
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
? c.type
: ('regular' as const),
@ -369,7 +384,10 @@ export class TeamTaskReader {
: ''
),
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,
status: 'deleted',
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,

View file

@ -38,6 +38,14 @@ function deriveTaskDisplayId(taskId: string): string {
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
// ---------------------------------------------------------------------------
@ -549,7 +557,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
.map((c) => ({
id: c.id as string,
author: c.author as string,
text: c.text as string,
text: unescapeLiteralNewlines(c.text as string),
createdAt: c.createdAt as string,
taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined,
type:
@ -651,7 +659,10 @@ async function readTasksDirForTeam(
: ''
),
subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined,
description:
typeof parsed.description === 'string'
? unescapeLiteralNewlines(parsed.description)
: undefined,
descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs)
? (parsed.descriptionTaskRefs as unknown[])
: undefined,

View file

@ -112,9 +112,11 @@ export const NotificationsSection = ({
setTestError(result.error ?? 'Unknown error');
setTimeout(() => setTestStatus('idle'), 5000);
}
} catch {
} catch (err) {
console.error('[notifications] testNotification failed:', err);
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);
}
};
@ -361,7 +363,7 @@ export const NotificationsSection = ({
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<div>
<div
className="text-sm font-medium"
@ -373,13 +375,11 @@ export const NotificationsSection = ({
Which target statuses trigger a notification
</div>
</div>
<div className="shrink-0">
<StatusCheckboxGroup
selected={safeConfig.notifications.statusChangeStatuses}
onChange={onStatusChangeStatusesUpdate}
disabled={saving}
/>
</div>
<StatusCheckboxGroup
selected={safeConfig.notifications.statusChangeStatuses}
onChange={onStatusChangeStatusesUpdate}
disabled={saving}
/>
</div>
</div>
) : null}

View file

@ -44,13 +44,11 @@ import {
Pencil,
Play,
Plus,
Search,
Square,
Terminal,
Trash2,
UserPlus,
Users,
X,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -64,6 +62,7 @@ import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { KanbanSearchInput } from './kanban/KanbanSearchInput';
import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog';
@ -1432,33 +1431,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort}
toolbarLeft={
<div 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
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>
<KanbanSearchInput
value={kanbanSearch}
onChange={setKanbanSearch}
tasks={filteredTasks}
members={activeMembers}
/>
}
onRequestReview={(taskId) => {
void (async () => {

View file

@ -574,6 +574,7 @@ export const CreateTeamDialog = ({
prompt: prompt.trim() || undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
@ -588,6 +589,7 @@ export const CreateTeamDialog = ({
prompt,
effectiveModel,
selectedEffort,
limitContext,
skipPermissions,
worktreeEnabled,
worktreeName,

View file

@ -592,6 +592,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
prompt: promptDraft.value.trim() || undefined,
model: computeEffectiveTeamModel(selectedModel, limitContext),
effort: (selectedEffort as EffortLevel) || undefined,
limitContext,
clearContext: clearContext || undefined,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,

View file

@ -44,10 +44,11 @@ import {
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { formatDistanceToNow } from 'date-fns';
import { format, formatDistanceToNow } from 'date-fns';
import {
AlignLeft,
ArrowLeftFromLine,
@ -510,10 +511,14 @@ export const TaskDetailDialog = ({
{formatTaskDisplayLabel(currentTask)}
</Badge>
{(currentTask.reviewState === 'approved' || currentTask.reviewState === 'review') &&
currentTask.reviewer ? (
currentTask.reviewer &&
currentTask.reviewer !== 'user' ? (
(() => {
const reviewerColor = colorMap.get(currentTask.reviewer);
const colors = getTeamColorSet(reviewerColor ?? '');
const colors =
currentTask.reviewState === 'review'
? getTeamColorSet('blue')
: getTeamColorSet(reviewerColor ?? '');
const reviewerBadgeStyle = {
backgroundColor: getThemedBadge(colors, isLight),
color: getThemedText(colors, isLight),
@ -521,7 +526,21 @@ export const TaskDetailDialog = ({
borderRight: `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-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>
);
return reviewTimeLabel ? (
<Tooltip>
<TooltipTrigger asChild>{badge}</TooltipTrigger>
<TooltipContent side="bottom">{reviewTimeLabel}</TooltipContent>
</Tooltip>
) : (
badge
);
})()
) : (
<span
@ -566,7 +593,10 @@ export const TaskDetailDialog = ({
value={subjectDraft}
onChange={(e) => setSubjectDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveSubject();
if (e.key === 'Enter') {
e.stopPropagation();
void saveSubject();
}
if (e.key === 'Escape') setEditingSubject(false);
}}
onBlur={() => void saveSubject()}
@ -796,7 +826,14 @@ export const TaskDetailDialog = ({
) : currentTask.description ? (
<div className="group relative">
<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>
<Tooltip>
<TooltipTrigger asChild>

View 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>
);
});

View file

@ -229,11 +229,13 @@ export const ChangeReviewDialog = ({
);
return {
totalFilesCount: sortedFiles.length,
readyFilesCount: sortedFiles.filter((file) => file.filePath in fileContents).length,
loadingFilesCount: loadingFiles.length,
snippetCount,
activeFileName: preferredFile?.relativePath ?? preferredFile?.filePath,
};
}, [activeFilePath, loadingFiles]);
}, [activeFilePath, loadingFiles, sortedFiles, fileContents]);
// File paths for viewed tracking
const allFilePaths = useMemo(() => sortedFiles.map((f) => f.filePath), [sortedFiles]);

View file

@ -24,6 +24,8 @@ interface ContinuousScrollViewProps {
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
globalDiffLoadingState?: {
totalFilesCount: number;
readyFilesCount: number;
loadingFilesCount: number;
snippetCount: number;
activeFileName?: string;
@ -242,6 +244,8 @@ export const ContinuousScrollView = ({
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{globalDiffLoadingState ? (
<FullDiffLoadingBanner
totalFilesCount={globalDiffLoadingState.totalFilesCount}
readyFilesCount={globalDiffLoadingState.readyFilesCount}
loadingFilesCount={globalDiffLoadingState.loadingFilesCount}
snippetCount={globalDiffLoadingState.snippetCount}
activeFileName={globalDiffLoadingState.activeFileName}

View file

@ -3,12 +3,16 @@ import React from 'react';
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
interface FullDiffLoadingBannerProps {
totalFilesCount: number;
readyFilesCount: number;
loadingFilesCount: number;
snippetCount: number;
activeFileName?: string;
}
export const FullDiffLoadingBanner = ({
totalFilesCount,
readyFilesCount,
loadingFilesCount,
snippetCount,
activeFileName,
@ -21,6 +25,9 @@ export const FullDiffLoadingBanner = ({
? `Finalizing the exact editor diff for ${activeFileName}.`
: 'Finalizing the exact editor diff for the current file.'
: '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 (
<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} />
{loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
</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 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
className="h-full w-1/3 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' }}
/>
className="relative h-full rounded-full bg-emerald-500/20 transition-[width] duration-500 ease-out"
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>
<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>
</div>
</div>

View file

@ -835,6 +835,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// Enter (without Shift) → submit; Shift+Enter → newline
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
e.preventDefault();
e.stopPropagation();
dismiss();
onModEnter();
return;

View file

@ -42,6 +42,7 @@ const DialogContent = React.forwardRef<
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',
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
'focus:outline-none',
className
)}
{...props}

View file

@ -331,6 +331,7 @@ export function useMentionDetection({
case 'Enter':
if (!e.shiftKey) {
e.preventDefault();
e.stopPropagation();
onSelectSuggestion(selectedIndex);
}
break;

View file

@ -598,16 +598,12 @@ function saveLaunchParams(teamName: string, params: TeamLaunchParams): void {
}
/**
* Parse raw model string from TeamCreateRequest/TeamLaunchRequest.
* E.g. 'opus[1m]' { model: 'opus', limitContext: false }
* 'sonnet' { model: 'sonnet', limitContext: true }
* Models without [1m] suffix mean context is limited to 200K.
* Extract the base model name from the raw model string sent to CLI.
* E.g. 'opus[1m]' 'opus', 'sonnet' 'sonnet', undefined undefined.
*/
function parseModelString(raw?: string): { model?: string; limitContext: boolean } {
if (!raw) return { limitContext: true };
const match = raw.match(/^(\w+)\[1m\]$/);
if (match) return { model: match[1], limitContext: false };
return { model: raw, limitContext: true };
function extractBaseModel(raw?: string): string | undefined {
if (!raw) return undefined;
return raw.replace(/\[1m\]$/, '') || undefined;
}
function loadToolApprovalSettings(): ToolApprovalSettings {
@ -1516,11 +1512,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
// Persist per-team launch params (model, effort, limit context)
const { model: baseModel, limitContext } = parseModelString(request.model);
const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = {
model: baseModel || 'default',
effort: request.effort,
limitContext,
limitContext: request.limitContext ?? false,
};
saveLaunchParams(request.teamName, params);
set((state) => ({
@ -1653,11 +1649,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
// Persist per-team launch params (model, effort, limit context)
const { model: baseModel, limitContext } = parseModelString(request.model);
const baseModel = extractBaseModel(request.model);
const params: TeamLaunchParams = {
model: baseModel || 'default',
effort: request.effort,
limitContext,
limitContext: request.limitContext ?? false,
};
saveLaunchParams(request.teamName, params);
set((state) => ({

View file

@ -21,9 +21,9 @@ const NO_AWAITING: AwaitingReplyResult = {
* Logic:
* 1. Find the latest comment authored by "user".
* 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).
* 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:
* - No user comments not awaiting.
@ -55,24 +55,20 @@ export function computeAwaitingReply(
}
if (latestUserCommentMs === 0) return NO_AWAITING;
// Check which responders have NOT replied after the user's comment
const awaitingFrom: string[] = [];
// Check if ANY responder has replied after the user's comment.
// The indicator hides as soon as at least one responder (owner OR lead) replies.
for (const responder of responders) {
const hasReplied = comments.some((c) => {
if (c.author !== responder) return false;
const ts = Date.parse(c.createdAt);
return Number.isFinite(ts) && ts > latestUserCommentMs;
});
if (!hasReplied) {
awaitingFrom.push(responder);
}
if (hasReplied) return NO_AWAITING;
}
if (awaitingFrom.length === 0) return NO_AWAITING;
return {
isAwaiting: true,
awaitingFrom,
awaitingFrom: Array.from(responders),
userCommentAtMs: latestUserCommentMs,
};
}

View file

@ -399,6 +399,8 @@ export interface TeamLaunchRequest {
prompt?: string;
model?: string;
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). */
clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
@ -523,6 +525,8 @@ export interface TeamCreateRequest {
prompt?: string;
model?: string;
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. */
skipPermissions?: boolean;
/** Worktree name — CLI: --worktree <name>. */