feat: enhance review approval process and notification handling

- Added a `suppressTaskComment` flag to the `approveReview` function, allowing users to approve reviews without adding a comment to the task.
- Updated the `notifyNewInboxMessages` function to include additional debug logging for better tracking of inbox notifications.
- Adjusted notification settings in `ConfigManager` to enable notifications for lead inbox and change the status change behavior.
- Enhanced the `NotificationManager` to improve error handling and logging during notification display.
- Refactored `TeamDataService` to utilize the new `suppressTaskComment` feature during review approvals.
- Updated tests to validate the new approval process and notification behaviors.
This commit is contained in:
iliya 2026-03-15 19:27:59 +02:00
parent 0c0088c871
commit 36e93abd42
11 changed files with 172 additions and 83 deletions

View file

@ -109,6 +109,7 @@ function approveReview(context, taskId, flags = {}) {
const from = const from =
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead'; typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved'; const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
const suppressTaskComment = flags.suppressTaskComment === true;
const leadSessionId = resolveLeadSessionId(context, flags); const leadSessionId = resolveLeadSessionId(context, flags);
const prevReviewState = getCurrentReviewState(task); const prevReviewState = getCurrentReviewState(task);
@ -127,12 +128,14 @@ function approveReview(context, taskId, flags = {}) {
return t; return t;
}); });
tasks.addTaskComment(context, task.id, { if (!suppressTaskComment) {
text: note, tasks.addTaskComment(context, task.id, {
from, text: note,
type: 'review_approved', from,
notifyOwner: false, type: 'review_approved',
}); notifyOwner: false,
});
}
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) { if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
messages.sendMessage(context, { messages.sendMessage(context, {

View file

@ -183,6 +183,7 @@ function extractNotificationContent(text: string): { summary: string; body: stri
} }
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> { async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
logger.debug(`[inbox-notify] called: team=${teamName} detail=${detail}`);
const config = configManager.getConfig(); const config = configManager.getConfig();
// Skip orphaned team directories without config.json (e.g., "default"). // Skip orphaned team directories without config.json (e.g., "default").
@ -191,6 +192,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
// correct team name via sentMessages.json, so inbox notifications from orphaned dirs // correct team name via sentMessages.json, so inbox notifications from orphaned dirs
// would be duplicates with a wrong team name. // would be duplicates with a wrong team name.
if (!existsSync(join(getTeamsBasePath(), teamName, 'config.json'))) { if (!existsSync(join(getTeamsBasePath(), teamName, 'config.json'))) {
logger.debug(`[inbox-notify] skipped: no config.json for team=${teamName}`);
return; // No config.json → orphaned team dir, skip notification return; // No config.json → orphaned team dir, skip notification
} }
@ -221,6 +223,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
if (isFirstLoad) { if (isFirstLoad) {
// First load — seed count, don't notify for pre-existing messages // First load — seed count, don't notify for pre-existing messages
logger.debug(`[inbox-notify] first load for ${key}: seeding count=${messages.length}`);
inboxMessageCounts.set(key, messages.length); inboxMessageCounts.set(key, messages.length);
return; return;
} }
@ -234,6 +237,10 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
const newMessages = messages.slice(0, messages.length - prevCount); const newMessages = messages.slice(0, messages.length - prevCount);
inboxMessageCounts.set(key, messages.length); inboxMessageCounts.set(key, messages.length);
logger.debug(
`[inbox-notify] ${key}: prevCount=${prevCount} newCount=${messages.length} newMessages=${newMessages.length} suppressToast=${String(suppressToast)}`
);
const teamDisplayName = await resolveTeamDisplayName(teamName); const teamDisplayName = await resolveTeamDisplayName(teamName);
for (let i = 0; i < newMessages.length; i++) { for (let i = 0; i < newMessages.length; i++) {
@ -729,9 +736,11 @@ function initializeServices(): void {
// Fire-and-forget: initializeServices() is sync, cannot await. // Fire-and-forget: initializeServices() is sync, cannot await.
// Safe because TeamBackupService.initialized flag blocks all backup/restore // Safe because TeamBackupService.initialized flag blocks all backup/restore
// operations until initialize() completes internally (restore → prune → set flag). // operations until initialize() completes internally (restore → prune → set flag).
void teamBackupService.initialize().catch((error: unknown) => void teamBackupService
logger.warn(`[Init] TeamBackupService init failed: ${String(error)}`) .initialize()
); .catch((error: unknown) =>
logger.warn(`[Init] TeamBackupService init failed: ${String(error)}`)
);
// Cross-team communication service // Cross-team communication service
const crossTeamConfigReader = new TeamConfigReader(); const crossTeamConfigReader = new TeamConfigReader();

View file

@ -259,12 +259,12 @@ const DEFAULT_CONFIG: AppConfig = {
snoozedUntil: null, snoozedUntil: null,
snoozeMinutes: 30, snoozeMinutes: 30,
includeSubagentErrors: false, includeSubagentErrors: false,
notifyOnLeadInbox: false, notifyOnLeadInbox: true,
notifyOnUserInbox: true, notifyOnUserInbox: true,
notifyOnClarifications: true, notifyOnClarifications: true,
notifyOnStatusChange: true, notifyOnStatusChange: true,
notifyOnTaskComments: true, notifyOnTaskComments: true,
statusChangeOnlySolo: true, statusChangeOnlySolo: false,
statusChangeStatuses: ['in_progress', 'completed'], statusChangeStatuses: ['in_progress', 'completed'],
triggers: DEFAULT_TRIGGERS, triggers: DEFAULT_TRIGGERS,
}, },

View file

@ -421,41 +421,56 @@ export class NotificationManager extends EventEmitter {
stored: StoredNotification, stored: StoredNotification,
payload: TeamNotificationPayload payload: TeamNotificationPayload
): void { ): void {
if (!this.isNativeNotificationSupported()) return; if (!this.isNativeNotificationSupported()) {
logger.warn('[team-toast] native notifications not supported — skipping');
return;
}
const config = this.configManager.getConfig(); try {
const isMac = process.platform === 'darwin'; const config = this.configManager.getConfig();
const truncatedBody = stripMarkdown(payload.body).slice(0, 300); const isMac = process.platform === 'darwin';
const iconPath = isMac ? undefined : getAppIconPath(); const truncatedBody = stripMarkdown(payload.body).slice(0, 300);
const notification = new Notification({ const iconPath = isMac ? undefined : getAppIconPath();
title: payload.teamDisplayName,
...(isMac ? { subtitle: payload.summary } : {}),
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});
// Hold a strong reference to prevent GC from collecting the notification logger.debug(
this.activeNotifications.add(notification); `[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}`
const cleanup = (): void => { );
this.activeNotifications.delete(notification);
};
notification.on('click', () => { const notification = new Notification({
this.handleNativeNotificationClick(stored); title: payload.teamDisplayName,
cleanup(); ...(isMac ? { subtitle: payload.summary } : {}),
}); body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
notification.on('close', cleanup); sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}),
});
notification.on('show', () => { // Hold a strong reference to prevent GC from collecting the notification
logger.debug(`[notification] shown: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`); this.activeNotifications.add(notification);
}); const cleanup = (): void => {
notification.on('failed', (_, error) => { this.activeNotifications.delete(notification);
logger.warn(`[notification] failed: ${error}`); };
cleanup();
});
notification.show(); notification.on('click', () => {
this.handleNativeNotificationClick(stored);
cleanup();
});
notification.on('close', cleanup);
notification.on('show', () => {
logger.debug(
`[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}`
);
});
notification.on('failed', (_, error) => {
logger.warn(`[team-toast] OS failed: ${String(error)}`);
cleanup();
});
notification.show();
logger.debug('[team-toast] notification.show() called');
} catch (error) {
logger.error(`[team-toast] exception in showTeamNativeNotification: ${String(error)}`);
}
} }
/** /**
@ -653,10 +668,21 @@ export class NotificationManager extends EventEmitter {
async addTeamNotification(payload: TeamNotificationPayload): Promise<StoredNotification | null> { async addTeamNotification(payload: TeamNotificationPayload): Promise<StoredNotification | null> {
const error = buildDetectedErrorFromTeam(payload); const error = buildDetectedErrorFromTeam(payload);
const stored = await this.storeNotification(error); const stored = await this.storeNotification(error);
if (!stored) return null; if (!stored) {
logger.debug(
`[team-notification] skipped (dedup): type=${payload.teamEventType} key=${payload.dedupeKey}`
);
return null;
}
// Team-specific toast policy: enabled/snoozed + suppressToast + dedupeKey throttle only // Team-specific toast policy: enabled/snoozed + suppressToast + dedupeKey throttle only
if (!payload.suppressToast && this.areNotificationsEnabled() && !this.isToastThrottled(error)) { const enabled = this.areNotificationsEnabled();
const throttled = this.isToastThrottled(error);
const shouldShow = !payload.suppressToast && enabled && !throttled;
logger.debug(
`[team-notification] toast decision: type=${payload.teamEventType} suppressToast=${String(payload.suppressToast ?? false)} enabled=${String(enabled)} throttled=${String(throttled)} → show=${String(shouldShow)}`
);
if (shouldShow) {
this.showTeamNativeNotification(stored, payload); this.showTeamNativeNotification(stored, payload);
} }

View file

@ -1891,7 +1891,7 @@ export class TeamDataService {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
controller.review.approveReview(taskId, { controller.review.approveReview(taskId, {
from: 'user', from: 'user',
note: 'Approved from kanban', suppressTaskComment: true,
'notify-owner': true, 'notify-owner': true,
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
}); });

View file

@ -224,8 +224,11 @@ export const TaskDetailDialog = ({
lightboxOpenRef.current = isOpen; lightboxOpenRef.current = isOpen;
}, []); }, []);
// Ref for the scrollable DialogContent — needed as IO root for viewport-based read tracking. // Callback-ref + useState for the scrollable DialogContent — needed as IO root
const dialogContentRef = useRef<HTMLDivElement | null>(null); // for viewport-based read tracking. Using useState (not useRef) ensures that
// useViewportObserver recreates the IntersectionObserver when the portal mounts
// and the DOM element becomes available.
const [dialogContentEl, setDialogContentEl] = useState<HTMLDivElement | null>(null);
const handleReply = useCallback( const handleReply = useCallback(
(author: string, text: string) => { (author: string, text: string) => {
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text }); if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
@ -269,7 +272,7 @@ export const TaskDetailDialog = ({
const { registerComment, flush: flushCommentRead } = useViewportCommentRead({ const { registerComment, flush: flushCommentRead } = useViewportCommentRead({
teamName, teamName,
taskId: currentTask?.id ?? '', taskId: currentTask?.id ?? '',
scrollContainerRef: dialogContentRef, scrollContainer: dialogContentEl,
}); });
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@ -525,7 +528,7 @@ export const TaskDetailDialog = ({
}} }}
> >
<DialogContent <DialogContent
ref={dialogContentRef} ref={setDialogContentEl}
className="sm:min-w-[500px] sm:max-w-4xl" className="sm:min-w-[500px] sm:max-w-4xl"
onInteractOutside={(e) => { onInteractOutside={(e) => {
if (lightboxOpenRef.current) e.preventDefault(); if (lightboxOpenRef.current) e.preventDefault();

View file

@ -65,10 +65,6 @@ function filterChunksByWorkIntervals(
return cs <= end && ce >= i.startMs; return cs <= end && ce >= i.startMs;
}); });
}); });
// DEBUG
console.log(
`[filterChunks] intervals=${parsed.length} chunks=${chunks.length}${filtered.length}`
);
return filtered; return filtered;
} }
@ -111,12 +107,6 @@ export const MemberLogsTab = ({
showLeadPreview = false, showLeadPreview = false,
onPreviewOnlineChange, onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => { }: MemberLogsTabProps): React.JSX.Element => {
// DEBUG: verify workIntervals reach this component
if (taskId && taskWorkIntervals) {
console.log(
`[MemberLogsTab] taskId=${taskId} workIntervals=${JSON.stringify(taskWorkIntervals)}`
);
}
const MIN_REFRESH_VISIBLE_MS = 250; const MIN_REFRESH_VISIBLE_MS = 250;
const intervalsKey = useMemo( const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''), () => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
@ -198,21 +188,52 @@ export const MemberLogsTab = ({
if (!Number.isFinite(startMs)) return Number.NaN; if (!Number.isFinite(startMs)) return Number.NaN;
const durationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0; const durationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0;
const endMs = startMs + durationMs; const endMs = startMs + durationMs;
// Keep actively-updating logs at the top even if duration lags slightly.
return log.isOngoing ? Math.max(endMs, nowMs) : endMs; return log.isOngoing ? Math.max(endMs, nowMs) : endMs;
}; };
const withIndex = logs.map((log, index) => ({ log, index })); // When viewing a task with workIntervals, sort by overlap (most relevant first).
// Fallback to endMs (most recent activity) when no intervals available.
const getOverlapMs = (log: MemberLogSummary): number => {
if (!taskWorkIntervals || taskWorkIntervals.length === 0) return 0;
const logStartMs = new Date(log.startTime).getTime();
if (!Number.isFinite(logStartMs)) return 0;
const logDurationMs = Number.isFinite(log.durationMs) ? Math.max(0, log.durationMs) : 0;
const logEndMs = log.isOngoing ? nowMs : logStartMs + logDurationMs;
let totalOverlap = 0;
for (const interval of taskWorkIntervals) {
const intStart = Date.parse(interval.startedAt);
if (!Number.isFinite(intStart)) continue;
const intEnd =
typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : nowMs;
if (!Number.isFinite(intEnd)) continue;
const overlapStart = Math.max(logStartMs, intStart);
const overlapEnd = Math.min(logEndMs, intEnd);
if (overlapEnd > overlapStart) totalOverlap += overlapEnd - overlapStart;
}
return totalOverlap;
};
const withIndex = logs.map((log, index) => ({
log,
index,
overlap: getOverlapMs(log),
lastActivity: getLastActivityMs(log),
}));
withIndex.sort((a, b) => { withIndex.sort((a, b) => {
const aTime = getLastActivityMs(a.log); // Primary: overlap with task workIntervals (more overlap = higher)
const bTime = getLastActivityMs(b.log); if (a.overlap !== b.overlap) return b.overlap - a.overlap;
// Secondary: last activity (most recent first)
const aTime = a.lastActivity;
const bTime = b.lastActivity;
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime; if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1; if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1; if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
return a.index - b.index; return a.index - b.index;
}); });
return withIndex.map((x) => x.log); return withIndex.map((x) => x.log);
}, [logs]); }, [logs, taskWorkIntervals]);
const shouldShowPreview = useMemo(() => { const shouldShowPreview = useMemo(() => {
return taskId != null && (showSubagentPreview || showLeadPreview); return taskId != null && (showSubagentPreview || showLeadPreview);

View file

@ -40,12 +40,26 @@ const AutoResizeTextarea = React.forwardRef<HTMLTextAreaElement, AutoResizeTexta
const maxHeight = const maxHeight =
maxRows * lineHeight + paddingTop + paddingBottom + borderTop + borderBottom; maxRows * lineHeight + paddingTop + paddingBottom + borderTop + borderBottom;
// Save scrollTop before collapsing height — setting height='auto'
// causes the browser to reset scrollTop to 0.
const savedScrollTop = textarea.scrollTop;
textarea.style.height = 'auto'; textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight; const scrollHeight = textarea.scrollHeight;
const clampedHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); const clampedHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
textarea.style.height = `${clampedHeight}px`; textarea.style.height = `${clampedHeight}px`;
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'; textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
// Restore scrollTop after height adjustment, then scroll to caret
// if cursor is at the end (common after paste).
if (scrollHeight > maxHeight) {
if (textarea.selectionStart === textarea.value.length) {
textarea.scrollTop = textarea.scrollHeight;
} else {
textarea.scrollTop = savedScrollTop;
}
}
}, [minRows, maxRows]); }, [minRows, maxRows]);
React.useEffect(() => { React.useEffect(() => {

View file

@ -4,17 +4,17 @@ import { markCommentsRead } from '@renderer/services/commentReadStorage';
import { useViewportObserver } from './useViewportObserver'; import { useViewportObserver } from './useViewportObserver';
import type { RefObject } from 'react';
interface UseViewportCommentReadOptions { interface UseViewportCommentReadOptions {
teamName: string; teamName: string;
taskId: string; taskId: string;
/** /**
* Scrollable ancestor element (e.g. DialogContent) used as IO root. * Scrollable ancestor DOM element (e.g. DialogContent) used as IO root.
* Required for portalled Dialogs where the default viewport root * Pass the actual element (not a RefObject) so that the observer is
* would not detect intersections correctly. * recreated when the portal mounts. Use useState + callback ref:
* const [rootEl, setRootEl] = useState<HTMLDivElement | null>(null);
* <DialogContent ref={setRootEl}>
*/ */
scrollContainerRef: RefObject<HTMLElement | null>; scrollContainer: HTMLElement | null;
} }
/** /**
@ -34,7 +34,7 @@ interface UseViewportCommentReadOptions {
export function useViewportCommentRead({ export function useViewportCommentRead({
teamName, teamName,
taskId, taskId,
scrollContainerRef, scrollContainer,
}: UseViewportCommentReadOptions): { }: UseViewportCommentReadOptions): {
/** Ref callback factory. Call with the comment's unique ID. */ /** Ref callback factory. Call with the comment's unique ID. */
registerComment: (commentId: string) => (el: HTMLElement | null) => void; registerComment: (commentId: string) => (el: HTMLElement | null) => void;
@ -78,7 +78,7 @@ export function useViewportCommentRead({
); );
const { registerElement } = useViewportObserver({ const { registerElement } = useViewportObserver({
rootRef: scrollContainerRef, root: scrollContainer,
threshold: 0.1, threshold: 0.1,
onVisibleChange: handleVisibleChange, onVisibleChange: handleVisibleChange,
}); });

View file

@ -1,17 +1,20 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { RefObject } from 'react';
/** Data attribute name used to store arbitrary string data on observed elements. */ /** Data attribute name used to store arbitrary string data on observed elements. */
const DATA_ATTR = 'data-viewport-value'; const DATA_ATTR = 'data-viewport-value';
interface UseViewportObserverOptions { interface UseViewportObserverOptions {
/** /**
* Scrollable ancestor element used as IntersectionObserver root. * Scrollable ancestor DOM element used as IntersectionObserver root.
* Required for elements inside Dialog portals where the default * Pass the actual element (not a RefObject) so that the hook can
* document viewport root would not detect intersections correctly. * react to the element becoming available (e.g. after a Dialog portal mounts).
*
* Use a callback-ref + useState pattern in the consumer:
* const [rootEl, setRootEl] = useState<HTMLElement | null>(null);
* <DialogContent ref={setRootEl}>
* useViewportObserver({ root: rootEl, ... })
*/ */
rootRef?: RefObject<HTMLElement | null>; root?: HTMLElement | null;
/** Visibility ratio threshold (0..1). Default: 0.1 (10% visible). */ /** Visibility ratio threshold (0..1). Default: 0.1 (10% visible). */
threshold?: number; threshold?: number;
/** /**
@ -26,17 +29,21 @@ interface UseViewportObserverOptions {
* scrollable container using IntersectionObserver. * scrollable container using IntersectionObserver.
* *
* Usage: * Usage:
* 1. Call the hook with a root ref and a callback. * 1. Call the hook with a root element and a callback.
* 2. Attach `registerElement(value)` as a ref callback on each element. * 2. Attach `registerElement(value)` as a ref callback on each element.
* `value` is an arbitrary string stored in a data attribute for identification. * `value` is an arbitrary string stored in a data attribute for identification.
* 3. The callback fires with the list of currently visible values whenever * 3. The callback fires with the list of currently visible values whenever
* the intersection state changes. * the intersection state changes.
* *
* Important: pass the root as a plain DOM element (not a RefObject) so the
* hook can recreate the observer when the element becomes available.
* Use `useState` + callback ref in the consumer for this.
*
* The hook manages a single IntersectionObserver instance and handles * The hook manages a single IntersectionObserver instance and handles
* element registration/deregistration automatically. * element registration/deregistration automatically.
*/ */
export function useViewportObserver({ export function useViewportObserver({
rootRef, root,
threshold = 0.1, threshold = 0.1,
onVisibleChange, onVisibleChange,
}: UseViewportObserverOptions): { }: UseViewportObserverOptions): {
@ -50,9 +57,15 @@ export function useViewportObserver({
const visibleValuesRef = useRef<Set<string>>(new Set()); const visibleValuesRef = useRef<Set<string>>(new Set());
const elementsByValue = useRef<Map<string, HTMLElement>>(new Map()); const elementsByValue = useRef<Map<string, HTMLElement>>(new Map());
// Create / recreate observer when root or threshold changes. // Create / recreate observer when root element or threshold changes.
// root is a plain DOM element (not a RefObject), so when the consumer
// updates state (e.g. Dialog portal mounts), this effect re-runs and
// creates an IO with the correct root.
useEffect(() => { useEffect(() => {
const root = rootRef?.current ?? null; // When root is not yet available (e.g. portal not mounted), skip
// creating the observer — it would default to document viewport
// and produce false positives for all visible elements.
if (!root) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
@ -94,7 +107,7 @@ export function useViewportObserver({
observerRef.current = null; observerRef.current = null;
visibleValuesRef.current.clear(); visibleValuesRef.current.clear();
}; };
}, [rootRef, threshold]); }, [root, threshold]);
const registerElement = useCallback((value: string) => { const registerElement = useCallback((value: string) => {
return (el: HTMLElement | null) => { return (el: HTMLElement | null) => {

View file

@ -566,7 +566,7 @@ describe('TeamDataService', () => {
}); });
expect(approveReviewMock).toHaveBeenCalledWith('task-1', { expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
from: 'user', from: 'user',
note: 'Approved from kanban', suppressTaskComment: true,
'notify-owner': true, 'notify-owner': true,
leadSessionId: 'lead-2', leadSessionId: 'lead-2',
}); });