diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 40530f69..0ca31d22 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -147,37 +147,6 @@ export const NotificationsSection = ({ ) : null} - {/* Task Completion Notifications */} - } - /> -
-

- Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar - badges. Works on macOS, Linux, and Windows. -

- -
- {/* Notification Settings */} } /> + + {/* Task Completion Notifications */} + } + /> +
+

+ Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar + badges. Works on macOS, Linux, and Windows. +

+ +
); }; diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index f5a0d54e..d60ee621 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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; + /** + * 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) => (
{ lightboxOpenRef.current = isOpen; }, []); + + // Ref for the scrollable DialogContent — needed as IO root for viewport-based read tracking. + const dialogContentRef = useRef(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 = ({ }} > { if (lightboxOpenRef.current) e.preventDefault(); @@ -824,7 +852,25 @@ export const TaskDetailDialog = ({
) : currentTask.description ? ( -
+
{ + const link = (e.target as HTMLElement).closest( + '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 + } + >
diff --git a/src/renderer/hooks/useMarkCommentsRead.ts b/src/renderer/hooks/useMarkCommentsRead.ts index d9c9160e..12a6d797 100644 --- a/src/renderer/hooks/useMarkCommentsRead.ts +++ b/src/renderer/hooks/useMarkCommentsRead.ts @@ -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(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; diff --git a/src/renderer/hooks/useViewportCommentRead.ts b/src/renderer/hooks/useViewportCommentRead.ts new file mode 100644 index 00000000..ea26aac2 --- /dev/null +++ b/src/renderer/hooks/useViewportCommentRead.ts @@ -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; +} + +/** + * 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 }; +} diff --git a/src/renderer/hooks/useViewportObserver.ts b/src/renderer/hooks/useViewportObserver.ts new file mode 100644 index 00000000..f9cfd9ed --- /dev/null +++ b/src/renderer/hooks/useViewportObserver.ts @@ -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; + /** 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(null); + const visibleValuesRef = useRef>(new Set()); + const elementsByValue = useRef>(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 }; +}