From 44ee729540db9cdfbcbd0581168dea4d011b9ed0 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 30 Jun 2025 13:52:44 -0400 Subject: [PATCH] Update task view in real-time (#155) Co-authored-by: matt Co-authored-by: Cursor Agent --- apps/web/src/actions/taskSharing.ts | 72 ++++++++ .../app/(authenticated)/usage/Messages.tsx | 172 +++++++++++------- .../app/(authenticated)/usage/TaskModal.tsx | 5 + .../src/app/(authenticated)/usage/Tasks.tsx | 3 + apps/web/src/app/share/[token]/page.tsx | 1 + .../task-sharing/SharedTaskView.tsx | 31 +++- apps/web/src/hooks/useAutoScroll.ts | 128 +++++++++++++ apps/web/src/hooks/useRealtimePolling.ts | 34 ++++ 8 files changed, 384 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/hooks/useAutoScroll.ts create mode 100644 apps/web/src/hooks/useRealtimePolling.ts diff --git a/apps/web/src/actions/taskSharing.ts b/apps/web/src/actions/taskSharing.ts index 96c3771c27..92ec0a8a21 100644 --- a/apps/web/src/actions/taskSharing.ts +++ b/apps/web/src/actions/taskSharing.ts @@ -483,6 +483,78 @@ export async function getTaskShares(taskId: string): Promise { } } +/** + * Get messages for a shared task (used for polling updates) + * This function handles authentication internally and should be used instead of getMessages with skipAuth + */ +export async function getSharedTaskMessages( + shareToken: string, +): Promise { + try { + if (!isValidShareToken(shareToken)) { + throw new Error('Invalid share token'); + } + + // Get the share to validate access + const [shareWithUser] = await db + .select({ + share: taskShares, + }) + .from(taskShares) + .where(eq(taskShares.shareToken, shareToken)) + .limit(1); + + if (!shareWithUser) { + throw new Error('Share not found'); + } + + const { share } = shareWithUser; + + if (isShareExpired(share.expiresAt)) { + throw new Error('Share has expired'); + } + + // Check visibility and auth requirements + if (share.visibility === TaskShareVisibility.ORGANIZATION) { + const authResult = await authorize(); + const userId = authResult.success ? authResult.userId : null; + const orgId = authResult.success ? authResult.orgId : null; + + // For organization shares, require auth and matching orgId + if (!userId || !orgId || orgId !== share.orgId) { + throw new Error('Authentication required for organization shares'); + } + } + // For public shares, no auth check needed + + // Get the task to get the userId + const tasks = await getTasks({ + taskId: share.taskId, + orgId: share.orgId, + allowCrossUserAccess: true, + skipAuth: true, // We've already validated access above + }); + const task = tasks[0]; + + if (!task) { + throw new Error('Task not found'); + } + + // Get messages with skipAuth since we've already validated access + const messages = await getMessages( + share.taskId, + share.orgId, + task.userId, + true, // Skip auth since we've already validated access above + ); + + return messages; + } catch (error) { + console.error('Error getting shared task messages:', error); + throw error; + } +} + /** * Clean up expired shares (background job function). */ diff --git a/apps/web/src/app/(authenticated)/usage/Messages.tsx b/apps/web/src/app/(authenticated)/usage/Messages.tsx index 1e023cc77c..a9176f0d3e 100644 --- a/apps/web/src/app/(authenticated)/usage/Messages.tsx +++ b/apps/web/src/app/(authenticated)/usage/Messages.tsx @@ -1,9 +1,10 @@ -import { useMemo } from 'react'; +import { useMemo, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import type { Message } from '@/actions/analytics'; import { cn } from '@/lib/utils'; import { formatTimestamp } from '@/lib/formatters'; +import { useAutoScroll } from '@/hooks/useAutoScroll'; type MessagesProps = { messages: Message[]; @@ -34,6 +35,13 @@ const parseQuestionData = (text: string): QuestionData | null => { }; export const Messages = ({ messages }: MessagesProps) => { + const { containerRef, scrollToBottom, autoScrollToBottom, userHasScrolled } = + useAutoScroll({ + enabled: true, + threshold: 50, + scrollBehavior: 'smooth', + }); + const conversation = useMemo(() => { const visibleMessages = messages.filter(isVisible); @@ -55,72 +63,114 @@ export const Messages = ({ messages }: MessagesProps) => { ); }, [messages]); - return ( -
- {conversation.map((message) => { - const isQuestion = message.type === 'ask' && message.ask === 'followup'; - const isCommand = message.type === 'ask' && message.ask === 'command'; - const questionData = - isQuestion && message.text ? parseQuestionData(message.text) : null; + // Auto-scroll when new messages arrive or content changes (only if user is at bottom) + useEffect(() => { + autoScrollToBottom(); + }, [conversation, autoScrollToBottom]); - return ( -
-
-
-
{message.name}
-
·
-
{message.timestamp}
+ return ( +
+ {/* Scrollable messages container */} +
+ {conversation.map((message) => { + const isQuestion = + message.type === 'ask' && message.ask === 'followup'; + const isCommand = message.type === 'ask' && message.ask === 'command'; + const questionData = + isQuestion && message.text ? parseQuestionData(message.text) : null; + + return ( +
+
+
+
{message.name}
+
·
+
{message.timestamp}
+
+ {message.mode && ( +
+ {message.mode} +
+ )}
- {message.mode && ( -
- {message.mode} + + {isQuestion && questionData ? ( +
+ {questionData.question && ( +
+ {questionData.question} +
+ )} + {questionData.suggestions && + questionData.suggestions.length > 0 && ( +
+ {questionData.suggestions.map((suggestion, index) => ( +
+ {typeof suggestion === 'string' + ? suggestion + : suggestion.answer} +
+ ))} +
+ )} +
+ ) : isCommand ? ( +
+
+ {message.text} +
+
+ ) : ( +
+ {message.text}
)}
+ ); + })} +
- {isQuestion && questionData ? ( -
- {questionData.question && ( -
- {questionData.question} -
- )} - {questionData.suggestions && - questionData.suggestions.length > 0 && ( -
- {questionData.suggestions.map((suggestion, index) => ( -
- {typeof suggestion === 'string' - ? suggestion - : suggestion.answer} -
- ))} -
- )} -
- ) : isCommand ? ( -
-
- {message.text} -
-
- ) : ( -
- {message.text} -
- )} -
- ); - })} + {/* Scroll to bottom button - shown when user has scrolled up */} + {userHasScrolled && ( +
+ +
+ )}
); }; diff --git a/apps/web/src/app/(authenticated)/usage/TaskModal.tsx b/apps/web/src/app/(authenticated)/usage/TaskModal.tsx index 9b2163982a..1b2450d62f 100644 --- a/apps/web/src/app/(authenticated)/usage/TaskModal.tsx +++ b/apps/web/src/app/(authenticated)/usage/TaskModal.tsx @@ -5,6 +5,7 @@ import type { TaskWithUser } from '@/actions/analytics'; import { getMessages } from '@/actions/analytics'; import { canShareTask } from '@/actions/taskSharing'; import { useOrganizationSettings } from '@/hooks/useOrganizationSettings'; +import { useRealtimePolling } from '@/hooks/useRealtimePolling'; import { QueryKey } from '@/types/react-query'; import { Dialog, DialogContentLarge } from '@/components/ui'; import { ShareButton } from '@/components/task-sharing/ShareButton'; @@ -18,11 +19,14 @@ type TaskModalProps = { export const TaskModal = ({ task, open, onClose }: TaskModalProps) => { const { orgId, userId } = useAuth(); + const messagePolling = useRealtimePolling({ enabled: open, interval: 2000 }); + const sharePolling = useRealtimePolling({ enabled: open, interval: 5000 }); const { data: messages = [] } = useQuery({ queryKey: ['messages', task.taskId, orgId, userId], queryFn: () => getMessages(task.taskId, orgId, userId, false), enabled: open && !!task.taskId, + ...messagePolling, }); const { data: orgSettings } = useOrganizationSettings(); @@ -31,6 +35,7 @@ export const TaskModal = ({ task, open, onClose }: TaskModalProps) => { queryKey: [QueryKey.CanShareTask, task.taskId], queryFn: () => canShareTask(task.taskId), enabled: open && !!task.taskId, + ...sharePolling, }); const isTaskSharingEnabled = diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index e35e47b5aa..68056a99f6 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import type { TaskWithUser } from '@/actions/analytics'; import { getTasks } from '@/actions/analytics'; +import { useRealtimePolling } from '@/hooks/useRealtimePolling'; import { Skeleton } from '@/components/ui'; import { TaskCard } from '@/components/usage'; @@ -23,6 +24,7 @@ export const Tasks = ({ currentUserId?: string | null; }) => { const { orgId } = useAuth(); + const polling = useRealtimePolling({ enabled: true, interval: 5000 }); const { data = [], isPending } = useQuery({ queryKey: [ @@ -37,6 +39,7 @@ export const Tasks = ({ userId: userRole === 'member' ? currentUserId : undefined, }), enabled: true, // Run for both personal and organization context + ...polling, }); const tasks = useMemo(() => { diff --git a/apps/web/src/app/share/[token]/page.tsx b/apps/web/src/app/share/[token]/page.tsx index 0670e9b895..23485c3247 100644 --- a/apps/web/src/app/share/[token]/page.tsx +++ b/apps/web/src/app/share/[token]/page.tsx @@ -36,6 +36,7 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) { messages={messages} sharedBy={sharedBy} sharedAt={sharedAt} + shareToken={token} />
); diff --git a/apps/web/src/components/task-sharing/SharedTaskView.tsx b/apps/web/src/components/task-sharing/SharedTaskView.tsx index a33819e6fc..3f2db5ae98 100644 --- a/apps/web/src/components/task-sharing/SharedTaskView.tsx +++ b/apps/web/src/components/task-sharing/SharedTaskView.tsx @@ -1,5 +1,11 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + import type { TaskWithUser, Message } from '@/actions/analytics'; import type { SharedByUser } from '@/types/task-sharing'; +import { getSharedTaskMessages } from '@/actions/taskSharing'; +import { useRealtimePolling } from '@/hooks/useRealtimePolling'; import { TaskDetails } from './TaskDetails'; type SharedTaskViewProps = { @@ -7,14 +13,37 @@ type SharedTaskViewProps = { messages: Message[]; sharedBy: SharedByUser; sharedAt: Date; + shareToken?: string; }; export const SharedTaskView = ({ task, - messages, + messages: initialMessages, sharedBy, sharedAt, + shareToken, }: SharedTaskViewProps) => { + const polling = useRealtimePolling({ + enabled: !!task.taskId, + interval: 3000, + }); + + // Poll for updated messages for shared tasks + const { data: messages = initialMessages } = useQuery({ + queryKey: ['shared-messages', task.taskId, shareToken], + queryFn: () => { + if (!shareToken) { + throw new Error( + 'Share token is required for polling shared task messages', + ); + } + return getSharedTaskMessages(shareToken); + }, + initialData: initialMessages, + enabled: !!task.taskId && !!shareToken, + ...polling, + }); + return ( ({ + enabled = true, + threshold = 100, + scrollBehavior = 'smooth', +}: UseAutoScrollOptions = {}) => { + const containerRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); + const [userHasScrolled, setUserHasScrolled] = useState(false); + const lastScrollTop = useRef(0); + const isScrollingToBottom = useRef(false); + const hasInitialized = useRef(false); + + // Check if user is at the bottom of the container + const checkIfAtBottom = useCallback(() => { + const container = containerRef.current; + if (!container) return false; + + const { scrollTop, scrollHeight, clientHeight } = container; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + return distanceFromBottom <= threshold; + }, [threshold]); + + // Scroll to bottom + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = scrollBehavior) => { + const container = containerRef.current; + if (!container || !enabled) return; + + isScrollingToBottom.current = true; + container.scrollTo({ + top: container.scrollHeight, + behavior, + }); + + // Reset the flag after scrolling is likely complete + setTimeout( + () => { + isScrollingToBottom.current = false; + }, + behavior === 'smooth' ? 500 : 100, + ); + }, + [enabled, scrollBehavior], + ); + + // Handle scroll events to detect manual scrolling + const handleScroll = useCallback(() => { + const container = containerRef.current; + if (!container || isScrollingToBottom.current) return; + + const currentScrollTop = container.scrollTop; + const atBottom = checkIfAtBottom(); + + // Detect if user scrolled up manually + if (currentScrollTop < lastScrollTop.current && !atBottom) { + setUserHasScrolled(true); + } + + // If user scrolled back to bottom, reset the manual scroll flag + if (atBottom && userHasScrolled) { + setUserHasScrolled(false); + } + + setIsAtBottom(atBottom); + lastScrollTop.current = currentScrollTop; + }, [checkIfAtBottom, userHasScrolled]); + + // Set up scroll listener + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', handleScroll, { passive: true }); + + // Initial check + handleScroll(); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + }, [handleScroll]); + + // Auto-scroll when new content is added (dependencies change) + // On first load: don't auto-scroll (let user read from top) + // On subsequent updates: only auto-scroll if user hasn't manually scrolled up + const autoScrollToBottom = useCallback(() => { + if (!enabled) return; + + const container = containerRef.current; + if (!container) return; + + // On first load, don't auto-scroll - let user start from top + if (!hasInitialized.current) { + hasInitialized.current = true; + return; + } + + // On subsequent updates, only auto-scroll if user hasn't manually scrolled up + if (userHasScrolled) return; + + // Small delay to ensure DOM has updated + setTimeout(() => { + scrollToBottom(); + }, 50); + }, [enabled, userHasScrolled, scrollToBottom]); + + return { + containerRef, + scrollToBottom, + autoScrollToBottom, + isAtBottom, + userHasScrolled, + resetUserScroll: () => setUserHasScrolled(false), + }; +}; diff --git a/apps/web/src/hooks/useRealtimePolling.ts b/apps/web/src/hooks/useRealtimePolling.ts new file mode 100644 index 0000000000..64bdf88e7d --- /dev/null +++ b/apps/web/src/hooks/useRealtimePolling.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +interface UseRealtimePollingOptions { + enabled?: boolean; + interval?: number; +} + +/** + * Custom hook to manage real-time polling configuration + * Automatically pauses polling when the tab is not visible + */ +export const useRealtimePolling = ({ + enabled = true, + interval = 3000, +}: UseRealtimePollingOptions = {}) => { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const handleVisibilityChange = () => { + setIsVisible(!document.hidden); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + return { + refetchInterval: enabled && isVisible ? interval : false, + refetchIntervalInBackground: false, + isVisible, + } as const; +};