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:
parent
f5efa17b1a
commit
edddf526db
6 changed files with 318 additions and 64 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
90
src/renderer/hooks/useViewportCommentRead.ts
Normal file
90
src/renderer/hooks/useViewportCommentRead.ts
Normal 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 };
|
||||
}
|
||||
119
src/renderer/hooks/useViewportObserver.ts
Normal file
119
src/renderer/hooks/useViewportObserver.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue