Dylan/task sidebar polling (#107)

* fix threadItem task-sidebar onClick bugs

* format + remove thread-utils

* init polling for threadItem updates

* rm Stream update for sidebar

* tasks completed accurate from state while polling

* remove console logs

* CR

* CR: fix types, rm selectedThread, fix active task logic

* fix minor navigation state, update polling animation time

* remove type narrowing / casting CR
This commit is contained in:
Dylan Boudro 2025-06-11 16:40:11 -07:00 • committed by GitHub
parent 708ee529d1
commit 30881a5d92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 303 additions and 210 deletions

View file

@ -1,5 +1,4 @@
"use client";
import {
Archive,
ChevronLeft,
@ -9,12 +8,11 @@ import {
import { Button } from "@/components/ui/button";
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState } from "react";
import { useState, useCallback } from "react";
import { ThreadItem } from "./thread-item";
const THREADS_PER_PAGE = 10; // More threads per page in sidebar
const THREADS_PER_PAGE = 10;
// TODO: Clarify Language about Threads and Tasks in the TaskListSidebar component
interface TaskListSidebarProps {
onCollapse?: () => void;
}
@ -23,15 +21,15 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading } = useThreads();
const { threads, threadsLoading, handleThreadClick } = useThreads();
// Handle thread navigation
const handleThreadClick = (thread: ThreadWithTasks) => {
setThreadId(thread.thread_id);
setTaskId(null);
};
// Sort threads by creation date (newest first) - already done in provider
const onThreadClick = useCallback(
(thread: ThreadWithTasks) => {
handleThreadClick(thread, threadId, setThreadId);
},
[handleThreadClick, threadId, setThreadId],
);
// Sort threads by creation date (newest first) TODO use provider (already done there)
const sortedThreads = threads;
const totalThreads = sortedThreads.length;
const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE);
@ -77,7 +75,7 @@ export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
<ThreadItem
key={thread.thread_id}
thread={thread}
onClick={handleThreadClick}
onClick={onThreadClick}
variant="sidebar"
/>
))}

View file

@ -1,26 +1,28 @@
"use client";
import { Archive, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState } from "react";
import { useState, useCallback } from "react";
import { ThreadItem } from "./thread-item";
const THREADS_PER_PAGE = 5;
export default function TaskList() {
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [_threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading } = useThreads();
const { threads, threadsLoading, handleThreadClick } = useThreads();
const isDashboardMode = !taskId;
const handleThreadClick = (thread: ThreadWithTasks) => {
setThreadId(thread.thread_id);
setTaskId(null);
};
const onThreadClick = useCallback(
(thread: ThreadWithTasks) => {
handleThreadClick(thread, threadId, setThreadId);
setTaskId(null);
},
[handleThreadClick, threadId, setThreadId, setTaskId],
);
if (!isDashboardMode) {
return null;
@ -51,7 +53,7 @@ export default function TaskList() {
<ThreadItem
key={thread.thread_id}
thread={thread}
onClick={handleThreadClick}
onClick={onThreadClick}
variant="dashboard"
/>
))}

View file

@ -1,11 +1,9 @@
"use client";
import { memo } from "react";
import { GitBranch, ArrowRight, ListTodo } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ThreadWithTasks } from "@/providers/Thread";
import { ThreadWithTasks, useThreads } from "@/providers/Thread";
import { cn } from "@/lib/utils";
import { StatusIndicator } from "@/components/status-indicator";
import { Skeleton } from "@/components/ui/skeleton";
import { GitHubSVG } from "./icons/github";
import { useQueryState } from "nuqs";
@ -16,66 +14,29 @@ interface ThreadItemProps {
className?: string;
}
export function ThreadItem({
export const ThreadItem = memo(function ThreadItem({
thread,
onClick,
variant = "dashboard",
className,
}: ThreadItemProps) {
const [threadId] = useQueryState("threadId");
const { recentlyUpdatedThreads } = useThreads();
const isSelected = thread.thread_id === threadId;
const isSidebar = variant === "sidebar";
const isRecentlyUpdated = recentlyUpdatedThreads.has(thread.thread_id);
const displayDate = new Date(thread.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
// Check if thread data is still loading/incomplete
const isLoading =
!thread.threadTitle ||
thread.threadTitle.includes("undefined") ||
!thread.repository ||
thread.repository === "Unknown Repository" ||
thread.repository.includes("undefined");
if (isLoading) {
return (
<div
className={cn(
"rounded-md border border-gray-200 bg-inherit p-1.5 shadow-sm",
className,
)}
>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<div className="mt-1 flex items-center gap-1 text-xs">
<Skeleton className="h-3 w-3" /> {/* GitHub icon placeholder */}
<Skeleton className="h-3 w-16" /> {/* Repo */}
<Skeleton className="ml-0.5 h-2.5 w-2.5" />{" "}
<Skeleton className="ml-0.5 h-3 w-12" /> {/* Branch */}
<Skeleton className="ml-auto h-3 w-10" /> {/* Date */}
</div>
<div className="mt-1">
<Skeleton className="h-4 w-20 rounded" />
</div>
</div>
</div>
</div>
);
}
return (
<div
className={cn(
"group cursor-pointer rounded-md border border-gray-200 bg-inherit p-2 shadow-sm transition-colors hover:bg-gray-50 hover:shadow-md",
isSelected && "border-primary",
isRecentlyUpdated && "animate-pulse border-blue-200 bg-blue-50",
className,
)}
onClick={() => {
@ -139,4 +100,4 @@ export function ThreadItem({
</div>
</div>
);
}
});

View file

@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { ThreadPoller, PollConfig } from "@/lib/polling/thread-poller";
import { ThreadWithTasks } from "@/providers/Thread";
interface UseThreadPollingProps {
threads: ThreadWithTasks[];
getThread: (threadId: string) => Promise<ThreadWithTasks | null>;
onUpdate: (
updatedThreads: ThreadWithTasks[],
changedThreadIds: string[],
) => void;
enabled?: boolean;
}
export function useThreadPolling({
threads,
getThread,
onUpdate,
enabled = true,
}: UseThreadPollingProps) {
const pollerRef = useRef<ThreadPoller | null>(null);
useEffect(() => {
if (!enabled) return;
const config: PollConfig = {
interval: 15000,
onUpdate,
};
pollerRef.current = new ThreadPoller(config, threads, getThread);
pollerRef.current.start();
return () => {
if (pollerRef.current) {
pollerRef.current.stop();
pollerRef.current = null;
}
};
}, [threads, getThread, onUpdate, enabled]);
return {
start: () => pollerRef.current?.start(),
stop: () => pollerRef.current?.stop(),
};
}

View file

@ -0,0 +1,97 @@
import { ThreadWithTasks } from "@/providers/Thread";
export interface PollConfig {
interval: number;
onUpdate: (
updatedThreads: ThreadWithTasks[],
changedThreadIds: string[],
) => void;
}
export class ThreadPoller {
private config: PollConfig;
private isPolling: boolean = false;
private intervalId: NodeJS.Timeout | null = null;
private threads: ThreadWithTasks[];
private getThreadFn: (threadId: string) => Promise<ThreadWithTasks | null>;
constructor(
config: PollConfig,
threads: ThreadWithTasks[],
getThreadFn: (threadId: string) => Promise<ThreadWithTasks | null>,
) {
this.config = config;
this.threads = threads;
this.getThreadFn = getThreadFn;
}
start(): void {
if (this.isPolling) return;
this.isPolling = true;
this.intervalId = setInterval(() => {
this.pollThreads();
}, this.config.interval);
}
stop(): void {
if (!this.isPolling) return;
this.isPolling = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async pollThreads(): Promise<void> {
try {
const currentThreads = this.threads;
const threadsToPool = currentThreads.slice(0, 10);
const updatedThreads: ThreadWithTasks[] = [];
const changedThreadIds: string[] = [];
const errors: string[] = [];
for (const currentThread of threadsToPool) {
try {
const updatedThread = await this.getThreadFn(currentThread.thread_id);
if (updatedThread) {
updatedThreads.push(updatedThread);
if (this.hasThreadChanged(currentThread, updatedThread)) {
changedThreadIds.push(updatedThread.thread_id);
}
}
} catch (error) {
errors.push(`Thread ${currentThread.thread_id}: ${error}`);
updatedThreads.push(currentThread);
}
}
if (changedThreadIds.length > 0) {
this.config.onUpdate(updatedThreads, changedThreadIds);
}
} catch (error) {
console.error("Thread polling error:", error);
}
}
private hasThreadChanged(
current: ThreadWithTasks,
updated: ThreadWithTasks,
): boolean {
return (
current.completedTasksCount !== updated.completedTasksCount ||
current.totalTasksCount !== updated.totalTasksCount ||
current.status !== updated.status ||
current.threadTitle !== updated.threadTitle ||
current.repository !== updated.repository ||
current.branch !== updated.branch ||
JSON.stringify(current.tasks) !== JSON.stringify(updated.tasks) ||
JSON.stringify(current.proposedPlan) !==
JSON.stringify(updated.proposedPlan)
);
}
}

View file

@ -4,7 +4,6 @@ import React, {
ReactNode,
useState,
useEffect,
useRef,
} from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import {
@ -52,8 +51,7 @@ const StreamSession = ({
githubToken: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { refreshThreads, updateThreadFromStream } = useThreads();
const { refreshThreads, setThreads } = useThreads();
const streamValue = useTypedStream({
apiUrl,
assistantId,
@ -69,46 +67,13 @@ const StreamSession = ({
},
onThreadId: (id) => {
setThreadId(id);
// Refetch threads list when thread ID changes.
// Wait for some seconds before fetching so we're able to get the new thread that was created.
sleep().then(() => {
refreshThreads().catch(console.error);
});
},
});
// Listen for stream updates to update all thread properties in real-time
// Use a ref to track the last update to prevent excessive calls
const lastUpdateRef = useRef<{ threadId: string; valuesHash: string } | null>(
null,
);
useEffect(() => {
if (threadId && streamValue.values) {
// Create a simple hash of the values to detect actual changes
const valuesHash = JSON.stringify({
plan: (streamValue.values as any).plan,
proposedPlan: (streamValue.values as any).proposedPlan,
targetRepository: (streamValue.values as any).targetRepository,
messages: (
streamValue.values as any
).messages?.[0]?.content?.[0]?.text?.substring(0, 50),
});
// Only update if thread or values actually changed
if (
!lastUpdateRef.current ||
lastUpdateRef.current.threadId !== threadId ||
lastUpdateRef.current.valuesHash !== valuesHash
) {
lastUpdateRef.current = { threadId, valuesHash };
updateThreadFromStream(threadId, streamValue.values);
}
}
}, [threadId, streamValue.values, updateThreadFromStream]);
// Real-time updates via stream - no polling needed
return (
<StreamContext.Provider value={streamValue}>
{children}
@ -129,7 +94,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Initialize from localStorage to prevent modal flash
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
boolean | null
>(() => {
@ -152,7 +116,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (isAuth) {
// Check if we already have cached installation status
const cachedInstallationStatus = localStorage.getItem(
"github_app_installed",
);
@ -165,7 +128,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
} else if (cachedInstallationStatus === "false") {
setHasGitHubAppInstalled(false);
} else {
// Only check installation if we don't have cached status
checkGitHubAppInstallation();
}
}
@ -189,7 +151,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
if (response.ok) {
setHasGitHubAppInstalled(true);
localStorage.setItem("github_app_installed", "true");
// If the app is installed, fetch a token
await fetchGitHubToken();
} else {
const errorData = await response.json();
@ -197,7 +159,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
} else {
// If there's a different error, we'll assume the app is not installed
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
}
@ -218,7 +179,7 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const handleInstallGitHubApp = () => {
setIsLoading(true);
// Clear cached status so we check again after installation
localStorage.removeItem("github_app_installed");
window.location.href = "/api/github/installation";
};
@ -401,7 +362,6 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
);
};
// Create a custom hook to use the context
export const useStreamContext = (): StreamContextType => {
const context = useContext(StreamContext);
if (context === undefined) {

View file

@ -9,10 +9,12 @@ import {
Dispatch,
SetStateAction,
useEffect,
useTransition,
} from "react";
import { createClient } from "./client";
import { getMessageContentString } from "@open-swe/shared/messages";
import { TaskPlan } from "@open-swe/shared/open-swe/types";
import { TaskPlan, GraphState } from "@open-swe/shared/open-swe/types";
import { useThreadPolling } from "@/hooks/useThreadPolling";
export interface ThreadWithTasks extends Thread {
threadTitle: string;
@ -31,7 +33,13 @@ interface ThreadContextType {
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
refreshThreads: () => Promise<void>;
getThread: (threadId: string) => Promise<ThreadWithTasks | null>;
updateThreadFromStream: (threadId: string, streamValues: any) => void;
isPending: boolean;
recentlyUpdatedThreads: Set<string>;
handleThreadClick: (
thread: ThreadWithTasks,
currentThreadId: string | null,
setThreadId: (id: string) => void,
) => void;
}
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
@ -49,39 +57,56 @@ function getThreadSearchMetadata(
const getTaskCounts = (
tasks?: TaskPlan,
proposedPlan?: string[],
existingCounts?: { totalTasksCount: number; completedTasksCount: number },
): { totalTasksCount: number; completedTasksCount: number } => {
if (proposedPlan && !tasks) {
const defaultCounts = existingCounts || {
totalTasksCount: 0,
completedTasksCount: 0,
};
if (proposedPlan && proposedPlan.length > 0 && !tasks) {
return {
totalTasksCount: proposedPlan.length,
completedTasksCount: 0,
};
}
if (!tasks) {
// No tasks passed, return 0s
return { totalTasksCount: 0, completedTasksCount: 0 };
if (!tasks || !tasks.tasks || tasks.tasks.length === 0) {
return defaultCounts;
}
const activeTaskIndex = tasks.activeTaskIndex;
const activeTask = tasks.tasks.find(
(task) => task.taskIndex === activeTaskIndex,
);
if (
!activeTask ||
!activeTask.planRevisions ||
activeTask.planRevisions.length === 0
) {
return defaultCounts;
}
const activeTaskList = tasks.tasks.find(
(t) => t.taskIndex === tasks.activeTaskIndex,
const activeRevisionIndex = activeTask.activeRevisionIndex;
const activeRevision = activeTask.planRevisions.find(
(revision) => revision.revisionIndex === activeRevisionIndex,
);
if (!activeTaskList) {
// Something is wrong here. Return 0
return { totalTasksCount: 0, completedTasksCount: 0 };
if (
!activeRevision ||
!activeRevision.plans ||
activeRevision.plans.length === 0
) {
return defaultCounts;
}
const activeTaskPlans = activeTaskList.planRevisions.find(
(p) => p.revisionIndex === activeTaskList.activeRevisionIndex,
);
if (!activeTaskPlans) {
// Something is wrong here. Return 0
return { totalTasksCount: 0, completedTasksCount: 0 };
}
const plans = activeRevision.plans;
const completedTasksCount = plans.filter((p) => p.completed)?.length || 0;
return {
totalTasksCount: activeTaskPlans.plans.length,
completedTasksCount: activeTaskPlans.plans.filter((p) => p.completed)
.length,
totalTasksCount: plans.length,
completedTasksCount,
};
};
@ -92,63 +117,10 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
const [threads, setThreads] = useState<ThreadWithTasks[]>([]);
const [threadsLoading, setThreadsLoading] = useState(false);
const [busyThreadIds, setBusyThreadIds] = useState<string[]>([]);
// Real-time thread updater for all properties (replaces polling)
const updateThreadFromStream = useCallback(
(threadId: string, streamValues: any) => {
if (!threadId || !streamValues) return;
setThreads((currentThreads) => {
const targetThread = currentThreads.find(
(t) => t.thread_id === threadId,
);
if (!targetThread) return currentThreads; // Thread not found, no update needed
const plan: TaskPlan | undefined = streamValues?.plan;
const proposedPlan: string[] = streamValues?.proposedPlan || [];
const targetRepository = streamValues?.targetRepository;
const messages = streamValues?.messages;
const { totalTasksCount, completedTasksCount } = getTaskCounts(
plan,
proposedPlan,
);
// Extract thread title from messages if available
const firstMessageContent = messages?.[0]?.content;
const threadTitle = firstMessageContent
? getMessageContentString(firstMessageContent)
: targetThread.threadTitle;
const newRepository =
targetRepository?.repo ||
targetRepository?.name ||
targetThread.repository ||
"Unknown Repository";
const newBranch =
targetRepository?.branch || targetThread.branch || "main";
return currentThreads.map((thread) => {
if (thread.thread_id === threadId) {
return {
...thread,
threadTitle,
repository: newRepository,
branch: newBranch,
completedTasksCount,
totalTasksCount,
tasks: plan,
proposedPlan,
};
}
return thread;
});
});
},
[],
);
const [isPending, startTransition] = useTransition();
const [recentlyUpdatedThreads, setRecentlyUpdatedThreads] = useState<
Set<string>
>(new Set());
const getThread = useCallback(
async (threadId: string): Promise<ThreadWithTasks | null> => {
@ -157,7 +129,17 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
try {
const thread = await client.threads.get(threadId);
return enhanceThreadWithTasks(thread);
// Get the comprehensive state data which contains the plan
let stateData: { values: GraphState } | null = null;
try {
stateData = await client.threads.getState(threadId);
} catch (stateError) {
console.error("Failed to get state data:", stateError);
}
return enhanceThreadWithTasks(thread, stateData);
} catch (error) {
console.error("Failed to fetch thread:", threadId, error);
return null;
@ -166,13 +148,20 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
[apiUrl, assistantId],
);
const enhanceThreadWithTasks = (thread: Thread): ThreadWithTasks => {
const threadValues = thread.values as any;
const plan: TaskPlan | undefined = threadValues?.plan;
const proposedPlan: string[] = threadValues?.proposedPlan || [];
const enhanceThreadWithTasks = (
thread: Thread,
stateData?: { values: GraphState } | null,
): ThreadWithTasks => {
const stateValues = stateData?.values;
const threadValues = thread.values as GraphState;
const targetRepository = threadValues?.targetRepository;
const messages = (threadValues as any)?.messages;
const plan: TaskPlan | undefined = stateValues?.plan || threadValues?.plan;
const proposedPlan: string[] =
stateValues?.proposedPlan || threadValues?.proposedPlan || [];
const targetRepository =
stateValues?.targetRepository || threadValues?.targetRepository;
const messages = stateValues?.messages || threadValues?.messages;
const firstMessageContent = messages?.[0]?.content;
const threadTitle = firstMessageContent
? getMessageContentString(firstMessageContent)
@ -186,10 +175,7 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
return {
...thread,
threadTitle,
repository:
targetRepository?.repo ||
targetRepository?.name ||
"Unknown Repository",
repository: targetRepository?.repo || "Unknown Repository",
branch: targetRepository?.branch || "main",
completedTasksCount,
totalTasksCount,
@ -205,7 +191,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
const client = createClient(apiUrl);
try {
// Simple thread search - try both metadata approaches
const searchParams = {
limit: 100,
metadata: getThreadSearchMetadata(assistantId),
@ -213,7 +198,6 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
let threadsResponse = await client.threads.search(searchParams);
// If no threads found, try alternative metadata
if (threadsResponse.length === 0) {
const altMetadata = assistantId.includes("-")
? { assistant_id: assistantId }
@ -224,18 +208,24 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
});
}
// Enhance threads with task data
const enhancedThreads: ThreadWithTasks[] = [];
for (const thread of threadsResponse) {
try {
const fullThread = await client.threads.get(thread.thread_id);
enhancedThreads.push(enhanceThreadWithTasks(fullThread));
let stateData: { values: GraphState } | null = null;
try {
stateData = await client.threads.getState(thread.thread_id);
} catch (stateError) {
console.error("Failed to get state data:", stateError);
}
enhancedThreads.push(enhanceThreadWithTasks(fullThread, stateData));
} catch (error) {
console.error(`Failed to enhance thread ${thread.thread_id}:`, error);
}
}
// Sort by creation date (newest first)
enhancedThreads.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
@ -249,13 +239,49 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
}
}, [apiUrl, assistantId]);
// Removed polling - now using real-time stream updates via updateThreadFromStream
// Initial load
useEffect(() => {
refreshThreads();
}, [refreshThreads]);
const handlePollingUpdate = useCallback(
(updatedThreads: ThreadWithTasks[], changedThreadIds: string[]) => {
setThreads((currentThreads) => {
const updatedMap = new Map(updatedThreads.map((t) => [t.thread_id, t]));
return currentThreads.map(
(thread) => updatedMap.get(thread.thread_id) || thread,
);
});
setRecentlyUpdatedThreads(new Set(changedThreadIds));
setTimeout(() => {
setRecentlyUpdatedThreads(new Set());
}, 2000);
},
[],
);
// Initialize polling
useThreadPolling({
threads,
getThread,
onUpdate: handlePollingUpdate,
enabled: true,
});
const handleThreadClick = useCallback(
(
thread: ThreadWithTasks,
currentThreadId: string | null,
setThreadId: (id: string) => void,
) => {
if (currentThreadId === thread.thread_id) return;
setThreadId(thread.thread_id);
},
[],
);
const value = {
threads,
setThreads,
@ -263,7 +289,9 @@ export function ThreadProvider({ children }: { children: ReactNode }) {
setThreadsLoading,
refreshThreads,
getThread,
updateThreadFromStream,
isPending,
recentlyUpdatedThreads,
handleThreadClick,
};
return (