refactor: reorganize task completion notifications in settings

- Moved the Task Completion Notifications section within the NotificationsSection component for improved structure and clarity.
- Updated the UI elements related to task completion notifications, ensuring consistent styling and functionality.
- Enhanced the overall user experience by maintaining clear access to notification settings.
This commit is contained in:
iliya 2026-03-15 14:40:48 +02:00
parent f5efa17b1a
commit edddf526db
6 changed files with 318 additions and 64 deletions

View file

@ -147,37 +147,6 @@ export const NotificationsSection = ({
</div>
) : null}
{/* Task Completion Notifications */}
<SettingsSectionHeader
title="Task Completion Notifications"
icon={<PartyPopper className="size-3.5" />}
/>
<div
className="mb-4 rounded-lg border p-4"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
}}
>
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Get native OS notifications when Claude finishes tasks sounds, banners, and Dock/taskbar
badges. Works on macOS, Linux, and Windows.
</p>
<button
onClick={() =>
void api.openExternal('https://github.com/777genius/claude-notifications-go')
}
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
style={{
backgroundColor: 'var(--color-border-emphasis)',
color: 'var(--color-text)',
}}
>
<ExternalLink className="size-3.5" />
Install claude-notifications-go plugin
</button>
</div>
{/* Notification Settings */}
<SettingsSectionHeader title="Notification Settings" icon={<Bell className="size-3.5" />} />
<SettingRow
@ -427,6 +396,37 @@ export const NotificationsSection = ({
disabled={saving}
dropUp
/>
{/* Task Completion Notifications */}
<SettingsSectionHeader
title="Task Completion Notifications"
icon={<PartyPopper className="size-3.5" />}
/>
<div
className="mb-4 rounded-lg border p-4"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
}}
>
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Get native OS notifications when Claude finishes tasks sounds, banners, and Dock/taskbar
badges. Works on macOS, Linux, and Windows.
</p>
<button
onClick={() =>
void api.openExternal('https://github.com/777genius/claude-notifications-go')
}
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
style={{
backgroundColor: 'var(--color-border-emphasis)',
color: 'var(--color-text)',
}}
>
<ExternalLink className="size-3.5" />
Install claude-notifications-go plugin
</button>
</div>
</div>
);
};

View file

@ -66,6 +66,11 @@ interface TaskCommentsSectionProps {
containerClassName?: string;
/** Snapshot of unread comment IDs captured when the dialog opened. Blue dot is shown for these. */
unreadCommentIds?: Set<string>;
/**
* Ref callback factory from useViewportCommentRead.
* When provided, each comment element is registered for viewport-based read tracking.
*/
registerCommentForViewport?: (timestampMs: number) => (el: HTMLElement | null) => void;
}
export const TaskCommentsSection = ({
@ -79,6 +84,7 @@ export const TaskCommentsSection = ({
onTaskIdClick,
containerClassName,
unreadCommentIds,
registerCommentForViewport,
}: TaskCommentsSectionProps): React.JSX.Element => {
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
@ -209,6 +215,11 @@ export const TaskCommentsSection = ({
{visibleComments.map((comment, index) => (
<AnimatedHeightReveal key={comment.id} animate={newCommentIds.has(comment.id)}>
<div
ref={
registerCommentForViewport
? registerCommentForViewport(new Date(comment.createdAt).getTime())
: undefined
}
className={[
'group min-w-0 overflow-hidden px-4 py-2.5',
comment.type === 'review_approved'

View file

@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { getLastReadTimestamp } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { useTheme } from '@renderer/hooks/useTheme';
import { useViewportCommentRead } from '@renderer/hooks/useViewportCommentRead';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import {
buildMemberColorMap,
@ -44,10 +45,14 @@ import {
displayMemberName,
} from '@renderer/utils/memberHelpers';
import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
deriveTaskDisplayId,
formatTaskDisplayLabel,
taskMatchesRef,
} from '@shared/utils/taskIdentity';
import { format, formatDistanceToNow } from 'date-fns';
import {
AlignLeft,
@ -217,6 +222,9 @@ export const TaskDetailDialog = ({
const setLightboxOpen = useCallback((isOpen: boolean) => {
lightboxOpenRef.current = isOpen;
}, []);
// Ref for the scrollable DialogContent — needed as IO root for viewport-based read tracking.
const dialogContentRef = useRef<HTMLDivElement | null>(null);
const handleReply = useCallback(
(author: string, text: string) => {
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
@ -225,11 +233,6 @@ export const TaskDetailDialog = ({
);
const clearReply = useCallback(() => setReplyTo(null), []);
const handleClose = useCallback(() => {
setReplyTo(null);
onClose();
}, [onClose]);
const effectiveReplyTo =
replyTo && replyTo.taskId === currentTask?.id
? { author: replyTo.author, text: replyTo.text }
@ -259,6 +262,19 @@ export const TaskDetailDialog = ({
unreadSnapshotRef.current = unread;
}, [open, teamName, currentTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Viewport-based comment read tracking (replaces mark-all-on-mount)
const { registerComment, flush: flushCommentRead } = useViewportCommentRead({
teamName,
taskId: currentTask?.id ?? '',
scrollContainerRef: dialogContentRef,
});
const handleClose = useCallback(() => {
flushCommentRead();
setReplyTo(null);
onClose();
}, [onClose, flushCommentRead]);
// Collect image attachments from comments for the Attachments section
const commentImageAttachments = useMemo(() => {
const comments = currentTask?.comments ?? [];
@ -413,8 +429,19 @@ export const TaskDetailDialog = ({
]);
const handleDependencyClick = (taskId: string): void => {
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
// since kanban cards use the full UUID in data-task-id.
let resolvedId = taskId;
if (!taskMap.has(taskId)) {
for (const [fullId, t] of taskMap) {
if (taskMatchesRef(t, taskId)) {
resolvedId = fullId;
break;
}
}
}
handleClose();
onScrollToTask?.(taskId);
onScrollToTask?.(resolvedId);
};
const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => {
@ -496,6 +523,7 @@ export const TaskDetailDialog = ({
}}
>
<DialogContent
ref={dialogContentRef}
className="sm:min-w-[500px] sm:max-w-4xl"
onInteractOutside={(e) => {
if (lightboxOpenRef.current) e.preventDefault();
@ -824,7 +852,25 @@ export const TaskDetailDialog = ({
</div>
</div>
) : currentTask.description ? (
<div className="group relative">
<div
className="group relative"
onClickCapture={
onScrollToTask
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const href = link.getAttribute('href');
const parsed = href ? parseTaskLinkHref(href) : null;
if (parsed?.taskId) handleDependencyClick(parsed.taskId);
}
}
: undefined
}
>
<ExpandableContent collapsedHeight={200}>
<MarkdownViewer
content={linkifyTaskIdsInMarkdown(
@ -1210,6 +1256,7 @@ export const TaskDetailDialog = ({
}
containerClassName="-mx-6"
unreadCommentIds={unreadSnapshotRef.current}
registerCommentForViewport={registerComment}
/>
</CollapsibleTeamSection>
</div>

View file

@ -1,36 +1,23 @@
import { useEffect, useRef } from 'react';
import { markAsRead } from '@renderer/services/commentReadStorage';
import type { TaskComment } from '@shared/types';
import { useRef } from 'react';
/**
* Marks task comments as read when the component is mounted and
* whenever the comments list changes while mounted.
* Provides a stable ref callback for the comments container.
*
* Previously used IntersectionObserver, but since the component
* is only rendered inside a CollapsibleTeamSection (conditional
* mount/unmount controls visibility), a simple effect is both
* simpler and more reliable especially inside Dialog portals
* where IntersectionObserver can miss the initial intersection.
* Previously this hook auto-marked all comments as read on mount via
* a useEffect. That behavior has been replaced by viewport-based
* tracking (useViewportCommentRead) which only marks comments read
* when they are scrolled into view inside the dialog.
*
* Returns a ref callback for the comments container (kept for
* API compatibility with TaskCommentsSection).
* This hook is kept for API compatibility with TaskCommentsSection
* (the ref callback is still attached to the container element).
*/
export function useMarkCommentsRead(
teamName: string,
taskId: string,
comments: TaskComment[]
_teamName: string,
_taskId: string,
_comments: unknown[]
): (node: HTMLElement | null) => void {
const nodeRef = useRef<HTMLElement | null>(null);
// Mark as read on mount and whenever comments change
useEffect(() => {
if (comments.length === 0) return;
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
if (latest > 0) markAsRead(teamName, taskId, latest);
}, [teamName, taskId, comments]);
// Stable ref callback (no dependencies — just stores the node)
const refCallback = useRef((node: HTMLElement | null) => {
nodeRef.current = node;

View file

@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef } from 'react';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { useViewportObserver } from './useViewportObserver';
import type { RefObject } from 'react';
interface UseViewportCommentReadOptions {
teamName: string;
taskId: string;
/**
* Scrollable ancestor element (e.g. DialogContent) used as IO root.
* Required for portalled Dialogs where the default viewport root
* would not detect intersections correctly.
*/
scrollContainerRef: RefObject<HTMLElement | null>;
}
/**
* Marks task comments as read based on viewport visibility.
*
* Instead of marking all comments read on mount, this hook uses
* IntersectionObserver (via useViewportObserver) to detect which
* comment elements are visible in the scroll container and updates
* the per-task read timestamp to the newest visible comment.
*
* Each comment element should be registered via the returned
* `registerComment(commentTimestampMs)` ref callback.
*
* Compatible with the existing per-task timestamp storage format
* in commentReadStorage no storage schema changes needed.
*/
export function useViewportCommentRead({
teamName,
taskId,
scrollContainerRef,
}: UseViewportCommentReadOptions): {
/** Ref callback factory. Call with the comment's createdAt timestamp (ms). */
registerComment: (timestampMs: number) => (el: HTMLElement | null) => void;
/**
* Flush the highest observed timestamp now. Call on dialog close
* as a safety fallback (e.g. if IO did not fire for portal reasons).
*/
flush: () => void;
} {
const highestSeenRef = useRef(0);
const teamNameRef = useRef(teamName);
const taskIdRef = useRef(taskId);
teamNameRef.current = teamName;
taskIdRef.current = taskId;
// Reset tracked state when team/task changes
useEffect(() => {
highestSeenRef.current = 0;
}, [teamName, taskId]);
const handleVisibleChange = useCallback((visibleValues: string[]) => {
let maxTs = 0;
for (const v of visibleValues) {
const ts = Number(v);
if (Number.isFinite(ts) && ts > maxTs) {
maxTs = ts;
}
}
if (maxTs > 0 && maxTs > highestSeenRef.current) {
highestSeenRef.current = maxTs;
markAsRead(teamNameRef.current, taskIdRef.current, maxTs);
}
}, []);
const { registerElement } = useViewportObserver({
rootRef: scrollContainerRef,
threshold: 0.1,
onVisibleChange: handleVisibleChange,
});
const registerComment = useCallback(
(timestampMs: number) => registerElement(String(timestampMs)),
[registerElement]
);
const flush = useCallback(() => {
if (highestSeenRef.current > 0) {
markAsRead(teamNameRef.current, taskIdRef.current, highestSeenRef.current);
}
}, []);
return { registerComment, flush };
}

View file

@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef } from 'react';
import type { RefObject } from 'react';
/** Data attribute name used to store arbitrary string data on observed elements. */
const DATA_ATTR = 'data-viewport-value';
interface UseViewportObserverOptions {
/**
* Scrollable ancestor element used as IntersectionObserver root.
* Required for elements inside Dialog portals where the default
* document viewport root would not detect intersections correctly.
*/
rootRef?: RefObject<HTMLElement | null>;
/** Visibility ratio threshold (0..1). Default: 0.1 (10% visible). */
threshold?: number;
/**
* Called when the set of visible elements changes.
* Receives the data-viewport-value strings of all currently intersecting elements.
*/
onVisibleChange: (visibleValues: string[]) => void;
}
/**
* Generic reusable hook for detecting which elements are visible in a
* scrollable container using IntersectionObserver.
*
* Usage:
* 1. Call the hook with a root ref and a callback.
* 2. Attach `registerElement(value)` as a ref callback on each element.
* `value` is an arbitrary string stored in a data attribute for identification.
* 3. The callback fires with the list of currently visible values whenever
* the intersection state changes.
*
* The hook manages a single IntersectionObserver instance and handles
* element registration/deregistration automatically.
*/
export function useViewportObserver({
rootRef,
threshold = 0.1,
onVisibleChange,
}: UseViewportObserverOptions): {
/** Ref callback factory. Attach the returned ref to an observed element. */
registerElement: (value: string) => (el: HTMLElement | null) => void;
} {
const onVisibleChangeRef = useRef(onVisibleChange);
onVisibleChangeRef.current = onVisibleChange;
const observerRef = useRef<IntersectionObserver | null>(null);
const visibleValuesRef = useRef<Set<string>>(new Set());
const elementsByValue = useRef<Map<string, HTMLElement>>(new Map());
// Create / recreate observer when root or threshold changes.
useEffect(() => {
const root = rootRef?.current ?? null;
const observer = new IntersectionObserver(
(entries) => {
let changed = false;
for (const entry of entries) {
const value = entry.target.getAttribute(DATA_ATTR);
if (!value) continue;
if (entry.isIntersecting) {
if (!visibleValuesRef.current.has(value)) {
visibleValuesRef.current.add(value);
changed = true;
}
} else {
if (visibleValuesRef.current.has(value)) {
visibleValuesRef.current.delete(value);
changed = true;
}
}
}
if (changed) {
onVisibleChangeRef.current(Array.from(visibleValuesRef.current));
}
},
{ root, threshold }
);
// Re-observe elements that were registered before observer was created
// (or after root changed).
for (const [value, el] of elementsByValue.current) {
el.setAttribute(DATA_ATTR, value);
observer.observe(el);
}
observerRef.current = observer;
return () => {
observer.disconnect();
observerRef.current = null;
visibleValuesRef.current.clear();
};
}, [rootRef, threshold]);
const registerElement = useCallback((value: string) => {
return (el: HTMLElement | null) => {
// Cleanup previous element for this value
const prev = elementsByValue.current.get(value);
if (prev) {
observerRef.current?.unobserve(prev);
elementsByValue.current.delete(value);
visibleValuesRef.current.delete(value);
}
// Register new element
if (el) {
el.setAttribute(DATA_ATTR, value);
elementsByValue.current.set(value, el);
observerRef.current?.observe(el);
}
};
}, []);
return { registerElement };
}