feat: improve task status handling and notification resilience

- Enhanced the startTask function to clear stale kanban entries when a task is reopened, ensuring accurate task tracking.
- Updated the addTaskComment function to handle notification failures gracefully, allowing comments to persist even if owner notifications fail.
- Added tests to validate the resilience of task comment notifications and ensure correct behavior under failure scenarios.
- Refactored related functions for improved clarity and maintainability in task management.
This commit is contained in:
iliya 2026-03-09 17:09:15 +02:00
parent 9adffb2295
commit 3f923c480e
24 changed files with 1215 additions and 721 deletions

View file

@ -164,7 +164,19 @@ function setTaskStatus(context, taskId, status, actor) {
}
function startTask(context, taskId, actor) {
return setTaskStatus(context, taskId, 'in_progress', actor);
const task = setTaskStatus(context, taskId, 'in_progress', actor);
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
try {
const kanbanStore = require('./kanbanStore.js');
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
if (state.tasks[task.id]) {
delete state.tasks[task.id];
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
}
} catch {
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
}
return task;
}
function completeTask(context, taskId, actor) {
@ -212,10 +224,19 @@ function addTaskComment(context, taskId, flags) {
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
});
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
inserted: result.inserted,
notifyOwner: flags.notifyOwner,
});
try {
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
inserted: result.inserted,
notifyOwner: flags.notifyOwner,
});
} catch (notifyError) {
// Best-effort: comment is already persisted, notification failure must not fail the call
if (typeof console !== 'undefined' && console.warn) {
console.warn(
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
);
}
}
return {
commentId: result.comment.id,

View file

@ -532,4 +532,32 @@ describe('agent-teams-controller API', () => {
controller.processes.unregisterProcess({ id: 'stale-entry' });
expect(controller.processes.listProcesses()).toEqual([]);
});
it('task_add_comment succeeds even when owner notification write fails', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Comment resilience',
owner: 'bob',
notifyOwner: false,
});
// Make inboxes directory read-only to force notification write failure
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
// Write a broken file that will cause JSON parse failure on append
fs.writeFileSync(path.join(inboxDir, 'bob.json'), 'NOT VALID JSON');
// Comment should still succeed despite notification failure
const commented = controller.tasks.addTaskComment(task.id, {
from: 'alice',
text: 'This should persist despite notification failure.',
});
expect(commented.commentId).toBeTruthy();
expect(commented.task.comments).toHaveLength(1);
expect(commented.task.comments[0].text).toBe(
'This should persist despite notification failure.'
);
});
});

View file

@ -260,9 +260,11 @@ describe('agent-teams-mcp tools', () => {
})
);
expect(approved.reviewState).toBe('approved');
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-1');
{
const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8'));
expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1');
}
const kanbanState = parseJsonToolResult(
await getTool('kanban_get').execute({
@ -549,4 +551,48 @@ describe('agent-teams-mcp tools', () => {
}).success
).toBe(false);
});
it('task_add_comment succeeds even when owner inbox write fails', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'resilience';
const task = parseJsonToolResult(
await getTool('task_create').execute({
claudeDir,
teamName,
subject: 'Comment resilience test',
owner: 'alice',
notifyOwner: false,
})
);
// Corrupt the inbox file to force notification failure
const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'alice.json'), 'BROKEN JSON');
const commented = parseJsonToolResult(
await getTool('task_add_comment').execute({
claudeDir,
teamName,
taskId: task.id,
text: 'Comment should persist despite broken inbox',
from: 'bob',
})
);
expect(commented.commentId).toBeTruthy();
// Verify the comment is actually persisted on the task
const reloaded = parseJsonToolResult(
await getTool('task_get').execute({
claudeDir,
teamName,
taskId: task.id,
})
);
expect(reloaded.comments).toHaveLength(1);
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
});
});

View file

@ -13,7 +13,7 @@ import {
AGENT_BLOCK_OPEN,
stripAgentBlocks,
} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -622,7 +622,7 @@ export class TeamDataService {
role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
agentType: 'general-purpose',
color: getMemberColor(members.filter((m) => !m.removedAt).length),
color: getMemberColorByName(name),
joinedAt: Date.now(),
};
@ -662,7 +662,7 @@ export class TeamDataService {
const joinedAt = Date.now();
const nextByName = new Set<string>();
const nextActive: TeamMember[] = request.members.map((member, index) => {
const nextActive: TeamMember[] = request.members.map((member) => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
if (name.toLowerCase() === 'team-lead') {
@ -681,7 +681,7 @@ export class TeamDataService {
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
agentType: prev?.agentType ?? 'general-purpose',
color: prev?.color ?? getMemberColor(index),
color: prev?.color ?? getMemberColorByName(name),
joinedAt: prev?.joinedAt ?? joinedAt,
removedAt: undefined,
};
@ -1113,7 +1113,7 @@ export class TeamDataService {
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await this.membersMetaStore.writeMembers(
request.teamName,
request.members.map((member, index) => ({
request.members.map((member) => ({
name: (() => {
const name = member.name.trim();
if (!name) throw new Error('Member name cannot be empty');
@ -1129,7 +1129,7 @@ export class TeamDataService {
})(),
role: member.role?.trim() || undefined,
agentType: 'general-purpose',
color: getMemberColor(index),
color: getMemberColorByName(member.name.trim()),
joinedAt,
}))
);

View file

@ -93,7 +93,10 @@ export class TeamMemberResolver {
const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask =
ownedTasks.find(
(task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved'
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const memberMessages = messages.filter((message) => message.from === name);
const latestMessage = memberMessages[0] ?? null;

View file

@ -19,7 +19,7 @@ import {
AGENT_BLOCK_OPEN,
stripAgentBlocks,
} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
@ -566,6 +566,7 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
- When mentioning teammates by name in messages and text output, always use @ prefix (e.g. @alice, @bob) for UI highlighting. Do NOT use @ in tool parameters (recipient, owner, etc.) those require plain names.
${agentBlockPolicy}
${membersFooter}`;
@ -1155,6 +1156,30 @@ export class TeamProvisioningService {
}
}
private persistInboxMessage(teamName: string, recipient: string, message: InboxMessage): void {
try {
createController({
teamName,
claudeDir: getClaudeBasePath(),
}).messages.sendMessage({
member: recipient,
from: message.from,
text: message.text,
timestamp: message.timestamp,
summary: message.summary,
messageId: message.messageId,
source: message.source,
leadSessionId: message.leadSessionId,
attachments: message.attachments,
color: message.color,
toolSummary: message.toolSummary,
toolCalls: message.toolCalls,
});
} catch (error) {
logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`);
}
}
private getMemberRelayKey(teamName: string, memberName: string): string {
return `${teamName}:${memberName.trim()}`;
}
@ -2923,12 +2948,24 @@ export class TeamProvisioningService {
};
this.pushLiveLeadProcessMessage(run.teamName, msg);
this.persistSentMessage(run.teamName, msg);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'sentMessages.json',
});
if (recipient === 'user') {
// User-directed messages go to sentMessages.json (canonical outbound store)
this.persistSentMessage(run.teamName, msg);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'sentMessages.json',
});
} else {
// Non-user messages go to canonical recipient inbox for relay delivery
this.persistInboxMessage(run.teamName, recipient, msg);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: `inboxes/${recipient}.json`,
});
}
logger.debug(
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
@ -5183,12 +5220,12 @@ export class TeamProvisioningService {
try {
await this.membersMetaStore.writeMembers(
teamName,
teammateMembers.map((member, index) => ({
teammateMembers.map((member) => ({
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
agentType: 'general-purpose',
color: getMemberColor(index),
color: getMemberColorByName(member.name.trim()),
joinedAt,
}))
);

View file

@ -385,6 +385,12 @@ const AIChatGroupInner = ({
// Determine if there's content to toggle
const hasToggleContent = enhanced.displayItems.length > 0;
// Last thinking text for collapsed preview
const lastThought = useMemo(() => {
const thinkingItems = enhanced.displayItems.filter((d) => d.type === 'thinking');
return thinkingItems.at(-1)?.content?.slice(0, 200) ?? null;
}, [enhanced.displayItems]);
// Handle item click - toggle inline expansion using store action
const handleItemClick = (itemId: string): void => {
toggleDisplayItemExpansion(aiGroup.id, itemId);
@ -501,6 +507,11 @@ const AIChatGroupInner = ({
</div>
)}
{/* Last thought preview in collapsed state */}
{hasToggleContent && !isExpanded && lastThought && (
<div className="truncate px-6 pb-1 text-xs text-text-muted">{lastThought}</div>
)}
{/* Expandable Content */}
{hasToggleContent && isExpanded && (
<div className="py-2 pl-2">

View file

@ -79,7 +79,7 @@ export const CollapsibleTeamSection = ({
>
<button
type="button"
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`}
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]' : 'rounded-md bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]'}`}
onClick={() => setOpen((prev) => !prev)}
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
/>

View file

@ -23,7 +23,13 @@ export const ActiveTasksBlock = ({
const { isLight } = useTheme();
const colorMap = buildMemberColorMap(members);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const working = members.filter((m) => m.currentTaskId != null);
const working = members.filter((m) => {
if (!m.currentTaskId) return false;
const task = taskMap.get(m.currentTaskId);
// Defense-in-depth: hide banner for approved/completed tasks even if currentTaskId is stale
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true;
});
if (working.length === 0) return null;
return (

View file

@ -342,24 +342,10 @@ const LeadThoughtItem = ({
<div ref={wrapperRef}>
<div ref={contentRef}>
{showDivider && (
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
<span className="shrink-0 font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
<div className="py-px text-center">
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
{formatTimeWithSec(thought.timestamp)}
</span>
<hr
className="flex-1 border-0"
style={{
height: '1px',
backgroundColor: 'var(--color-border-emphasis)',
}}
/>
</div>
)}
<div className="group/thought relative flex text-[11px]">
@ -746,6 +732,20 @@ export const LeadThoughtsGroupRow = ({
)}
</div>
{/* Last thought preview when body is collapsed */}
{!isBodyVisible && newest.text && (
<div
className="truncate border-t px-3 py-1 text-[11px]"
style={{
borderColor: 'var(--color-border-subtle)',
color: CARD_TEXT_LIGHT,
opacity: 0.7,
}}
>
{newest.text.slice(0, 200)}
</div>
)}
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
{isBodyVisible ? (
<div

View file

@ -1,7 +1,7 @@
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/counter.css';
import { useMemo } from 'react';
import { createContext, useContext, useEffect, useRef, useMemo } from 'react';
import Lightbox from 'yet-another-react-lightbox';
import Counter from 'yet-another-react-lightbox/plugins/counter';
@ -10,6 +10,21 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import type { Plugin, Slide } from 'yet-another-react-lightbox';
// ---------------------------------------------------------------------------
// LightboxLock context — allows a parent (e.g. Dialog) to know when a
// lightbox is open so it can block dismiss events.
// ---------------------------------------------------------------------------
type LightboxLockCallback = (open: boolean) => void;
const LightboxLockContext = createContext<LightboxLockCallback | null>(null);
/**
* Wrap a Dialog (or any dismissable container) with this provider and pass a
* callback that receives `true` when a lightbox opens and `false` when it closes.
*/
export const LightboxLockProvider = LightboxLockContext.Provider;
export interface ImageLightboxSlide {
src: string;
alt?: string;
@ -30,6 +45,8 @@ interface ImageLightboxProps {
enableZoom?: boolean;
enableFullscreen?: boolean;
showCounter?: boolean;
/** Called when lightbox open state changes (useful for parent components to block dismiss). */
onOpenChange?: (open: boolean) => void;
}
export const ImageLightbox = ({
@ -42,6 +59,7 @@ export const ImageLightbox = ({
enableZoom = true,
enableFullscreen = true,
showCounter,
onOpenChange,
}: ImageLightboxProps): React.JSX.Element | null => {
const slides = useMemo<Slide[]>(() => {
if (slidesProp && slidesProp.length > 0) {
@ -63,6 +81,29 @@ export const ImageLightbox = ({
return list;
}, [enableZoom, enableFullscreen, showCounter, slides.length]);
// Resolve the lightbox lock callback: explicit prop takes priority, then context.
const contextLock = useContext(LightboxLockContext);
const lockCallback = onOpenChange ?? contextLock;
const lockCallbackRef = useRef(lockCallback);
lockCallbackRef.current = lockCallback;
// Track our notified state to avoid double-calling.
const notifiedOpenRef = useRef(false);
// Notify parent on mount when open=true and on unmount.
useEffect(() => {
if (open && !notifiedOpenRef.current) {
notifiedOpenRef.current = true;
lockCallbackRef.current?.(true);
}
return () => {
if (notifiedOpenRef.current) {
notifiedOpenRef.current = false;
lockCallbackRef.current?.(false);
}
};
}, [open]);
if (!open || slides.length === 0) return null;
return (

View file

@ -28,7 +28,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
@ -254,7 +254,8 @@ export const CreateTeamDialog = ({
// Re-read localStorage when advancedKey changes
useEffect(() => {
const storedEnabled = localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
const storedEnabled =
localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
setWorktreeNameRaw(storedName);
@ -497,7 +498,7 @@ export const CreateTeamDialog = ({
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
: members
.filter((m) => m.name.trim())
.map((m, index) => ({
.map((m) => ({
id: m.id,
name: m.name.trim(),
subtitle:
@ -506,7 +507,7 @@ export const CreateTeamDialog = ({
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColor(index),
color: getMemberColorByName(m.name.trim()),
})),
[members, soloTeam]
);

View file

@ -1,3 +1,4 @@
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import {
@ -5,15 +6,17 @@ import {
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
import { ArrowRight, Eye, MessageSquareX, Play, Plus, ShieldCheck } from 'lucide-react';
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react';
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
interface WorkflowTimelineProps {
events: TaskHistoryEvent[];
/** Map of member name → color name for colored badges. */
memberColorMap?: Map<string, string>;
}
export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelineProps) => {
if (events.length === 0) {
return (
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
@ -43,10 +46,14 @@ export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
{time}
</span>
<EventContent event={event} />
<EventContent event={event} memberColorMap={memberColorMap} />
{event.actor ? (
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
by {event.actor}
<span className="ml-auto shrink-0">
<MemberBadge
name={event.actor}
color={memberColorMap?.get(event.actor)}
size="sm"
/>
</span>
) : null}
</div>
@ -65,7 +72,13 @@ export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
/** Keep old name as re-export for backwards compatibility during migration. */
export const StatusHistoryTimeline = WorkflowTimeline;
const EventContent = ({ event }: { event: TaskHistoryEvent }) => {
const EventContent = ({
event,
memberColorMap,
}: {
event: TaskHistoryEvent;
memberColorMap?: Map<string, string>;
}) => {
switch (event.type) {
case 'task_created':
return (
@ -89,7 +102,12 @@ const EventContent = ({ event }: { event: TaskHistoryEvent }) => {
<Eye size={10} className="text-purple-400" />
Review requested
{event.reviewer ? (
<span className="text-[10px] text-[var(--color-text-muted)]">({event.reviewer})</span>
<MemberBadge
name={event.reviewer}
color={memberColorMap?.get(event.reviewer)}
size="sm"
hideAvatar
/>
) : null}
</span>
);

View file

@ -53,6 +53,8 @@ interface TaskCommentsSectionProps {
onTaskIdClick?: (taskId: string) => void;
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
containerClassName?: string;
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
unreadCommentIds?: Set<string>;
}
/** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */
@ -83,6 +85,7 @@ export const TaskCommentsSection = ({
onReply,
onTaskIdClick,
containerClassName,
unreadCommentIds,
}: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
@ -207,6 +210,9 @@ export const TaskCommentsSection = ({
}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{unreadCommentIds?.has(comment.id) ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" />
) : null}
<MemberBadge
name={comment.author}
color={colorMap.get(comment.author)}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColor } from '@shared/constants/memberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
@ -47,7 +47,9 @@ export const MemberDraftRow = ({
projectPath,
mentionSuggestions = [],
}: MemberDraftRowProps): React.JSX.Element => {
const memberColorSet = getTeamColorSet(getMemberColor(index));
const memberColorSet = getTeamColorSet(
getMemberColorByName(member.name.trim() || `member-${index}`)
);
const [workflowExpanded, setWorkflowExpanded] = useState(false);
const [modelExpanded, setModelExpanded] = useState(false);

View file

@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { getMemberColor } from '@shared/constants/memberColors';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -154,7 +154,7 @@ export const MembersEditorSection = ({
() =>
members
.filter((m) => m.name.trim())
.map((m, i) => ({
.map((m) => ({
id: m.id,
name: m.name.trim(),
subtitle:
@ -163,7 +163,7 @@ export const MembersEditorSection = ({
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColor(i),
color: getMemberColorByName(m.name.trim()),
})),
[members]
);

View file

@ -243,6 +243,12 @@
--step-warning-text: #fde68a;
--step-warning-border: rgba(245, 158, 11, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1);
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(255, 255, 255, 0.04);
--color-section-bg-open: rgba(255, 255, 255, 0.07);
--color-section-hover: rgba(255, 255, 255, 0.08);
--color-section-hover-open: rgba(255, 255, 255, 0.1);
}
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
@ -490,6 +496,12 @@
--step-warning-text: #b45309;
--step-warning-border: rgba(180, 83, 9, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1);
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(0, 0, 0, 0.04);
--color-section-bg-open: rgba(0, 0, 0, 0.07);
--color-section-hover: rgba(0, 0, 0, 0.08);
--color-section-hover-open: rgba(0, 0, 0, 0.1);
}
/* rehype-highlight (highlight.js) — map hljs classes to app theme variables */

View file

@ -7,8 +7,32 @@ const STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
let cache: ReadState = {};
let loaded = false;
// --- localStorage fallback ---
function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
}
// Synchronous init from localStorage — guarantees first render sees read state
const lsInitial = lsLoad();
let cache: ReadState = lsInitial ?? {};
let loaded = lsInitial !== null && Object.keys(lsInitial).length > 0;
let idbAvailable = true; // flips to false on first IndexedDB failure
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
@ -48,26 +72,10 @@ export function getUnreadCount(
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
}
// --- localStorage fallback ---
function lsLoad(): ReadState | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as ReadState)
: null;
} catch {
return null;
}
}
function lsSave(state: ReadState): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
} catch {
// localStorage full or unavailable — silently ignore
}
/** Return the last-read timestamp for a team/task pair (0 if never read). */
export function getLastReadTimestamp(teamName: string, taskId: string): number {
const key = `${teamName}/${taskId}`;
return cache[key] ?? 0;
}
// --- Internal ---
@ -90,41 +98,38 @@ function scheduleSave(): void {
async function load(): Promise<void> {
if (loaded) return;
// Try IndexedDB first
// IDB may have fresher data — merge with max timestamp per key
if (hasIndexedDB() && idbAvailable) {
try {
const stored = await get<ReadState>(IDB_KEY);
if (stored && typeof stored === 'object') {
cache = { ...stored, ...cache };
const merged = { ...cache };
for (const [k, v] of Object.entries(stored)) {
merged[k] = Math.max(merged[k] ?? 0, v);
}
cache = merged;
notify();
}
loaded = true;
return;
} catch {
// IndexedDB broken — fall back to localStorage silently
idbAvailable = false;
}
}
// Fallback: localStorage
const stored = lsLoad();
if (stored) {
cache = { ...stored, ...cache };
notify();
}
loaded = true;
}
async function save(): Promise<void> {
// Always write to localStorage (sync, reliable)
lsSave(cache);
// Also write to IndexedDB (async, primary)
if (idbAvailable && hasIndexedDB()) {
try {
await set(IDB_KEY, cache);
return;
} catch {
idbAvailable = false;
}
}
lsSave(cache);
}
export async function cleanupStale(): Promise<void> {
@ -142,20 +147,20 @@ export async function cleanupStale(): Promise<void> {
return { cleaned: result, changed };
};
const { cleaned, changed } = clean(cache);
if (!changed) return;
// Update in-memory cache
cache = cleaned;
notify();
// Persist to both storages
lsSave(cleaned);
if (idbAvailable && hasIndexedDB()) {
try {
const stored = await get<ReadState>(IDB_KEY);
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) await set(IDB_KEY, cleaned);
return;
await set(IDB_KEY, cleaned);
} catch {
idbAvailable = false;
}
}
const stored = lsLoad();
if (!stored) return;
const { cleaned, changed } = clean(stored);
if (changed) lsSave(cleaned);
}

View file

@ -1,4 +1,4 @@
import { getMemberColor, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
import { getMemberColorByName, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors';
import type {
LeadActivityState,
@ -95,29 +95,31 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
const usedColors = new Set<string>();
const paletteSize = MEMBER_COLOR_PALETTE.length;
let nextFallback = 0;
for (const member of active) {
let color = member.color;
if (!color || usedColors.has(color)) {
// Search for an unused palette color, but cap iterations to avoid
// an infinite loop when there are more members than palette colors.
const searchStart = nextFallback;
while (usedColors.has(getMemberColor(nextFallback))) {
nextFallback++;
if (nextFallback - searchStart >= paletteSize) {
// All palette colors exhausted — reuse by cycling
break;
// Deterministic fallback: hash the member name to a palette color.
// If that color is already taken, linear-probe for the next free one.
color = getMemberColorByName(member.name);
if (usedColors.has(color)) {
const startIdx = MEMBER_COLOR_PALETTE.indexOf(
color as (typeof MEMBER_COLOR_PALETTE)[number]
);
for (let offset = 1; offset < paletteSize; offset++) {
const candidate = MEMBER_COLOR_PALETTE[(startIdx + offset) % paletteSize];
if (!usedColors.has(candidate)) {
color = candidate;
break;
}
}
}
color = getMemberColor(nextFallback);
nextFallback++;
}
map.set(member.name, color);
usedColors.add(color);
}
for (let i = 0; i < removed.length; i++) {
map.set(removed[i].name, removed[i].color ?? getMemberColor(active.length + i));
map.set(removed[i].name, removed[i].color ?? getMemberColorByName(removed[i].name));
}
map.set('user', 'user');

View file

@ -90,3 +90,24 @@ export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
}
/**
* Simple deterministic hash for a string non-negative integer.
* Uses djb2 algorithm for good distribution across the palette.
*/
function hashStringToIndex(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
/**
* Get a stable color for a member name.
* The color is deterministic same name always maps to the same palette entry,
* regardless of member order or team size.
*/
export function getMemberColorByName(name: string): string {
return MEMBER_COLOR_PALETTE[hashStringToIndex(name) % MEMBER_COLOR_PALETTE.length];
}

View file

@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type { InboxMessage, TeamConfig, TeamTask } from '../../../../src/shared/types/team';
import type {
InboxMessage,
TeamConfig,
TeamTask,
TeamTaskWithKanban,
} from '../../../../src/shared/types/team';
describe('TeamMemberResolver', () => {
it('builds roster from config + meta + inbox only', () => {
@ -65,4 +70,74 @@ describe('TeamMemberResolver', () => {
expect(names).toContain('team-lead');
expect(names).toContain('alice');
});
it('sets currentTaskId for in_progress task', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBe('t1');
});
it('clears currentTaskId when task is approved via kanbanColumn', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 't1',
subject: 'Work',
status: 'in_progress',
owner: 'bob',
reviewState: 'approved',
kanbanColumn: 'approved',
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
it('clears currentTaskId when task reviewState is approved even without kanbanColumn', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{
id: 't1',
subject: 'Work',
status: 'in_progress',
owner: 'bob',
reviewState: 'approved',
// kanbanColumn not set — stale data scenario
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
it('clears currentTaskId when task status is completed', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'bob', agentType: 'general-purpose' }],
};
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
});

View file

@ -42,6 +42,22 @@ const hoisted = vi.hoisted(() => {
files.set(p, JSON.stringify(rows));
return message;
}),
sendInboxMessage: vi.fn(
(teamName: string, message: Record<string, unknown>) => {
const member =
typeof message.member === 'string'
? message.member
: typeof message.to === 'string'
? message.to
: 'unknown';
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return { deliveredToInbox: true, messageId: 'mock-id', message };
}
),
};
});
@ -67,6 +83,8 @@ vi.mock('agent-teams-controller', () => ({
messages: {
appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
},
}),
}));
@ -146,6 +164,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
beforeEach(() => {
hoisted.files.clear();
hoisted.appendSentMessage.mockClear();
hoisted.sendInboxMessage.mockClear();
});
it('pre-ready assistant text is added to liveLeadProcessMessages', () => {
@ -301,7 +320,9 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(live[0].to).toBe('team-lead');
expect(live[0].text).toBe('Need clarification on #abcd1234');
expect(live[0].source).toBe('lead_process');
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
// Non-user recipient → delivered to inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('post-ready path also uses the unified helper', () => {
@ -325,4 +346,80 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect.objectContaining({ type: 'lead-message', teamName: 'my-team' })
);
});
it('SendMessage(to:teammate) creates inbox row and emits inbox detail for recipient', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'alice',
content: 'Please check the migration.',
summary: 'Migration check',
},
},
],
});
// Delivered to recipient inbox, not sentMessages
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).toHaveBeenCalledWith(
'my-team',
expect.objectContaining({ member: 'alice' })
);
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
// Emits inbox event for the specific recipient
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'inboxes/alice.json',
})
);
});
it('SendMessage(to:user) still persists to sentMessages.json', () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
service.setTeamChangeEmitter(emitter);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'user',
content: 'Task completed!',
summary: 'Done',
},
},
],
});
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
expect(emitter).toHaveBeenCalledWith(
expect.objectContaining({
type: 'inbox',
teamName: 'my-team',
detail: 'sentMessages.json',
})
);
});
});

View file

@ -50,6 +50,22 @@ const hoisted = vi.hoisted(() => {
files.set(sentMessagesPath, JSON.stringify(rows));
return message;
}),
sendInboxMessage: vi.fn(
(teamName: string, message: Record<string, unknown>) => {
const member =
typeof message.member === 'string'
? message.member
: typeof message.to === 'string'
? message.to
: 'unknown';
const p = `/mock/teams/${teamName}/inboxes/${member}.json`;
const current = files.get(p);
const rows = current ? (JSON.parse(current) as unknown[]) : [];
rows.push(message);
files.set(p, JSON.stringify(rows));
return { deliveredToInbox: true, messageId: 'mock-id', message };
}
),
setAtomicWriteShouldFail: (next: boolean) => {
atomicWriteShouldFail = next;
},
@ -85,6 +101,8 @@ vi.mock('agent-teams-controller', () => ({
messages: {
appendSentMessage: (message: Record<string, unknown>) =>
hoisted.appendSentMessage(teamName, message),
sendMessage: (message: Record<string, unknown>) =>
hoisted.sendInboxMessage(teamName, message),
},
}),
}));