mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
19a41708ab
commit
44ee729540
8 changed files with 384 additions and 62 deletions
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>·</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>·</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
|
|||
messages={messages}
|
||||
sharedBy={sharedBy}
|
||||
sharedAt={sharedAt}
|
||||
shareToken={token}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
128
apps/web/src/hooks/useAutoScroll.ts
Normal file
128
apps/web/src/hooks/useAutoScroll.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
34
apps/web/src/hooks/useRealtimePolling.ts
Normal file
34
apps/web/src/hooks/useRealtimePolling.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue