Update task view in real-time (#155)

Co-authored-by: matt <matt@roocode.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Matt Rubens 2025-06-30 13:52:44 -04:00 committed by GitHub
parent 19a41708ab
commit 44ee729540
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 384 additions and 62 deletions

View file

@ -483,6 +483,78 @@ export async function getTaskShares(taskId: string): Promise<TaskShare[]> {
}
}
/**
* 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<Message[]> {
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).
*/

View file

@ -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<HTMLDivElement>({
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 (
<div className="space-y-6">
{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 (
<div
key={message.id}
className={cn(
'flex flex-col gap-3 rounded-lg p-4',
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
)}
>
<div className="flex flex-row items-center justify-between gap-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<div>{message.name}</div>
<div>&middot;</div>
<div>{message.timestamp}</div>
return (
<div className="relative">
{/* Scrollable messages container */}
<div
ref={containerRef}
className="max-h-[600px] overflow-y-auto space-y-6 pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'hsl(var(--border)) transparent',
}}
>
{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 (
<div
key={message.id}
className={cn(
'flex flex-col gap-3 rounded-lg p-4',
message.role === 'user' ? 'bg-primary/10' : 'bg-secondary/10',
)}
>
<div className="flex flex-row items-center justify-between gap-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<div>{message.name}</div>
<div>&middot;</div>
<div>{message.timestamp}</div>
</div>
{message.mode && (
<div className="px-2 py-1 bg-muted rounded text-xs font-medium">
{message.mode}
</div>
)}
</div>
{message.mode && (
<div className="px-2 py-1 bg-muted rounded text-xs font-medium">
{message.mode}
{isQuestion && questionData ? (
<div className="space-y-4">
{questionData.question && (
<div className="text-sm leading-relaxed">
{questionData.question}
</div>
)}
{questionData.suggestions &&
questionData.suggestions.length > 0 && (
<div className="space-y-2">
{questionData.suggestions.map((suggestion, index) => (
<div
key={index}
className="px-4 py-3 bg-background border border-border rounded-md text-sm hover:bg-muted/50 cursor-pointer transition-colors"
>
{typeof suggestion === 'string'
? suggestion
: suggestion.answer}
</div>
))}
</div>
)}
</div>
) : isCommand ? (
<div className="space-y-3">
<div className="bg-black/90 text-foreground p-3 rounded-md font-mono text-sm">
{message.text}
</div>
</div>
) : (
<div className="text-sm leading-relaxed markdown-prose">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
{isQuestion && questionData ? (
<div className="space-y-4">
{questionData.question && (
<div className="text-sm leading-relaxed">
{questionData.question}
</div>
)}
{questionData.suggestions &&
questionData.suggestions.length > 0 && (
<div className="space-y-2">
{questionData.suggestions.map((suggestion, index) => (
<div
key={index}
className="px-4 py-3 bg-background border border-border rounded-md text-sm hover:bg-muted/50 cursor-pointer transition-colors"
>
{typeof suggestion === 'string'
? suggestion
: suggestion.answer}
</div>
))}
</div>
)}
</div>
) : isCommand ? (
<div className="space-y-3">
<div className="bg-black/90 text-foreground p-3 rounded-md font-mono text-sm">
{message.text}
</div>
</div>
) : (
<div className="text-sm leading-relaxed markdown-prose">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
)}
</div>
);
})}
{/* Scroll to bottom button - shown when user has scrolled up */}
{userHasScrolled && (
<div className="absolute bottom-4 right-4">
<button
className="bg-secondary text-secondary-foreground shadow-lg rounded-full h-10 w-10 p-0 border border-border hover:bg-secondary/80 flex items-center justify-center transition-colors"
onClick={() => scrollToBottom()}
title="Scroll to bottom"
type="button"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</button>
</div>
)}
</div>
);
};

View file

@ -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 =

View file

@ -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(() => {

View file

@ -36,6 +36,7 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
messages={messages}
sharedBy={sharedBy}
sharedAt={sharedAt}
shareToken={token}
/>
</div>
);

View file

@ -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 (
<TaskDetails
task={task}

View file

@ -0,0 +1,128 @@
import { useEffect, useRef, useCallback, useState } from 'react';
interface UseAutoScrollOptions {
enabled?: boolean;
threshold?: number; // Distance from bottom to consider "at bottom"
scrollBehavior?: ScrollBehavior;
}
/**
* Custom hook to manage auto-scrolling for message containers
* Automatically scrolls to bottom when new content is added,
* unless the user has manually scrolled up
*/
export const useAutoScroll = <T extends HTMLElement>({
enabled = true,
threshold = 100,
scrollBehavior = 'smooth',
}: UseAutoScrollOptions = {}) => {
const containerRef = useRef<T>(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),
};
};

View file

@ -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;
};