+
React.ReactNode;
+ /** Custom label renderer for the trigger button (closed state). */
+ renderTriggerLabel?: (option: ComboboxOption) => React.ReactNode;
/** Label for the reset item shown at the top of the dropdown. */
resetLabel?: string;
/** Called when the user clicks the reset item. */
@@ -40,6 +42,7 @@ export const Combobox = ({
disabled = false,
className,
renderOption,
+ renderTriggerLabel,
resetLabel,
onReset,
}: ComboboxProps): React.JSX.Element => {
@@ -64,7 +67,9 @@ export const Combobox = ({
)}
>
- {selectedOption ? selectedOption.label : placeholder}
+ {selectedOption
+ ? (renderTriggerLabel?.(selectedOption) ?? selectedOption.label)
+ : placeholder}
diff --git a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
index 4c6c7b10..0b71dc1e 100644
--- a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
+++ b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
@@ -1,10 +1,9 @@
+import { cn } from '@renderer/lib/utils';
import { useCurrentEditor, useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { Bold, Code, Italic, Strikethrough } from 'lucide-react';
-import { cn } from '@renderer/lib/utils';
-
-export function TiptapBubbleMenu() {
+export const TiptapBubbleMenu = () => {
const { editor } = useCurrentEditor();
const state = useEditorState({
@@ -73,4 +72,4 @@ export function TiptapBubbleMenu() {
);
-}
+};
diff --git a/src/renderer/components/ui/tiptap/TiptapEditor.tsx b/src/renderer/components/ui/tiptap/TiptapEditor.tsx
index aa45561f..f72d5d91 100644
--- a/src/renderer/components/ui/tiptap/TiptapEditor.tsx
+++ b/src/renderer/components/ui/tiptap/TiptapEditor.tsx
@@ -1,16 +1,17 @@
-import { EditorContent, EditorContext } from '@tiptap/react';
+import './tiptapStyles.css';
+
import { useMemo } from 'react';
import { cn } from '@renderer/lib/utils';
+import { EditorContent, EditorContext } from '@tiptap/react';
import { TiptapBubbleMenu } from './TiptapBubbleMenu';
import { TiptapToolbar } from './TiptapToolbar';
-import type { TiptapEditorProps } from './types';
import { useTiptapEditor } from './useTiptapEditor';
-import './tiptapStyles.css';
+import type { TiptapEditorProps } from './types';
-export function TiptapEditor({
+export const TiptapEditor = ({
content,
onChange,
placeholder,
@@ -23,7 +24,7 @@ export function TiptapEditor({
extensions,
className,
disabled = false,
-}: TiptapEditorProps) {
+}: TiptapEditorProps) => {
const isEditable = editable && !disabled;
const { editor } = useTiptapEditor({
content,
@@ -69,4 +70,4 @@ export function TiptapEditor({
);
-}
+};
diff --git a/src/renderer/components/ui/tiptap/TiptapToolbar.tsx b/src/renderer/components/ui/tiptap/TiptapToolbar.tsx
index 2d470938..ad7060af 100644
--- a/src/renderer/components/ui/tiptap/TiptapToolbar.tsx
+++ b/src/renderer/components/ui/tiptap/TiptapToolbar.tsx
@@ -1,3 +1,4 @@
+import { cn } from '@renderer/lib/utils';
import { useCurrentEditor, useEditorState } from '@tiptap/react';
import {
Bold,
@@ -16,8 +17,6 @@ import {
Undo2,
} from 'lucide-react';
-import { cn } from '@renderer/lib/utils';
-
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { ToolbarConfig } from './types';
@@ -26,7 +25,7 @@ interface TiptapToolbarProps {
config?: ToolbarConfig;
}
-function ToolbarButton({
+const ToolbarButton = ({
icon,
active,
disabled,
@@ -38,7 +37,7 @@ function ToolbarButton({
disabled?: boolean;
onClick: () => void;
label: string;
-}) {
+}) => {
return (
@@ -65,13 +64,13 @@ function ToolbarButton({
);
-}
+};
-function Divider() {
+const Divider = () => {
return
;
-}
+};
-export function TiptapToolbar({ config }: TiptapToolbarProps) {
+export const TiptapToolbar = ({ config }: TiptapToolbarProps) => {
const { editor } = useCurrentEditor();
// useEditorState — КРИТИЧНО для v3!
@@ -89,8 +88,7 @@ export function TiptapToolbar({ config }: TiptapToolbarProps) {
isBulletList: e.isActive('bulletList'),
isOrderedList: e.isActive('orderedList'),
isBlockquote: e.isActive('blockquote'),
- headingLevel:
- ([1, 2, 3] as const).find((l) => e.isActive('heading', { level: l })) ?? 0,
+ headingLevel: ([1, 2, 3] as const).find((l) => e.isActive('heading', { level: l })) ?? 0,
canUndo: e.can().undo(),
canRedo: e.can().redo(),
};
@@ -268,4 +266,4 @@ export function TiptapToolbar({ config }: TiptapToolbarProps) {
))}
);
-}
+};
diff --git a/src/renderer/components/ui/tiptap/index.ts b/src/renderer/components/ui/tiptap/index.ts
index 9d770de8..74ea9e2b 100644
--- a/src/renderer/components/ui/tiptap/index.ts
+++ b/src/renderer/components/ui/tiptap/index.ts
@@ -1,3 +1,3 @@
+export { EDITOR_PRESETS } from './presets';
export { TiptapEditor } from './TiptapEditor';
export type { EditorPreset, TiptapEditorProps, ToolbarConfig } from './types';
-export { EDITOR_PRESETS } from './presets';
diff --git a/src/renderer/components/ui/tiptap/useTiptapEditor.ts b/src/renderer/components/ui/tiptap/useTiptapEditor.ts
index cd34abc0..32ea7888 100644
--- a/src/renderer/components/ui/tiptap/useTiptapEditor.ts
+++ b/src/renderer/components/ui/tiptap/useTiptapEditor.ts
@@ -1,8 +1,9 @@
+import { useEffect, useRef } from 'react';
+
import Placeholder from '@tiptap/extension-placeholder';
import { Markdown } from '@tiptap/markdown';
import { type Extension, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
-import { useEffect, useRef } from 'react';
interface UseTiptapEditorOptions {
content: string;
diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts
index 6bb7c17c..019a21f0 100644
--- a/src/renderer/hooks/useComposerDraft.ts
+++ b/src/renderer/hooks/useComposerDraft.ts
@@ -25,7 +25,7 @@ import {
} from '@renderer/utils/attachmentUtils';
import type { InlineChip } from '@renderer/types/inlineChip';
-import type { AttachmentPayload, AgentActionMode } from '@shared/types';
+import type { AgentActionMode, AttachmentPayload } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts
index b80a6775..a273bc69 100644
--- a/src/renderer/hooks/useMentionDetection.ts
+++ b/src/renderer/hooks/useMentionDetection.ts
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react';
+import { type Dispatch, type SetStateAction, useCallback, useRef, useState } from 'react';
import {
getSuggestionInsertionText,
diff --git a/src/renderer/services/commentReadStorage.ts b/src/renderer/services/commentReadStorage.ts
index 459932b4..295321da 100644
--- a/src/renderer/services/commentReadStorage.ts
+++ b/src/renderer/services/commentReadStorage.ts
@@ -262,7 +262,7 @@ async function load(): Promise
{
const merged = { ...cache };
for (const [k, v] of Object.entries(stored)) {
if (!v || typeof v !== 'object') continue;
- const entry = v as TaskReadEntry;
+ const entry = v;
const prev = merged[k];
if (!prev) {
merged[k] = entry;
diff --git a/src/renderer/services/composerDraftStorage.ts b/src/renderer/services/composerDraftStorage.ts
index ceb73836..5a59e7cc 100644
--- a/src/renderer/services/composerDraftStorage.ts
+++ b/src/renderer/services/composerDraftStorage.ts
@@ -9,7 +9,7 @@
import { del, get, set } from 'idb-keyval';
import type { InlineChip } from '@renderer/types/inlineChip';
-import type { AttachmentPayload, AgentActionMode } from '@shared/types';
+import type { AgentActionMode, AttachmentPayload } from '@shared/types';
// ---------------------------------------------------------------------------
// Types
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts
index c6d3370a..c63757e4 100644
--- a/src/renderer/store/index.ts
+++ b/src/renderer/store/index.ts
@@ -15,10 +15,10 @@ import { createConversationSlice } from './slices/conversationSlice';
import { createEditorSlice } from './slices/editorSlice';
import { createExtensionsSlice } from './slices/extensionsSlice';
import { createNotificationSlice } from './slices/notificationSlice';
-import { createScheduleSlice } from './slices/scheduleSlice';
import { createPaneSlice } from './slices/paneSlice';
import { createProjectSlice } from './slices/projectSlice';
import { createRepositorySlice } from './slices/repositorySlice';
+import { createScheduleSlice } from './slices/scheduleSlice';
import { createSessionDetailSlice } from './slices/sessionDetailSlice';
import { createSessionSlice } from './slices/sessionSlice';
import { createSubagentSlice } from './slices/subagentSlice';
diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts
index c2c652f4..aceba6dd 100644
--- a/src/renderer/store/slices/changeReviewSlice.ts
+++ b/src/renderer/store/slices/changeReviewSlice.ts
@@ -1314,10 +1314,8 @@ export const createChangeReviewSlice: StateCreator();
const notifiedStatusChangeKeys = new Set();
const notifiedCommentKeys = new Set();
+const notifiedCreatedTaskKeys = new Set();
+const notifiedAllCompletedTeams = new Set();
let isFirstFetchAllTasks = true;
@@ -181,10 +184,11 @@ function detectStatusChangeNotifications(
const taskKanbanColumn = getTaskKanbanColumn(task);
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
+ const becameReview = taskKanbanColumn === 'review' && oldTaskKanbanColumn !== 'review';
const becameNeedsFix = task.reviewState === 'needsFix' && oldTask.reviewState !== 'needsFix';
const statusChanged = oldTask.status !== task.status;
- if (!statusChanged && !becameApproved && !becameNeedsFix) continue;
+ if (!statusChanged && !becameApproved && !becameReview && !becameNeedsFix) continue;
if (onlySolo) {
const team = teamByName[task.teamName];
@@ -192,18 +196,30 @@ function detectStatusChangeNotifications(
}
// Resolve the effective status for notification matching
- const effectiveStatus = becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : task.status;
+ const effectiveStatus = becameApproved
+ ? 'approved'
+ : becameReview
+ ? 'review'
+ : becameNeedsFix
+ ? 'needsFix'
+ : task.status;
if (!statuses.includes(effectiveStatus)) continue;
const key = `${task.teamName}:${task.id}:${effectiveStatus}`;
if (notifiedStatusChangeKeys.has(key)) continue;
notifiedStatusChangeKeys.add(key);
- const fromLabel = becameApproved ? 'Completed' : oldTask.status;
+ const fromLabel = becameApproved ? 'Completed' : becameReview ? 'Completed' : oldTask.status;
fireStatusChangeNotification(
task,
fromLabel,
- becameApproved ? 'approved' : becameNeedsFix ? 'needsFix' : undefined,
+ becameApproved
+ ? 'approved'
+ : becameReview
+ ? 'review'
+ : becameNeedsFix
+ ? 'needsFix'
+ : undefined,
!statusChangeEnabled
);
}
@@ -220,6 +236,7 @@ function fireStatusChangeNotification(
in_progress: 'In Progress',
completed: 'Completed',
deleted: 'Deleted',
+ review: 'Review',
needsFix: 'Needs Fixes',
approved: 'Approved',
};
@@ -295,6 +312,97 @@ function fireTaskCommentNotification(
.catch(() => undefined);
}
+function detectTaskCreatedNotifications(
+ oldTasks: GlobalTask[],
+ newTasks: GlobalTask[],
+ notifyEnabled: boolean
+): void {
+ const oldTaskKeys = new Set(oldTasks.map((t) => `${t.teamName}:${t.id}`));
+
+ for (const task of newTasks) {
+ const key = `${task.teamName}:${task.id}`;
+ if (oldTaskKeys.has(key)) continue;
+ if (notifiedCreatedTaskKeys.has(key)) continue;
+ notifiedCreatedTaskKeys.add(key);
+
+ fireTaskCreatedNotification(task, !notifyEnabled);
+ }
+}
+
+function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean): void {
+ void api.teams
+ ?.showMessageNotification({
+ teamName: task.teamName,
+ teamDisplayName: task.teamDisplayName,
+ from: task.owner ?? 'system',
+ to: 'user',
+ summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
+ body: task.description || task.subject,
+ teamEventType: 'task_created',
+ dedupeKey: `created:${task.teamName}:${task.id}`,
+ suppressToast,
+ })
+ .catch(() => undefined);
+}
+
+function detectAllTasksCompletedNotification(
+ oldTasks: GlobalTask[],
+ newTasks: GlobalTask[],
+ notifyEnabled: boolean
+): void {
+ // Group tasks by team
+ const teamTasks = new Map();
+ for (const task of newTasks) {
+ const list = teamTasks.get(task.teamName) ?? [];
+ list.push(task);
+ teamTasks.set(task.teamName, list);
+ }
+
+ for (const [teamName, tasks] of teamTasks) {
+ if (tasks.length === 0) continue;
+ const allCompleted = tasks.every((t) => t.status === 'completed' || t.status === 'deleted');
+ if (!allCompleted) {
+ // Reset so we can notify again if tasks become all-completed later
+ notifiedAllCompletedTeams.delete(teamName);
+ continue;
+ }
+ if (notifiedAllCompletedTeams.has(teamName)) continue;
+
+ // Check that at least one task was NOT completed before (real transition)
+ const oldTeamTasks = oldTasks.filter((t) => t.teamName === teamName);
+ const wasAlreadyAllCompleted =
+ oldTeamTasks.length > 0 &&
+ oldTeamTasks.every((t) => t.status === 'completed' || t.status === 'deleted');
+ if (wasAlreadyAllCompleted) {
+ notifiedAllCompletedTeams.add(teamName);
+ continue;
+ }
+
+ notifiedAllCompletedTeams.add(teamName);
+ fireAllTasksCompletedNotification(tasks[0], tasks.length, !notifyEnabled);
+ }
+}
+
+function fireAllTasksCompletedNotification(
+ sampleTask: GlobalTask,
+ taskCount: number,
+ suppressToast: boolean
+): void {
+ void api.teams
+ ?.showMessageNotification({
+ teamName: sampleTask.teamName,
+ teamDisplayName: sampleTask.teamDisplayName,
+ from: 'system',
+ to: 'user',
+ summary: `All ${taskCount} tasks completed`,
+ body: `All tasks in team "${sampleTask.teamDisplayName}" are done`,
+ teamEventType: 'all_tasks_completed',
+ dedupeKey: `all-done:${sampleTask.teamName}:${Date.now()}`,
+ suppressToast,
+ })
+ .catch(() => undefined);
+}
+
function collectTaskChangeInvalidationState(
teamName: string,
prevTasks: TeamData['tasks'],
@@ -852,6 +960,11 @@ export const createTeamSlice: StateCreator = (set,
detectStatusChangeNotifications(oldTasks, tasks, get().appConfig, get().teamByName);
const notifyOnTaskComments = get().appConfig?.notifications?.notifyOnTaskComments ?? true;
detectTaskCommentNotifications(oldTasks, tasks, notifyOnTaskComments);
+ const notifyOnTaskCreated = get().appConfig?.notifications?.notifyOnTaskCreated ?? true;
+ detectTaskCreatedNotifications(oldTasks, tasks, notifyOnTaskCreated);
+ const notifyOnAllCompleted =
+ get().appConfig?.notifications?.notifyOnAllTasksCompleted ?? true;
+ detectAllTasksCompletedNotification(oldTasks, tasks, notifyOnAllCompleted);
} else {
// Initial load — seed the Sets to prevent false notifications on next update
for (const task of tasks) {
@@ -865,10 +978,27 @@ export const createTeamSlice: StateCreator = (set,
if (getTaskKanbanColumn(task) === 'approved') {
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
}
+ if (getTaskKanbanColumn(task) === 'review') {
+ notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:review`);
+ }
// Seed comment keys to prevent false notifications
for (const comment of task.comments ?? []) {
notifiedCommentKeys.add(`${task.teamName}:${task.id}:${comment.id}`);
}
+ // Seed created task keys to prevent false notifications
+ notifiedCreatedTaskKeys.add(`${task.teamName}:${task.id}`);
+ }
+ // Seed all-completed teams
+ const teamTasksMap = new Map();
+ for (const task of tasks) {
+ const list = teamTasksMap.get(task.teamName) ?? [];
+ list.push(task);
+ teamTasksMap.set(task.teamName, list);
+ }
+ for (const [teamName, teamTasks] of teamTasksMap) {
+ if (teamTasks.every((t) => t.status === 'completed' || t.status === 'deleted')) {
+ notifiedAllCompletedTeams.add(teamName);
+ }
}
}
diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts
index 3247f51f..94d2cecc 100644
--- a/src/renderer/utils/chipUtils.ts
+++ b/src/renderer/utils/chipUtils.ts
@@ -3,13 +3,12 @@
*/
import { chipToken } from '@renderer/types/inlineChip';
-import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
-
-import type { MentionSuggestion } from '@renderer/types/mention';
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
+import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
import { getBasename } from '@shared/utils/platformPath';
import type { InlineChip } from '@renderer/types/inlineChip';
+import type { MentionSuggestion } from '@renderer/types/mention';
import type { EditorSelectionAction } from '@shared/types/editor';
// =============================================================================
diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts
index 6976e635..2a8a398c 100644
--- a/src/renderer/utils/taskChangeRequest.ts
+++ b/src/renderer/utils/taskChangeRequest.ts
@@ -1,11 +1,12 @@
-import type { ReviewAPI } from '@shared/types/api';
-import type { TeamTaskWithKanban } from '@shared/types/team';
import {
getTaskChangeStateBucket,
isTaskChangeSummaryCacheable,
type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState';
+import type { ReviewAPI } from '@shared/types/api';
+import type { TeamTaskWithKanban } from '@shared/types/team';
+
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
export type TaskChangeRequestOptions = NonNullable[2]>;
@@ -130,3 +131,12 @@ export function isTaskSummaryCacheableForOptions(
): boolean {
return isTaskChangeSummaryCacheable(getTaskChangeStateBucketFromOptions(options));
}
+
+export function canDisplayTaskChangesForOptions(
+ options: TaskChangeRequestOptions | null | undefined
+): boolean {
+ const bucket = getTaskChangeStateBucketFromOptions(options);
+ if (bucket !== 'active') return true;
+ // 'active' bucket includes both pending and in_progress — show for in_progress only
+ return options?.status === 'in_progress';
+}
diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts
index 4cd4c1b3..f4a949ce 100644
--- a/src/shared/constants/crossTeam.ts
+++ b/src/shared/constants/crossTeam.ts
@@ -93,7 +93,7 @@ export const CROSS_TEAM_PREFIX_RE = new RegExp(
/** Parse metadata from a cross-team prefix line. */
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
- const match = text.match(CROSS_TEAM_PREFIX_RE);
+ const match = CROSS_TEAM_PREFIX_RE.exec(text);
if (!match?.groups) return null;
const attrs = parseCrossTeamAttributes(match.groups.attrs ?? '');
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index dbc0e18a..9b5bb57f 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -10,20 +10,13 @@
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { CliInstallerAPI } from './cliInstaller';
import type { EditorAPI, EditorFileChangeEvent, ProjectAPI } from './editor';
-import type { McpCatalogAPI, PluginCatalogAPI, ApiKeysAPI, SkillsCatalogAPI } from './extensions';
+import type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './extensions';
import type {
AppConfig,
DetectedError,
NotificationTrigger,
TriggerTestResult,
} from './notifications';
-import type {
- CreateScheduleInput,
- Schedule,
- ScheduleChangeEvent,
- ScheduleRun,
- UpdateSchedulePatch,
-} from './schedule';
import type {
AgentChangeSet,
ApplyReviewRequest,
@@ -36,6 +29,13 @@ import type {
SnippetDiff,
TaskChangeSetV2,
} from './review';
+import type {
+ CreateScheduleInput,
+ Schedule,
+ ScheduleChangeEvent,
+ ScheduleRun,
+ UpdateSchedulePatch,
+} from './schedule';
import type {
AddMemberRequest,
AddTaskCommentRequest,
diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts
index 61e7e9c1..f6d7927a 100644
--- a/src/shared/types/extensions/api.ts
+++ b/src/shared/types/extensions/api.ts
@@ -10,15 +10,15 @@ import type {
ApiKeyStorageStatus,
} from './apikey';
import type { InstallScope, OperationResult } from './common';
-import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpCustomInstallRequest,
McpInstallRequest,
- McpServerDiagnostic,
McpSearchResult,
+ McpServerDiagnostic,
} from './mcp';
+import type { EnrichedPlugin, PluginInstallRequest } from './plugin';
import type {
SkillCatalogItem,
SkillDeleteRequest,
diff --git a/src/shared/types/extensions/index.ts b/src/shared/types/extensions/index.ts
index 20831b8d..424722a6 100644
--- a/src/shared/types/extensions/index.ts
+++ b/src/shared/types/extensions/index.ts
@@ -2,8 +2,31 @@
* Extension Store types — barrel export.
*/
+export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './api';
+export type {
+ ApiKeyEntry,
+ ApiKeyLookupResult,
+ ApiKeySaveRequest,
+ ApiKeyStorageStatus,
+} from './apikey';
export type { ExtensionOperationState, InstallScope, OperationResult } from './common';
-
+export type {
+ InstalledMcpEntry,
+ McpAuthHeaderDef,
+ McpCatalogItem,
+ McpCustomInstallRequest,
+ McpEnvVarDef,
+ McpHeaderDef,
+ McpHostingType,
+ McpHttpInstallSpec,
+ McpInstallRequest,
+ McpInstallSpec,
+ McpSearchResult,
+ McpServerDiagnostic,
+ McpServerHealthStatus,
+ McpStdioInstallSpec,
+ McpToolDef,
+} from './mcp';
export type {
EnrichedPlugin,
InstalledPluginEntry,
@@ -14,57 +37,29 @@ export type {
PluginSortField,
} from './plugin';
export { inferCapabilities } from './plugin';
-
-export type {
- InstalledMcpEntry,
- McpAuthHeaderDef,
- McpCatalogItem,
- McpCustomInstallRequest,
- McpServerDiagnostic,
- McpServerHealthStatus,
- McpEnvVarDef,
- McpHeaderDef,
- McpHostingType,
- McpHttpInstallSpec,
- McpInstallRequest,
- McpInstallSpec,
- McpSearchResult,
- McpStdioInstallSpec,
- McpToolDef,
-} from './mcp';
-
export type {
CreateSkillRequest,
DeleteSkillRequest,
SkillCatalogItem,
SkillDeleteRequest,
+ SkillDetail,
+ SkillDirectoryFlags,
SkillDraft,
SkillDraftFile,
SkillDraftTemplateInput,
- SkillDetail,
- SkillDirectoryFlags,
SkillImportRequest,
SkillInvocationMode,
SkillIssueSeverity,
- SkillRootKind,
SkillReviewAction,
SkillReviewFileChange,
SkillReviewPreview,
SkillReviewSummary,
+ SkillRootKind,
SkillSaveResult,
SkillScope,
SkillSourceType,
- UpdateSkillRequest,
SkillUpsertRequest,
SkillValidationIssue,
SkillWatcherEvent,
+ UpdateSkillRequest,
} from './skill';
-
-export type {
- ApiKeyEntry,
- ApiKeyLookupResult,
- ApiKeySaveRequest,
- ApiKeyStorageStatus,
-} from './apikey';
-
-export type { ApiKeysAPI, McpCatalogAPI, PluginCatalogAPI, SkillsCatalogAPI } from './api';
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index ab86408f..f5cc5e57 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -60,6 +60,9 @@ export interface DetectedError {
| 'task_clarification'
| 'task_status_change'
| 'task_comment'
+ | 'task_created'
+ | 'all_tasks_completed'
+ | 'cross_team_message'
| 'schedule_completed'
| 'schedule_failed';
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
@@ -271,6 +274,12 @@ export interface AppConfig {
notifyOnStatusChange: boolean;
/** Whether to show native OS notifications when a new comment is added to a task */
notifyOnTaskComments: boolean;
+ /** Whether to show native OS notifications when a new task is created */
+ notifyOnTaskCreated: boolean;
+ /** Whether to show native OS notifications when all tasks in a team are completed */
+ notifyOnAllTasksCompleted: boolean;
+ /** Whether to show native OS notifications for cross-team messages */
+ notifyOnCrossTeamMessage: boolean;
/** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 45b0f12b..c13d9d24 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -108,12 +108,19 @@ export interface TaskReviewApprovedEvent extends TaskHistoryEventBase {
note?: string;
}
+export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
+ type: 'review_started';
+ from: TeamReviewState;
+ to: 'review';
+}
+
export type TaskHistoryEvent =
| TaskCreatedEvent
| TaskStatusChangedEvent
| TaskReviewRequestedEvent
| TaskReviewChangesRequestedEvent
- | TaskReviewApprovedEvent;
+ | TaskReviewApprovedEvent
+ | TaskReviewStartedEvent;
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
@@ -134,6 +141,23 @@ export interface TaskComment {
attachments?: TaskAttachmentMeta[];
}
+/**
+ * Snapshot of a user message captured at task-creation time.
+ * Stored as provenance — the original message identity is `sourceMessageId`.
+ */
+export interface SourceMessageSnapshot {
+ /** Sanitized message text (agent-only blocks stripped). */
+ text: string;
+ /** Who sent the message. */
+ from: string;
+ /** ISO timestamp of the original message. */
+ timestamp: string;
+ /** Message source type (e.g. "user_sent", "inbox"). */
+ source?: string;
+ /** Attachment metadata references (IDs only, no blobs). */
+ attachments?: { id: string; filename: string; mimeType: string; size: number }[];
+}
+
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`.
// Adding a field here without mapping it there will cause a compile error.
export interface TeamTask {
@@ -179,6 +203,10 @@ export interface TeamTask {
attachments?: TaskAttachmentMeta[];
/** Derived review state — computed from historyEvents, not persisted as authority. */
reviewState?: TeamReviewState;
+ /** Exact messageId of the user message this task was created from. */
+ sourceMessageId?: string;
+ /** Snapshot of the source message at creation time (sanitized, no blobs). */
+ sourceMessage?: SourceMessageSnapshot;
}
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
@@ -618,6 +646,10 @@ export interface MemberLogSummaryBase {
filePath?: string;
/** Short preview of the last assistant output (truncated). */
lastOutputPreview?: string;
+ /** Short preview of the last thinking block (truncated). */
+ lastThinkingPreview?: string;
+ /** Recent thinking/output previews with timestamps for task-scoped filtering. */
+ recentPreviews?: { text: string; timestamp: string; kind: 'thinking' | 'output' }[];
}
export interface MemberSubagentLogSummary extends MemberLogSummaryBase {
@@ -688,7 +720,12 @@ export interface TeamMessageNotificationData {
/** Optional sender color for visual context. */
color?: string;
/** Team event sub-type for notification categorization. */
- teamEventType?: 'task_clarification' | 'task_status_change' | 'task_comment';
+ teamEventType?:
+ | 'task_clarification'
+ | 'task_status_change'
+ | 'task_comment'
+ | 'task_created'
+ | 'all_tasks_completed';
/** Stable key for storage deduplication. Required — no fallback to Date.now(). */
dedupeKey?: string;
/**
diff --git a/src/shared/utils/taskChangeState.ts b/src/shared/utils/taskChangeState.ts
index 5e196e2e..cb55e0c5 100644
--- a/src/shared/utils/taskChangeState.ts
+++ b/src/shared/utils/taskChangeState.ts
@@ -1,7 +1,7 @@
-import type { TaskHistoryEvent, TeamReviewState } from '@shared/types';
-
import { getDerivedReviewState } from './taskHistory';
+import type { TaskHistoryEvent, TeamReviewState } from '@shared/types';
+
export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
interface TaskChangeStateLike {
@@ -46,3 +46,21 @@ export function isTaskChangeSummaryCacheable(
typeof taskOrBucket === 'string' ? taskOrBucket : getTaskChangeStateBucket(taskOrBucket);
return bucket === 'completed' || bucket === 'approved';
}
+
+/**
+ * Whether a task can display its file changes in the UI.
+ * Unlike `isTaskChangeSummaryCacheable` (permanent-cache gate for terminal states),
+ * this returns true for any task that could plausibly have changes:
+ * in_progress, review, approved, completed — everything except pending/backlog.
+ */
+export function canDisplayTaskChanges(
+ taskOrBucket: TaskChangeStateLike | TaskChangeStateBucket
+): boolean {
+ if (typeof taskOrBucket === 'string') {
+ return taskOrBucket !== 'active';
+ }
+ const bucket = getTaskChangeStateBucket(taskOrBucket);
+ if (bucket !== 'active') return true;
+ // 'active' bucket includes both pending and in_progress — show for in_progress only
+ return taskOrBucket.status === 'in_progress';
+}
diff --git a/src/shared/utils/taskHistory.ts b/src/shared/utils/taskHistory.ts
index a4bc4fa7..18e71ace 100644
--- a/src/shared/utils/taskHistory.ts
+++ b/src/shared/utils/taskHistory.ts
@@ -26,7 +26,8 @@ export function getDerivedReviewState(task: Pick): Te
if (
event.type === 'review_requested' ||
event.type === 'review_changes_requested' ||
- event.type === 'review_approved'
+ event.type === 'review_approved' ||
+ event.type === 'review_started'
) {
return event.to;
}
diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts
index 04043d6f..c5b8a8d9 100644
--- a/src/types/agent-teams-controller.d.ts
+++ b/src/types/agent-teams-controller.d.ts
@@ -46,6 +46,7 @@ declare module 'agent-teams-controller' {
export interface ControllerMessageApi {
appendSentMessage(flags: Record): unknown;
+ lookupMessage(messageId: string): { message: Record; store: string };
sendMessage(flags: Record): unknown;
}
@@ -66,6 +67,15 @@ declare module 'agent-teams-controller' {
getCrossTeamOutbox(): unknown;
}
+ export interface AgentBlocksApi {
+ AGENT_BLOCK_TAG: string;
+ AGENT_BLOCK_OPEN: string;
+ AGENT_BLOCK_CLOSE: string;
+ AGENT_BLOCK_RE: RegExp;
+ stripAgentBlocks(text: string): string;
+ wrapAgentBlock(text: string): string;
+ }
+
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
@@ -77,4 +87,6 @@ declare module 'agent-teams-controller' {
}
export function createController(options: ControllerContextOptions): AgentTeamsController;
+
+ export const agentBlocks: AgentBlocksApi;
}
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index 4a4ee3d8..78905fc0 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -1,5 +1,5 @@
import * as os from 'os';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team';
vi.mock('electron', () => ({
@@ -82,7 +82,6 @@ import {
registerTeamHandlers,
removeTeamHandlers,
} from '../../../src/main/ipc/teams';
-import { MEMBER_BRIEFING_BOOTSTRAP_ENV } from '../../../src/main/services/team/TeamProvisioningService';
describe('ipc teams handlers', () => {
const handlers = new Map Promise>();
@@ -94,7 +93,6 @@ describe('ipc teams handlers', () => {
handlers.delete(channel);
}),
};
- let originalMemberBriefingBootstrapEnv: string | undefined;
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
@@ -170,20 +168,10 @@ describe('ipc teams handlers', () => {
beforeEach(() => {
handlers.clear();
vi.clearAllMocks();
- originalMemberBriefingBootstrapEnv = process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '1';
initializeTeamHandlers(service as never, provisioningService as never);
registerTeamHandlers(ipcMain as never);
});
- afterEach(() => {
- if (originalMemberBriefingBootstrapEnv === undefined) {
- delete process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
- } else {
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = originalMemberBriefingBootstrapEnv;
- }
- });
-
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
@@ -268,7 +256,8 @@ describe('ipc teams handlers', () => {
'Can you review the approach?',
undefined,
undefined,
- undefined
+ undefined,
+ expect.any(String)
);
});
@@ -535,25 +524,6 @@ describe('ipc teams handlers', () => {
);
});
- it('falls back to the legacy add-member spawn instruction when bootstrap flag is disabled', async () => {
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '0';
- const handler = handlers.get(TEAM_ADD_MEMBER)!;
- const result = (await handler({} as never, 'my-team', {
- name: 'alice',
- role: 'developer',
- })) as { success: boolean };
-
- expect(result.success).toBe(true);
- expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
- 'my-team',
- expect.stringContaining('Please spawn them immediately using the Task tool with team_name="my-team" and name="alice".')
- );
- expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalledWith(
- 'my-team',
- expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
- );
- });
-
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, '../bad', {
diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts
index 9dd3cda1..b18e40b9 100644
--- a/test/main/services/team/TeamMcpConfigBuilder.test.ts
+++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts
@@ -52,10 +52,10 @@ describe('TeamMcpConfigBuilder', () => {
expect(server?.command).toBe('pnpm');
expect(server?.args).toEqual([
'--dir',
- `${process.cwd()}/mcp-server`,
+ path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
- `${process.cwd()}/mcp-server/src/index.ts`,
+ path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
]);
});
@@ -178,10 +178,10 @@ describe('TeamMcpConfigBuilder', () => {
command: 'pnpm',
args: [
'--dir',
- `${process.cwd()}/mcp-server`,
+ path.join(process.cwd(), 'mcp-server'),
'exec',
'tsx',
- `${process.cwd()}/mcp-server/src/index.ts`,
+ path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
],
});
});
diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
index 2ce587fc..1ace727c 100644
--- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts
+++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts
@@ -10,7 +10,6 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBloc
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
-let originalMemberBriefingBootstrapEnv: string | undefined;
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
@@ -33,7 +32,6 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
});
import {
- MEMBER_BRIEFING_BOOTSTRAP_ENV,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
@@ -71,8 +69,6 @@ function extractPromptFromWrite(writeSpy: ReturnType): string {
describe('TeamProvisioningService prompt content (solo mode discipline)', () => {
beforeEach(() => {
vi.clearAllMocks();
- originalMemberBriefingBootstrapEnv = process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '1';
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@@ -81,11 +77,6 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
});
afterEach(() => {
- if (originalMemberBriefingBootstrapEnv === undefined) {
- delete process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
- } else {
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = originalMemberBriefingBootstrapEnv;
- }
// Best-effort cleanup of temp dir (per-test)
try {
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
@@ -128,6 +119,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
);
expect(prompt).toContain('task_start');
expect(prompt).toContain('task_complete');
+ expect(prompt).toContain('task_create_from_message');
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('ASK: Strict read-only conversation mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
@@ -368,39 +360,4 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
-
- it('createTeam prompt falls back to legacy inline protocol when bootstrap flag is disabled', async () => {
- process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '0';
- vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
- const { child, writeSpy } = createFakeChild();
- vi.mocked(spawnCli).mockReturnValue(child as any);
-
- const svc = new TeamProvisioningService();
- (svc as any).buildProvisioningEnv = vi.fn(async () => ({
- env: { ANTHROPIC_API_KEY: 'test' },
- authSource: 'anthropic_api_key',
- }));
- (svc as any).startFilesystemMonitor = vi.fn();
- (svc as any).pathExists = vi.fn(async () => false);
-
- const { runId } = await svc.createTeam(
- {
- teamName: 'legacy-team',
- cwd: process.cwd(),
- members: [{ name: 'alice', role: 'developer' }],
- description: 'Legacy prompt fallback test',
- },
- () => {}
- );
-
- const prompt = extractPromptFromWrite(writeSpy);
- expect(prompt).toContain('Include the following agent-only instructions verbatim in the prompt:');
- expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
- expect(prompt).toContain(
- 'If a newly assigned task must wait because you are still busy on another task, immediately add a short task comment on that waiting task with the reason and your best ETA.'
- );
- expect(prompt).not.toContain('Your FIRST action: call MCP tool member_briefing');
-
- await svc.cancelProvisioning(runId);
- });
});
diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
index 8d8acbe9..355c4e89 100644
--- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
@@ -274,7 +274,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Source: system_notification');
expect(payload).toContain('summary looks like \\"Comment on #...\\"');
- expect(payload).toContain('Prefer replying on the task via task_add_comment');
+ expect(payload).toContain('REQUIRES an on-task reply via task_add_comment');
(service as any).handleStreamJsonMessage(run, {
type: 'assistant',
@@ -773,4 +773,90 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
+
+ it('includes MessageId in lead inbox relay prompt for provenance', async () => {
+ const service = new TeamProvisioningService();
+ const teamName = 'my-team';
+ seedConfig(teamName);
+ seedLeadInbox(teamName, [
+ {
+ from: 'user',
+ text: 'Build the authentication module',
+ timestamp: '2026-02-23T14:00:00.000Z',
+ read: false,
+ summary: 'Auth module request',
+ messageId: 'msg-provenance-001',
+ source: 'user_sent',
+ },
+ ]);
+
+ const { writeSpy } = attachAliveRun(service, teamName);
+ const relayPromise = service.relayLeadInboxMessages(teamName);
+ const run = await waitForCapture(service);
+ (service as any).handleStreamJsonMessage(run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Creating task.' }],
+ });
+ (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
+ await relayPromise;
+
+ const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
+ expect(payload).toContain('MessageId: msg-provenance-001');
+ expect(payload).toContain('Build the authentication module');
+ });
+
+ it('includes MessageId in member inbox relay prompt for provenance', async () => {
+ const service = new TeamProvisioningService();
+ const teamName = 'my-team';
+ seedConfig(teamName);
+ seedMemberInbox(teamName, 'alice', [
+ {
+ from: 'bob',
+ text: 'Please review my changes',
+ timestamp: '2026-02-23T15:00:00.000Z',
+ read: false,
+ summary: 'Review request',
+ messageId: 'msg-member-relay-001',
+ },
+ ]);
+
+ const { writeSpy } = attachAliveRun(service, teamName);
+ await service.relayMemberInboxMessages(teamName, 'alice');
+
+ expect(writeSpy).toHaveBeenCalledTimes(1);
+ const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
+ expect(payload).toContain('MessageId: msg-member-relay-001');
+ expect(payload).toContain('Please review my changes');
+ });
+
+ it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => {
+ const service = new TeamProvisioningService();
+ const teamName = 'my-team';
+ seedConfig(teamName);
+ seedLeadInbox(teamName, [
+ {
+ from: 'user',
+ text: 'Implement dark mode',
+ timestamp: '2026-02-23T16:00:00.000Z',
+ read: false,
+ summary: 'Dark mode',
+ messageId: 'msg-task-pref-001',
+ source: 'user_sent',
+ },
+ ]);
+
+ const { writeSpy } = attachAliveRun(service, teamName);
+ const relayPromise = service.relayLeadInboxMessages(teamName);
+ const run = await waitForCapture(service);
+ (service as any).handleStreamJsonMessage(run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Got it.' }],
+ });
+ (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
+ await relayPromise;
+
+ const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
+ expect(payload).toContain('task_create_from_message');
+ expect(payload).toContain('MessageId');
+ });
});
diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts
index 865c07bd..2538af70 100644
--- a/test/main/utils/childProcess.test.ts
+++ b/test/main/utils/childProcess.test.ts
@@ -44,7 +44,11 @@ describe('cli child process helpers', () => {
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' });
- expect(child.spawn).toHaveBeenCalledWith('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' });
+ expect(child.spawn).toHaveBeenCalledWith(
+ 'C:\\bin\\claude.exe',
+ ['--version'],
+ expect.objectContaining({ cwd: 'x', env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }) })
+ );
expect(result).toEqual({} as any);
});
@@ -91,7 +95,11 @@ describe('cli child process helpers', () => {
setPlatform('linux');
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('/usr/bin/claude', ['--help']);
- expect(child.spawn).toHaveBeenCalledWith('/usr/bin/claude', ['--help'], {});
+ expect(child.spawn).toHaveBeenCalledWith(
+ '/usr/bin/claude',
+ ['--help'],
+ expect.objectContaining({ env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }) })
+ );
expect(result).toEqual({} as any);
});
});
@@ -110,7 +118,7 @@ describe('cli child process helpers', () => {
expect(execFileMock).toHaveBeenCalledWith(
'C:\\bin\\claude.exe',
['--version'],
- {},
+ expect.objectContaining({ env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }) }),
expect.any(Function)
);
expect(result.stdout).toBe('ok');
diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts
index 1636af2a..7765f604 100644
--- a/test/main/utils/teamNotificationBuilder.test.ts
+++ b/test/main/utils/teamNotificationBuilder.test.ts
@@ -93,6 +93,9 @@ describe('buildDetectedErrorFromTeam', () => {
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
+ task_created: { triggerName: 'Task Created', triggerColor: 'green' },
+ all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' },
+ cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
};
diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts
index 9920636e..8140a67e 100644
--- a/test/renderer/components/team/activity/ActivityItem.test.ts
+++ b/test/renderer/components/team/activity/ActivityItem.test.ts
@@ -10,7 +10,7 @@ import {
describe('ActivityItem legacy system message fallback', () => {
it('recognizes historical assignment and review message wording', () => {
expect(getSystemMessageLabel('New task assigned to you: #abcd1234 "Implement feature".')).toBe(
- 'Task assignment'
+ 'Task'
);
expect(getSystemMessageLabel('Task #abcd1234 approved by reviewer.')).toBe('Task approved');
expect(getSystemMessageLabel('Task #abcd1234 needs fixes before approval.')).toBe(
diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts
index 69c320b9..234cd038 100644
--- a/test/renderer/store/changeReviewSlice.test.ts
+++ b/test/renderer/store/changeReviewSlice.test.ts
@@ -290,7 +290,10 @@ describe('changeReviewSlice task changes', () => {
});
await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS);
+ // Expire the 30s negative-cache TTL so the second call actually hits the API
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31_000);
await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS);
+ vi.mocked(Date.now).mockRestore();
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
diff --git a/test/shared/utils/reviewState.test.ts b/test/shared/utils/reviewState.test.ts
index 783a4637..4261f977 100644
--- a/test/shared/utils/reviewState.test.ts
+++ b/test/shared/utils/reviewState.test.ts
@@ -17,4 +17,21 @@ describe('reviewState utils', () => {
it('does not map needsFix to a kanban column', () => {
expect(getKanbanColumnFromReviewState('needsFix')).toBeUndefined();
});
+
+ it('derives review state from review_started history event', () => {
+ expect(
+ getReviewStateFromTask({
+ historyEvents: [
+ {
+ id: '1',
+ timestamp: '2026-01-01T00:00:00Z',
+ type: 'review_started',
+ from: 'none',
+ to: 'review',
+ actor: 'alice',
+ },
+ ],
+ })
+ ).toBe('review');
+ });
});