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:
parent
9adffb2295
commit
3f923c480e
24 changed files with 1215 additions and 721 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue