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

View file

@ -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' } : {}),

View file

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

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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) => ({

View file

@ -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,
}; };
} }

View file

@ -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>. */