feat: enhance notification handling and improve task comment protocols
- Added debug logging for test notification handling in the NotificationManager, improving traceability during notification operations. - Implemented unescaping of literal newline sequences in task descriptions and comments across various components, ensuring proper formatting. - Updated task comment handling logic to clarify awaiting replies from task responders, enhancing user awareness of task interactions. - Refined UI components to improve user experience in settings and task management, including adjustments to the NotificationsSection and TeamDetailView.
This commit is contained in:
parent
cb6a41d899
commit
f5efa17b1a
20 changed files with 453 additions and 85 deletions
|
|
@ -194,8 +194,11 @@ async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise<number>
|
|||
*/
|
||||
function handleTestNotification(_event: IpcMainInvokeEvent): { success: boolean; error?: string } {
|
||||
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) };
|
||||
|
|
|
|||
|
|
@ -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' } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
284
src/renderer/components/team/kanban/KanbanSearchInput.tsx
Normal file
284
src/renderer/components/team/kanban/KanbanSearchInput.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { Hash, Search, X } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface KanbanSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
tasks: TeamTask[];
|
||||
members: ResolvedTeamMember[];
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 15;
|
||||
|
||||
/**
|
||||
* Kanban search input with task autocomplete dropdown.
|
||||
* When user types `#`, shows a filterable list of tasks by displayId.
|
||||
* Selecting a task inserts `#<displayId>` into the search field.
|
||||
*/
|
||||
export const KanbanSearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
tasks,
|
||||
members,
|
||||
}: KanbanSearchInputProps): React.JSX.Element => {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
/** Prevents the useEffect from reopening the dropdown right after a selection. */
|
||||
const suppressReopenRef = useRef(false);
|
||||
|
||||
// Detect `#` trigger and extract filter text after it
|
||||
const hashMatch = useMemo(() => {
|
||||
const match = value.match(/#(\S*)$/);
|
||||
return match ? match[1] : null;
|
||||
}, [value]);
|
||||
|
||||
const isHashMode = hashMatch !== null;
|
||||
|
||||
// Filter tasks by displayId when in hash mode
|
||||
const suggestions = useMemo(() => {
|
||||
if (!isHashMode) return [];
|
||||
const filter = hashMatch.toLowerCase();
|
||||
const filtered = tasks.filter((t) => {
|
||||
const displayId = getTaskDisplayId(t).toLowerCase();
|
||||
return filter === '' || displayId.includes(filter);
|
||||
});
|
||||
return filtered.slice(0, MAX_SUGGESTIONS);
|
||||
}, [isHashMode, hashMatch, tasks]);
|
||||
|
||||
// Show dropdown when in hash mode with suggestions
|
||||
useEffect(() => {
|
||||
if (suppressReopenRef.current) {
|
||||
suppressReopenRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (isHashMode && suggestions.length > 0) {
|
||||
setShowDropdown(true);
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [isHashMode, suggestions.length]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showDropdown]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
if (!showDropdown || !listRef.current) return;
|
||||
const activeEl = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
||||
activeEl?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex, showDropdown]);
|
||||
|
||||
const selectTask = useCallback(
|
||||
(task: TeamTask) => {
|
||||
const displayId = getTaskDisplayId(task);
|
||||
// Replace the `#<partial>` at end of input with the full `#<displayId>`
|
||||
const newValue = value.replace(/#\S*$/, `#${displayId}`);
|
||||
suppressReopenRef.current = true;
|
||||
onChange(newValue);
|
||||
setShowDropdown(false);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % suggestions.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
selectTask(suggestions[activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
},
|
||||
[showDropdown, suggestions, activeIndex, selectTask]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative max-w-[240px]">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search tasks... (#id or text)"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
/>
|
||||
{value && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Clear search</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute left-0 top-full z-50 mt-1 max-h-[280px] w-[360px] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1 shadow-xl shadow-black/30"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5">
|
||||
<Hash size={10} className="text-[var(--color-text-muted)]" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Tasks
|
||||
</span>
|
||||
</div>
|
||||
{suggestions.map((task, index) => (
|
||||
<TaskSuggestionItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
members={members}
|
||||
isActive={index === activeIndex}
|
||||
onSelect={() => selectTask(task)}
|
||||
onHover={() => setActiveIndex(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual task suggestion row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TaskSuggestionItemProps {
|
||||
task: TeamTask;
|
||||
index: number;
|
||||
members: ResolvedTeamMember[];
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onHover: () => void;
|
||||
}
|
||||
|
||||
const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
|
||||
task,
|
||||
index,
|
||||
members,
|
||||
isActive,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: TaskSuggestionItemProps): React.JSX.Element {
|
||||
const displayId = getTaskDisplayId(task);
|
||||
const statusLabel = TASK_STATUS_LABELS[task.status] ?? task.status;
|
||||
const memberColorMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const m of members) {
|
||||
if (m.color) map.set(m.name, m.color);
|
||||
}
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
const createdAgo = useMemo(() => {
|
||||
if (!task.createdAt) return null;
|
||||
const date = new Date(task.createdAt);
|
||||
return isNaN(date.getTime()) ? null : formatDistanceToNowStrict(date, { addSuffix: true });
|
||||
}, [task.createdAt]);
|
||||
|
||||
const updatedAgo = useMemo(() => {
|
||||
if (!task.updatedAt) return null;
|
||||
const date = new Date(task.updatedAt);
|
||||
return isNaN(date.getTime()) ? null : formatDistanceToNowStrict(date, { addSuffix: true });
|
||||
}, [task.updatedAt]);
|
||||
|
||||
const statusStyle = useMemo(() => {
|
||||
switch (task.status) {
|
||||
case 'pending':
|
||||
return 'bg-zinc-500/15 text-zinc-400';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-500/15 text-blue-400';
|
||||
case 'completed':
|
||||
return 'bg-emerald-500/15 text-emerald-400';
|
||||
case 'deleted':
|
||||
return 'bg-red-500/15 text-red-400';
|
||||
default:
|
||||
return 'bg-zinc-500/15 text-zinc-400';
|
||||
}
|
||||
}, [task.status]);
|
||||
|
||||
const zebraBg = index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 px-3 py-1.5 text-left transition-colors hover:!bg-[var(--color-surface-raised)]"
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--color-surface-raised)' : zebraBg,
|
||||
}}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
{/* Left column: ID + status */}
|
||||
<div className="flex shrink-0 flex-col items-start gap-0.5">
|
||||
<span className="font-mono text-[11px] font-medium text-[var(--color-text)]">
|
||||
#{displayId}
|
||||
</span>
|
||||
<span className={`rounded px-1 py-px text-[9px] font-medium ${statusStyle}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right column: subject + metadata */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[11px] text-[var(--color-text-secondary)]">{task.subject}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
{task.createdBy && (
|
||||
<MemberBadge
|
||||
name={task.createdBy}
|
||||
color={memberColorMap.get(task.createdBy)}
|
||||
size="xs"
|
||||
hideAvatar
|
||||
/>
|
||||
)}
|
||||
{createdAgo && (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">created {createdAgo}</span>
|
||||
)}
|
||||
{updatedAgo && updatedAgo !== createdAgo && (
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">updated {updatedAgo}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
|
@ -229,11 +229,13 @@ export const ChangeReviewDialog = ({
|
|||
);
|
||||
|
||||
return {
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ export function useMentionDetection({
|
|||
case 'Enter':
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectSuggestion(selectedIndex);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue