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) {
|
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) {
|
function completeTask(context, taskId, actor) {
|
||||||
|
|
@ -212,10 +224,19 @@ function addTaskComment(context, taskId, flags) {
|
||||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
try {
|
||||||
inserted: result.inserted,
|
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||||
notifyOwner: flags.notifyOwner,
|
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 {
|
return {
|
||||||
commentId: result.comment.id,
|
commentId: result.comment.id,
|
||||||
|
|
|
||||||
|
|
@ -532,4 +532,32 @@ describe('agent-teams-controller API', () => {
|
||||||
controller.processes.unregisterProcess({ id: 'stale-entry' });
|
controller.processes.unregisterProcess({ id: 'stale-entry' });
|
||||||
expect(controller.processes.listProcesses()).toEqual([]);
|
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');
|
expect(approved.reviewState).toBe('approved');
|
||||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
{
|
||||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||||
expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-1');
|
const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8'));
|
||||||
|
expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1');
|
||||||
|
}
|
||||||
|
|
||||||
const kanbanState = parseJsonToolResult(
|
const kanbanState = parseJsonToolResult(
|
||||||
await getTool('kanban_get').execute({
|
await getTool('kanban_get').execute({
|
||||||
|
|
@ -549,4 +551,48 @@ describe('agent-teams-mcp tools', () => {
|
||||||
}).success
|
}).success
|
||||||
).toBe(false);
|
).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,
|
AGENT_BLOCK_OPEN,
|
||||||
stripAgentBlocks,
|
stripAgentBlocks,
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -622,7 +622,7 @@ export class TeamDataService {
|
||||||
role: request.role?.trim() || undefined,
|
role: request.role?.trim() || undefined,
|
||||||
workflow: request.workflow?.trim() || undefined,
|
workflow: request.workflow?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColor(members.filter((m) => !m.removedAt).length),
|
color: getMemberColorByName(name),
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -662,7 +662,7 @@ export class TeamDataService {
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const nextByName = new Set<string>();
|
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();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
if (name.toLowerCase() === 'team-lead') {
|
if (name.toLowerCase() === 'team-lead') {
|
||||||
|
|
@ -681,7 +681,7 @@ export class TeamDataService {
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.trim() || undefined,
|
workflow: member.workflow?.trim() || undefined,
|
||||||
agentType: prev?.agentType ?? 'general-purpose',
|
agentType: prev?.agentType ?? 'general-purpose',
|
||||||
color: prev?.color ?? getMemberColor(index),
|
color: prev?.color ?? getMemberColorByName(name),
|
||||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||||
removedAt: undefined,
|
removedAt: undefined,
|
||||||
};
|
};
|
||||||
|
|
@ -1113,7 +1113,7 @@ export class TeamDataService {
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
request.teamName,
|
request.teamName,
|
||||||
request.members.map((member, index) => ({
|
request.members.map((member) => ({
|
||||||
name: (() => {
|
name: (() => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
|
|
@ -1129,7 +1129,7 @@ export class TeamDataService {
|
||||||
})(),
|
})(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColor(index),
|
color: getMemberColorByName(member.name.trim()),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,10 @@ export class TeamMemberResolver {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||||
const currentTask =
|
const currentTask =
|
||||||
ownedTasks.find(
|
ownedTasks.find(
|
||||||
(task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved'
|
(task) =>
|
||||||
|
task.status === 'in_progress' &&
|
||||||
|
task.reviewState !== 'approved' &&
|
||||||
|
task.kanbanColumn !== 'approved'
|
||||||
) ?? null;
|
) ?? null;
|
||||||
const memberMessages = messages.filter((message) => message.from === name);
|
const memberMessages = messages.filter((message) => message.from === name);
|
||||||
const latestMessage = memberMessages[0] ?? null;
|
const latestMessage = memberMessages[0] ?? null;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
stripAgentBlocks,
|
stripAgentBlocks,
|
||||||
} from '@shared/constants/agentBlocks';
|
} 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 { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
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").
|
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||||
|
|
||||||
Message formatting:
|
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}
|
${agentBlockPolicy}
|
||||||
|
|
||||||
${membersFooter}`;
|
${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 {
|
private getMemberRelayKey(teamName: string, memberName: string): string {
|
||||||
return `${teamName}:${memberName.trim()}`;
|
return `${teamName}:${memberName.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
@ -2923,12 +2948,24 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pushLiveLeadProcessMessage(run.teamName, msg);
|
this.pushLiveLeadProcessMessage(run.teamName, msg);
|
||||||
this.persistSentMessage(run.teamName, msg);
|
|
||||||
this.teamChangeEmitter?.({
|
if (recipient === 'user') {
|
||||||
type: 'inbox',
|
// User-directed messages go to sentMessages.json (canonical outbound store)
|
||||||
teamName: run.teamName,
|
this.persistSentMessage(run.teamName, msg);
|
||||||
detail: 'sentMessages.json',
|
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(
|
logger.debug(
|
||||||
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
|
`[${run.teamName}] Captured SendMessage→${recipient} from stdout: ${cleanContent.slice(0, 100)}`
|
||||||
|
|
@ -5183,12 +5220,12 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
teamName,
|
teamName,
|
||||||
teammateMembers.map((member, index) => ({
|
teammateMembers.map((member) => ({
|
||||||
name: member.name.trim(),
|
name: member.name.trim(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.trim() || undefined,
|
workflow: member.workflow?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColor(index),
|
color: getMemberColorByName(member.name.trim()),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,12 @@ const AIChatGroupInner = ({
|
||||||
// Determine if there's content to toggle
|
// Determine if there's content to toggle
|
||||||
const hasToggleContent = enhanced.displayItems.length > 0;
|
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
|
// Handle item click - toggle inline expansion using store action
|
||||||
const handleItemClick = (itemId: string): void => {
|
const handleItemClick = (itemId: string): void => {
|
||||||
toggleDisplayItemExpansion(aiGroup.id, itemId);
|
toggleDisplayItemExpansion(aiGroup.id, itemId);
|
||||||
|
|
@ -501,6 +507,11 @@ const AIChatGroupInner = ({
|
||||||
</div>
|
</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 */}
|
{/* Expandable Content */}
|
||||||
{hasToggleContent && isExpanded && (
|
{hasToggleContent && isExpanded && (
|
||||||
<div className="py-2 pl-2">
|
<div className="py-2 pl-2">
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export const CollapsibleTeamSection = ({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,13 @@ export const ActiveTasksBlock = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
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;
|
if (working.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -342,24 +342,10 @@ const LeadThoughtItem = ({
|
||||||
<div ref={wrapperRef}>
|
<div ref={wrapperRef}>
|
||||||
<div ref={contentRef}>
|
<div ref={contentRef}>
|
||||||
{showDivider && (
|
{showDivider && (
|
||||||
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
|
<div className="py-px text-center">
|
||||||
<hr
|
<span className="font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
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 }}>
|
|
||||||
{formatTimeWithSec(thought.timestamp)}
|
{formatTimeWithSec(thought.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<hr
|
|
||||||
className="flex-1 border-0"
|
|
||||||
style={{
|
|
||||||
height: '1px',
|
|
||||||
backgroundColor: 'var(--color-border-emphasis)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="group/thought relative flex text-[11px]">
|
<div className="group/thought relative flex text-[11px]">
|
||||||
|
|
@ -746,6 +732,20 @@ export const LeadThoughtsGroupRow = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||||
{isBodyVisible ? (
|
{isBodyVisible ? (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'yet-another-react-lightbox/styles.css';
|
import 'yet-another-react-lightbox/styles.css';
|
||||||
import 'yet-another-react-lightbox/plugins/counter.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 Lightbox from 'yet-another-react-lightbox';
|
||||||
import Counter from 'yet-another-react-lightbox/plugins/counter';
|
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';
|
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 {
|
export interface ImageLightboxSlide {
|
||||||
src: string;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
|
@ -30,6 +45,8 @@ interface ImageLightboxProps {
|
||||||
enableZoom?: boolean;
|
enableZoom?: boolean;
|
||||||
enableFullscreen?: boolean;
|
enableFullscreen?: boolean;
|
||||||
showCounter?: boolean;
|
showCounter?: boolean;
|
||||||
|
/** Called when lightbox open state changes (useful for parent components to block dismiss). */
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageLightbox = ({
|
export const ImageLightbox = ({
|
||||||
|
|
@ -42,6 +59,7 @@ export const ImageLightbox = ({
|
||||||
enableZoom = true,
|
enableZoom = true,
|
||||||
enableFullscreen = true,
|
enableFullscreen = true,
|
||||||
showCounter,
|
showCounter,
|
||||||
|
onOpenChange,
|
||||||
}: ImageLightboxProps): React.JSX.Element | null => {
|
}: ImageLightboxProps): React.JSX.Element | null => {
|
||||||
const slides = useMemo<Slide[]>(() => {
|
const slides = useMemo<Slide[]>(() => {
|
||||||
if (slidesProp && slidesProp.length > 0) {
|
if (slidesProp && slidesProp.length > 0) {
|
||||||
|
|
@ -63,6 +81,29 @@ export const ImageLightbox = ({
|
||||||
return list;
|
return list;
|
||||||
}, [enableZoom, enableFullscreen, showCounter, slides.length]);
|
}, [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;
|
if (!open || slides.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
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 { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
|
@ -254,7 +254,8 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
// Re-read localStorage when advancedKey changes
|
// Re-read localStorage when advancedKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedEnabled = localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
|
const storedEnabled =
|
||||||
|
localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
|
||||||
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
|
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
|
||||||
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
|
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
|
||||||
setWorktreeNameRaw(storedName);
|
setWorktreeNameRaw(storedName);
|
||||||
|
|
@ -497,7 +498,7 @@ export const CreateTeamDialog = ({
|
||||||
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
? [{ id: 'team-lead', name: 'team-lead', subtitle: 'Team Lead', color: 'blue' }]
|
||||||
: members
|
: members
|
||||||
.filter((m) => m.name.trim())
|
.filter((m) => m.name.trim())
|
||||||
.map((m, index) => ({
|
.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
subtitle:
|
subtitle:
|
||||||
|
|
@ -506,7 +507,7 @@ export const CreateTeamDialog = ({
|
||||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
: m.roleSelection && m.roleSelection !== NO_ROLE
|
||||||
? m.roleSelection
|
? m.roleSelection
|
||||||
: undefined,
|
: undefined,
|
||||||
color: getMemberColor(index),
|
color: getMemberColorByName(m.name.trim()),
|
||||||
})),
|
})),
|
||||||
[members, soloTeam]
|
[members, soloTeam]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|
@ -5,15 +6,17 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} 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';
|
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
interface WorkflowTimelineProps {
|
interface WorkflowTimelineProps {
|
||||||
events: TaskHistoryEvent[];
|
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) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<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)]">
|
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
{time}
|
{time}
|
||||||
</span>
|
</span>
|
||||||
<EventContent event={event} />
|
<EventContent event={event} memberColorMap={memberColorMap} />
|
||||||
{event.actor ? (
|
{event.actor ? (
|
||||||
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
<span className="ml-auto shrink-0">
|
||||||
by {event.actor}
|
<MemberBadge
|
||||||
|
name={event.actor}
|
||||||
|
color={memberColorMap?.get(event.actor)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -65,7 +72,13 @@ export const WorkflowTimeline = ({ events }: WorkflowTimelineProps) => {
|
||||||
/** Keep old name as re-export for backwards compatibility during migration. */
|
/** Keep old name as re-export for backwards compatibility during migration. */
|
||||||
export const StatusHistoryTimeline = WorkflowTimeline;
|
export const StatusHistoryTimeline = WorkflowTimeline;
|
||||||
|
|
||||||
const EventContent = ({ event }: { event: TaskHistoryEvent }) => {
|
const EventContent = ({
|
||||||
|
event,
|
||||||
|
memberColorMap,
|
||||||
|
}: {
|
||||||
|
event: TaskHistoryEvent;
|
||||||
|
memberColorMap?: Map<string, string>;
|
||||||
|
}) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'task_created':
|
case 'task_created':
|
||||||
return (
|
return (
|
||||||
|
|
@ -89,7 +102,12 @@ const EventContent = ({ event }: { event: TaskHistoryEvent }) => {
|
||||||
<Eye size={10} className="text-purple-400" />
|
<Eye size={10} className="text-purple-400" />
|
||||||
Review requested
|
Review requested
|
||||||
{event.reviewer ? (
|
{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}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ interface TaskCommentsSectionProps {
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
|
/** Extra className on the outer comments container (e.g. negative margins for edge-to-edge). */
|
||||||
containerClassName?: string;
|
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. */
|
/** Convert `#<task-display-id>` in plain text to markdown links with task:// protocol. */
|
||||||
|
|
@ -83,6 +85,7 @@ export const TaskCommentsSection = ({
|
||||||
onReply,
|
onReply,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
|
unreadCommentIds,
|
||||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
const addingComment = useStore((s) => s.addingComment);
|
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)]">
|
<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
|
<MemberBadge
|
||||||
name={comment.author}
|
name={comment.author}
|
||||||
color={colorMap.get(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 { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
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 { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
|
|
@ -47,7 +47,9 @@ export const MemberDraftRow = ({
|
||||||
projectPath,
|
projectPath,
|
||||||
mentionSuggestions = [],
|
mentionSuggestions = [],
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
const memberColorSet = getTeamColorSet(
|
||||||
|
getMemberColorByName(member.name.trim() || `member-${index}`)
|
||||||
|
);
|
||||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||||
const [modelExpanded, setModelExpanded] = 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 { Button } from '@renderer/components/ui/button';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
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';
|
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ export const MembersEditorSection = ({
|
||||||
() =>
|
() =>
|
||||||
members
|
members
|
||||||
.filter((m) => m.name.trim())
|
.filter((m) => m.name.trim())
|
||||||
.map((m, i) => ({
|
.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
subtitle:
|
subtitle:
|
||||||
|
|
@ -163,7 +163,7 @@ export const MembersEditorSection = ({
|
||||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
: m.roleSelection && m.roleSelection !== NO_ROLE
|
||||||
? m.roleSelection
|
? m.roleSelection
|
||||||
: undefined,
|
: undefined,
|
||||||
color: getMemberColor(i),
|
color: getMemberColorByName(m.name.trim()),
|
||||||
})),
|
})),
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,12 @@
|
||||||
--step-warning-text: #fde68a;
|
--step-warning-text: #fde68a;
|
||||||
--step-warning-border: rgba(245, 158, 11, 0.4);
|
--step-warning-border: rgba(245, 158, 11, 0.4);
|
||||||
--step-warning-bg: rgba(245, 158, 11, 0.1);
|
--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 */
|
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
|
||||||
|
|
@ -490,6 +496,12 @@
|
||||||
--step-warning-text: #b45309;
|
--step-warning-text: #b45309;
|
||||||
--step-warning-border: rgba(180, 83, 9, 0.4);
|
--step-warning-border: rgba(180, 83, 9, 0.4);
|
||||||
--step-warning-bg: rgba(245, 158, 11, 0.1);
|
--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 */
|
/* 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
|
type ReadState = Record<string, number>; // key = "teamName/taskId", value = timestamp
|
||||||
|
|
||||||
let cache: ReadState = {};
|
// --- localStorage fallback ---
|
||||||
let loaded = false;
|
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 idbAvailable = true; // flips to false on first IndexedDB failure
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
@ -48,26 +72,10 @@ export function getUnreadCount(
|
||||||
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
return comments.filter((c) => new Date(c.createdAt).getTime() > lastRead).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- localStorage fallback ---
|
/** Return the last-read timestamp for a team/task pair (0 if never read). */
|
||||||
function lsLoad(): ReadState | null {
|
export function getLastReadTimestamp(teamName: string, taskId: string): number {
|
||||||
try {
|
const key = `${teamName}/${taskId}`;
|
||||||
const raw = localStorage.getItem(LS_KEY);
|
return cache[key] ?? 0;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Internal ---
|
// --- Internal ---
|
||||||
|
|
@ -90,41 +98,38 @@ function scheduleSave(): void {
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
|
|
||||||
// Try IndexedDB first
|
// IDB may have fresher data — merge with max timestamp per key
|
||||||
if (hasIndexedDB() && idbAvailable) {
|
if (hasIndexedDB() && idbAvailable) {
|
||||||
try {
|
try {
|
||||||
const stored = await get<ReadState>(IDB_KEY);
|
const stored = await get<ReadState>(IDB_KEY);
|
||||||
if (stored && typeof stored === 'object') {
|
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();
|
notify();
|
||||||
}
|
}
|
||||||
loaded = true;
|
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
// IndexedDB broken — fall back to localStorage silently
|
|
||||||
idbAvailable = false;
|
idbAvailable = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: localStorage
|
|
||||||
const stored = lsLoad();
|
|
||||||
if (stored) {
|
|
||||||
cache = { ...stored, ...cache };
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
|
// Always write to localStorage (sync, reliable)
|
||||||
|
lsSave(cache);
|
||||||
|
|
||||||
|
// Also write to IndexedDB (async, primary)
|
||||||
if (idbAvailable && hasIndexedDB()) {
|
if (idbAvailable && hasIndexedDB()) {
|
||||||
try {
|
try {
|
||||||
await set(IDB_KEY, cache);
|
await set(IDB_KEY, cache);
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
idbAvailable = false;
|
idbAvailable = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lsSave(cache);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupStale(): Promise<void> {
|
export async function cleanupStale(): Promise<void> {
|
||||||
|
|
@ -142,20 +147,20 @@ export async function cleanupStale(): Promise<void> {
|
||||||
return { cleaned: result, changed };
|
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()) {
|
if (idbAvailable && hasIndexedDB()) {
|
||||||
try {
|
try {
|
||||||
const stored = await get<ReadState>(IDB_KEY);
|
await set(IDB_KEY, cleaned);
|
||||||
if (!stored) return;
|
|
||||||
const { cleaned, changed } = clean(stored);
|
|
||||||
if (changed) await set(IDB_KEY, cleaned);
|
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
idbAvailable = false;
|
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 {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
|
@ -95,29 +95,31 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
|
||||||
const usedColors = new Set<string>();
|
const usedColors = new Set<string>();
|
||||||
|
|
||||||
const paletteSize = MEMBER_COLOR_PALETTE.length;
|
const paletteSize = MEMBER_COLOR_PALETTE.length;
|
||||||
let nextFallback = 0;
|
|
||||||
for (const member of active) {
|
for (const member of active) {
|
||||||
let color = member.color;
|
let color = member.color;
|
||||||
if (!color || usedColors.has(color)) {
|
if (!color || usedColors.has(color)) {
|
||||||
// Search for an unused palette color, but cap iterations to avoid
|
// Deterministic fallback: hash the member name to a palette color.
|
||||||
// an infinite loop when there are more members than palette colors.
|
// If that color is already taken, linear-probe for the next free one.
|
||||||
const searchStart = nextFallback;
|
color = getMemberColorByName(member.name);
|
||||||
while (usedColors.has(getMemberColor(nextFallback))) {
|
if (usedColors.has(color)) {
|
||||||
nextFallback++;
|
const startIdx = MEMBER_COLOR_PALETTE.indexOf(
|
||||||
if (nextFallback - searchStart >= paletteSize) {
|
color as (typeof MEMBER_COLOR_PALETTE)[number]
|
||||||
// All palette colors exhausted — reuse by cycling
|
);
|
||||||
break;
|
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);
|
map.set(member.name, color);
|
||||||
usedColors.add(color);
|
usedColors.add(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < removed.length; i++) {
|
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');
|
map.set('user', 'user');
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,24 @@ export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number];
|
||||||
export function getMemberColor(index: number): string {
|
export function getMemberColor(index: number): string {
|
||||||
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
|
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 { 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', () => {
|
describe('TeamMemberResolver', () => {
|
||||||
it('builds roster from config + meta + inbox only', () => {
|
it('builds roster from config + meta + inbox only', () => {
|
||||||
|
|
@ -65,4 +70,74 @@ describe('TeamMemberResolver', () => {
|
||||||
expect(names).toContain('team-lead');
|
expect(names).toContain('team-lead');
|
||||||
expect(names).toContain('alice');
|
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));
|
files.set(p, JSON.stringify(rows));
|
||||||
return message;
|
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: {
|
messages: {
|
||||||
appendSentMessage: (message: Record<string, unknown>) =>
|
appendSentMessage: (message: Record<string, unknown>) =>
|
||||||
hoisted.appendSentMessage(teamName, message),
|
hoisted.appendSentMessage(teamName, message),
|
||||||
|
sendMessage: (message: Record<string, unknown>) =>
|
||||||
|
hoisted.sendInboxMessage(teamName, message),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
@ -146,6 +164,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hoisted.files.clear();
|
hoisted.files.clear();
|
||||||
hoisted.appendSentMessage.mockClear();
|
hoisted.appendSentMessage.mockClear();
|
||||||
|
hoisted.sendInboxMessage.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pre-ready assistant text is added to liveLeadProcessMessages', () => {
|
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].to).toBe('team-lead');
|
||||||
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
||||||
expect(live[0].source).toBe('lead_process');
|
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', () => {
|
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' })
|
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));
|
files.set(sentMessagesPath, JSON.stringify(rows));
|
||||||
return message;
|
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) => {
|
setAtomicWriteShouldFail: (next: boolean) => {
|
||||||
atomicWriteShouldFail = next;
|
atomicWriteShouldFail = next;
|
||||||
},
|
},
|
||||||
|
|
@ -85,6 +101,8 @@ vi.mock('agent-teams-controller', () => ({
|
||||||
messages: {
|
messages: {
|
||||||
appendSentMessage: (message: Record<string, unknown>) =>
|
appendSentMessage: (message: Record<string, unknown>) =>
|
||||||
hoisted.appendSentMessage(teamName, message),
|
hoisted.appendSentMessage(teamName, message),
|
||||||
|
sendMessage: (message: Record<string, unknown>) =>
|
||||||
|
hoisted.sendInboxMessage(teamName, message),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue