diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 3dc08fda..20c1ab67 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -194,8 +194,11 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise */ 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) }; diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index daf33196..51f3444b 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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' } : {}), diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0a6dea3c..85713ce8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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: "", text: "", from: "" } + - 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: "", text: "", from: "" } 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: "", 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, diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index e3e9e10e..7e65425e 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -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, diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index d0fa49c6..6dd1a5cd 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -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, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 4594b8bd..40530f69 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -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 = ({ /> -
+
-
- -
+
) : null} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fd65d402..e4e9099d 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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={ -
- - 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 && ( - - - - - Clear search - - )} -
+ } onRequestReview={(taskId) => { void (async () => { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index af50f790..4fa59aec 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 621933e2..4e057d1e 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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, diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 50a0bfae..fbdc770b 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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)} {(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 = ( ); + return reviewTimeLabel ? ( + + {badge} + {reviewTimeLabel} + + ) : ( + badge + ); })() ) : ( 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 ? (
- + diff --git a/src/renderer/components/team/kanban/KanbanSearchInput.tsx b/src/renderer/components/team/kanban/KanbanSearchInput.tsx new file mode 100644 index 00000000..e6ebfc69 --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanSearchInput.tsx @@ -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 `#` 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(null); + const inputRef = useRef(null); + const listRef = useRef(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 `#` at end of input with the full `#` + 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 ( +
+ + 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 && ( + + + + + Clear search + + )} + + {/* Autocomplete dropdown */} + {showDropdown && suggestions.length > 0 && ( +
+
+ + + Tasks + +
+ {suggestions.map((task, index) => ( + selectTask(task)} + onHover={() => setActiveIndex(index)} + /> + ))} +
+ )} +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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(); + 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 ( + + ); +}); diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index d5c7186e..63a34469 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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]); diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index d45b54ec..a98409c7 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -24,6 +24,8 @@ interface ContinuousScrollViewProps { fileContents: Record; fileContentsLoading: Record; globalDiffLoadingState?: { + totalFilesCount: number; + readyFilesCount: number; loadingFilesCount: number; snippetCount: number; activeFileName?: string; @@ -242,6 +244,8 @@ export const ContinuousScrollView = ({
{globalDiffLoadingState ? ( 1; + const progressPercent = + totalFilesCount > 0 ? Math.max(0, Math.min(100, (readyFilesCount / totalFilesCount) * 100)) : 0; return (
@@ -60,19 +67,32 @@ export const FullDiffLoadingBanner = ({ {loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress + {showFileProgress ? ( + + + {readyFilesCount}/{totalFilesCount} files ready + + ) : null}
-
+
+ className="relative h-full rounded-full bg-emerald-500/20 transition-[width] duration-500 ease-out" + style={{ width: `${showFileProgress ? progressPercent : 100}%` }} + > +
+

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

diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index ed8cba1e..ab564c60 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -835,6 +835,7 @@ export const MentionableTextarea = React.forwardRef = (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 = (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) => ({ diff --git a/src/renderer/utils/taskCommentPendingReply.ts b/src/renderer/utils/taskCommentPendingReply.ts index ed052bc5..a6433ef0 100644 --- a/src/renderer/utils/taskCommentPendingReply.ts +++ b/src/renderer/utils/taskCommentPendingReply.ts @@ -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, }; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 0ce517c8..e6705c57 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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 . */