-
+
{t("sign_in")}
diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx
index a6926dc5c..1ec179b35 100644
--- a/surfsense_web/app/(home)/register/page.tsx
+++ b/surfsense_web/app/(home)/register/page.tsx
@@ -160,7 +160,7 @@ export default function RegisterPage() {
-
+
{t("create_account")}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
index f4db77125..bc17949f5 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx
@@ -267,12 +267,23 @@ export function DocumentsTableShell({
const [metadataJson, setMetadataJson] = useState
| null>(null);
const [metadataLoading, setMetadataLoading] = useState(false);
const [previewScrollPos, setPreviewScrollPos] = useState<"top" | "middle" | "bottom">("top");
+ const previewRafRef = useRef();
const handlePreviewScroll = useCallback((e: React.UIEvent) => {
const el = e.currentTarget;
- const atTop = el.scrollTop <= 2;
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
- setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ if (previewRafRef.current) return;
+ previewRafRef.current = requestAnimationFrame(() => {
+ const atTop = el.scrollTop <= 2;
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
+ setPreviewScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
+ previewRafRef.current = undefined;
+ });
}, []);
+ useEffect(
+ () => () => {
+ if (previewRafRef.current) cancelAnimationFrame(previewRafRef.current);
+ },
+ []
+ );
const [deleteDoc, setDeleteDoc] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx
new file mode 100644
index 000000000..1522e153f
--- /dev/null
+++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Spinner } from "@/components/ui/spinner";
+
+export function DesktopContent() {
+ const [isElectron, setIsElectron] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [enabled, setEnabled] = useState(true);
+
+ useEffect(() => {
+ if (!window.electronAPI) {
+ setLoading(false);
+ return;
+ }
+ setIsElectron(true);
+
+ window.electronAPI.getAutocompleteEnabled().then((val) => {
+ setEnabled(val);
+ setLoading(false);
+ });
+ }, []);
+
+ if (!isElectron) {
+ return (
+
+
+ Desktop settings are only available in the SurfSense desktop app.
+
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ const handleToggle = async (checked: boolean) => {
+ setEnabled(checked);
+ await window.electronAPI!.setAutocompleteEnabled(checked);
+ };
+
+ return (
+
+
+
+ Autocomplete
+
+ Get inline writing suggestions powered by your knowledge base as you type in any app.
+
+
+
+
+
+
+
+ Show suggestions while typing in other applications.
+
+
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/desktop/permissions/page.tsx b/surfsense_web/app/desktop/permissions/page.tsx
new file mode 100644
index 000000000..6c08e35b5
--- /dev/null
+++ b/surfsense_web/app/desktop/permissions/page.tsx
@@ -0,0 +1,215 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Logo } from "@/components/Logo";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+
+type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
+
+interface PermissionsStatus {
+ accessibility: PermissionStatus;
+ screenRecording: PermissionStatus;
+}
+
+const STEPS = [
+ {
+ id: "screen-recording",
+ title: "Screen Recording",
+ description: "Lets SurfSense capture your screen to understand context and provide smart writing suggestions.",
+ action: "requestScreenRecording",
+ field: "screenRecording" as const,
+ },
+ {
+ id: "accessibility",
+ title: "Accessibility",
+ description: "Lets SurfSense insert suggestions seamlessly, right where you\u2019re typing.",
+ action: "requestAccessibility",
+ field: "accessibility" as const,
+ },
+];
+
+function StatusBadge({ status }: { status: PermissionStatus }) {
+ if (status === "authorized") {
+ return (
+
+
+ Granted
+
+ );
+ }
+ if (status === "denied") {
+ return (
+
+
+ Denied
+
+ );
+ }
+ return (
+
+
+ Pending
+
+ );
+}
+
+export default function DesktopPermissionsPage() {
+ const router = useRouter();
+ const [permissions, setPermissions] = useState(null);
+ const [isElectron, setIsElectron] = useState(false);
+
+ useEffect(() => {
+ if (!window.electronAPI) return;
+ setIsElectron(true);
+
+ let interval: ReturnType | null = null;
+
+ const isResolved = (s: string) => s === "authorized" || s === "restricted";
+
+ const poll = async () => {
+ const status = await window.electronAPI!.getPermissionsStatus();
+ setPermissions(status);
+
+ if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
+ if (interval) clearInterval(interval);
+ }
+ };
+
+ poll();
+ interval = setInterval(poll, 2000);
+ return () => { if (interval) clearInterval(interval); };
+ }, []);
+
+ if (!isElectron) {
+ return (
+
+
This page is only available in the desktop app.
+
+ );
+ }
+
+ if (!permissions) {
+ return (
+
+
+
+ );
+ }
+
+ const allGranted = permissions.accessibility === "authorized" && permissions.screenRecording === "authorized";
+
+ const handleRequest = async (action: string) => {
+ if (action === "requestScreenRecording") {
+ await window.electronAPI!.requestScreenRecording();
+ } else if (action === "requestAccessibility") {
+ await window.electronAPI!.requestAccessibility();
+ }
+ };
+
+ const handleContinue = () => {
+ if (allGranted) {
+ window.electronAPI!.restartApp();
+ }
+ };
+
+ const handleSkip = () => {
+ router.push("/dashboard");
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
System Permissions
+
+ SurfSense needs two macOS permissions to provide context-aware writing suggestions.
+
+
+
+
+ {/* Steps */}
+
+ {STEPS.map((step, index) => {
+ const status = permissions[step.field];
+ const isGranted = status === "authorized";
+
+ return (
+
+
+
+
+ {isGranted ? "\u2713" : index + 1}
+
+
+
{step.title}
+
{step.description}
+
+
+
+
+ {!isGranted && (
+
+
+ {status === "denied" && (
+
+ Toggle SurfSense on in System Settings to continue.
+
+ )}
+
+ If SurfSense doesn't appear in the list, click + and select it from Applications.
+
+
+ )}
+
+ );
+ })}
+
+
+ {/* Footer */}
+
+ {allGranted ? (
+ <>
+
+
+ A restart is needed for permissions to take effect.
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/surfsense_web/app/desktop/suggestion/layout.tsx b/surfsense_web/app/desktop/suggestion/layout.tsx
new file mode 100644
index 000000000..36b7e037b
--- /dev/null
+++ b/surfsense_web/app/desktop/suggestion/layout.tsx
@@ -0,0 +1,13 @@
+import "./suggestion.css";
+
+export const metadata = {
+ title: "SurfSense Suggestion",
+};
+
+export default function SuggestionLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return {children}
;
+}
diff --git a/surfsense_web/app/desktop/suggestion/page.tsx b/surfsense_web/app/desktop/suggestion/page.tsx
new file mode 100644
index 000000000..03944867f
--- /dev/null
+++ b/surfsense_web/app/desktop/suggestion/page.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getBearerToken } from "@/lib/auth-utils";
+
+type SSEEvent =
+ | { type: "text-delta"; id: string; delta: string }
+ | { type: "text-start"; id: string }
+ | { type: "text-end"; id: string }
+ | { type: "start"; messageId: string }
+ | { type: "finish" }
+ | { type: "error"; errorText: string };
+
+function friendlyError(raw: string | number): string {
+ if (typeof raw === "number") {
+ if (raw === 401) return "Please sign in to use suggestions.";
+ if (raw === 403) return "You don\u2019t have permission for this.";
+ if (raw === 404) return "Suggestion service not found. Is the backend running?";
+ if (raw >= 500) return "Something went wrong on the server. Try again.";
+ return "Something went wrong. Try again.";
+ }
+ const lower = raw.toLowerCase();
+ if (lower.includes("not authenticated") || lower.includes("unauthorized"))
+ return "Please sign in to use suggestions.";
+ if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
+ return "No Vision LLM configured. Set one in search space settings.";
+ if (lower.includes("does not support vision"))
+ return "Selected model doesn\u2019t support vision. Set a vision-capable model in settings.";
+ if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
+ return "Can\u2019t reach the server. Check your connection.";
+ return "Something went wrong. Try again.";
+}
+
+const AUTO_DISMISS_MS = 3000;
+
+export default function SuggestionPage() {
+ const [suggestion, setSuggestion] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [isDesktop, setIsDesktop] = useState(true);
+ const [error, setError] = useState(null);
+ const abortRef = useRef(null);
+
+ useEffect(() => {
+ if (!window.electronAPI?.onAutocompleteContext) {
+ setIsDesktop(false);
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!error) return;
+ const timer = setTimeout(() => {
+ window.electronAPI?.dismissSuggestion?.();
+ }, AUTO_DISMISS_MS);
+ return () => clearTimeout(timer);
+ }, [error]);
+
+ const fetchSuggestion = useCallback(
+ async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ setIsLoading(true);
+ setSuggestion("");
+ setError(null);
+
+ const token = getBearerToken();
+ if (!token) {
+ setError(friendlyError("not authenticated"));
+ setIsLoading(false);
+ return;
+ }
+
+ const backendUrl =
+ process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
+
+ try {
+ const response = await fetch(
+ `${backendUrl}/api/v1/autocomplete/vision/stream`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ screenshot,
+ search_space_id: parseInt(searchSpaceId, 10),
+ app_name: appName || "",
+ window_title: windowTitle || "",
+ }),
+ signal: controller.signal,
+ },
+ );
+
+ if (!response.ok) {
+ setError(friendlyError(response.status));
+ setIsLoading(false);
+ return;
+ }
+
+ if (!response.body) {
+ setError(friendlyError("network error"));
+ setIsLoading(false);
+ return;
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const events = buffer.split(/\r?\n\r?\n/);
+ buffer = events.pop() || "";
+
+ for (const event of events) {
+ const lines = event.split(/\r?\n/);
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue;
+ const data = line.slice(6).trim();
+ if (!data || data === "[DONE]") continue;
+
+ try {
+ const parsed: SSEEvent = JSON.parse(data);
+ if (parsed.type === "text-delta") {
+ setSuggestion((prev) => prev + parsed.delta);
+ } else if (parsed.type === "error") {
+ setError(friendlyError(parsed.errorText));
+ }
+ } catch {
+ continue;
+ }
+ }
+ }
+ }
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") return;
+ setError(friendlyError("network error"));
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (!window.electronAPI?.onAutocompleteContext) return;
+
+ const cleanup = window.electronAPI.onAutocompleteContext((data) => {
+ const searchSpaceId = data.searchSpaceId || "1";
+ if (data.screenshot) {
+ fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
+ }
+ });
+
+ return cleanup;
+ }, [fetchSuggestion]);
+
+ if (!isDesktop) {
+ return (
+
+
+ This page is only available in the SurfSense desktop app.
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (isLoading && !suggestion) {
+ return (
+
+ );
+ }
+
+ const handleAccept = () => {
+ if (suggestion) {
+ window.electronAPI?.acceptSuggestion?.(suggestion);
+ }
+ };
+
+ const handleDismiss = () => {
+ window.electronAPI?.dismissSuggestion?.();
+ };
+
+ if (!suggestion) return null;
+
+ return (
+
+
{suggestion}
+
+
+
+
+
+ );
+}
diff --git a/surfsense_web/app/desktop/suggestion/suggestion.css b/surfsense_web/app/desktop/suggestion/suggestion.css
new file mode 100644
index 000000000..62f4d2ea7
--- /dev/null
+++ b/surfsense_web/app/desktop/suggestion/suggestion.css
@@ -0,0 +1,121 @@
+html:has(.suggestion-body),
+body:has(.suggestion-body) {
+ margin: 0 !important;
+ padding: 0 !important;
+ background: transparent !important;
+ overflow: hidden !important;
+ height: auto !important;
+ width: 100% !important;
+}
+
+.suggestion-body {
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ user-select: none;
+ -webkit-app-region: no-drag;
+}
+
+.suggestion-tooltip {
+ background: #1e1e1e;
+ border: 1px solid #3c3c3c;
+ border-radius: 8px;
+ padding: 8px 12px;
+ margin: 4px;
+ max-width: 400px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
+}
+
+.suggestion-text {
+ color: #d4d4d4;
+ font-size: 13px;
+ line-height: 1.45;
+ margin: 0 0 6px 0;
+ word-wrap: break-word;
+ white-space: pre-wrap;
+}
+
+.suggestion-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 4px;
+ border-top: 1px solid #2a2a2a;
+ padding-top: 6px;
+}
+
+.suggestion-btn {
+ padding: 2px 8px;
+ border-radius: 3px;
+ border: 1px solid #3c3c3c;
+ font-family: inherit;
+ font-size: 10px;
+ font-weight: 500;
+ cursor: pointer;
+ line-height: 16px;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.suggestion-btn-accept {
+ background: #2563eb;
+ border-color: #3b82f6;
+ color: #fff;
+}
+
+.suggestion-btn-accept:hover {
+ background: #1d4ed8;
+}
+
+.suggestion-btn-dismiss {
+ background: #2a2a2a;
+ color: #999;
+}
+
+.suggestion-btn-dismiss:hover {
+ background: #333;
+ color: #ccc;
+}
+
+.suggestion-error {
+ border-color: #5c2626;
+}
+
+.suggestion-error-text {
+ color: #f48771;
+ font-size: 12px;
+}
+
+.suggestion-loading {
+ display: flex;
+ gap: 5px;
+ padding: 2px 0;
+ justify-content: center;
+}
+
+.suggestion-dot {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: #666;
+ animation: suggestion-pulse 1.2s infinite ease-in-out;
+}
+
+.suggestion-dot:nth-child(2) {
+ animation-delay: 0.15s;
+}
+
+.suggestion-dot:nth-child(3) {
+ animation-delay: 0.3s;
+}
+
+@keyframes suggestion-pulse {
+ 0%, 80%, 100% {
+ opacity: 0.3;
+ transform: scale(0.8);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1.1);
+ }
+}
diff --git a/surfsense_web/components/Logo.tsx b/surfsense_web/components/Logo.tsx
index 121185757..0c1b2cce2 100644
--- a/surfsense_web/components/Logo.tsx
+++ b/surfsense_web/components/Logo.tsx
@@ -5,9 +5,11 @@ import { cn } from "@/lib/utils";
export const Logo = ({
className,
disableLink = false,
+ priority = false,
}: {
className?: string;
disableLink?: boolean;
+ priority?: boolean;
}) => {
const image = (
);
diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
index 99e26c542..268ab0f98 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/circleback-config.tsx
@@ -34,9 +34,12 @@ export const CirclebackConfig: FC = ({ connector, onNameC
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
+ // Fetch webhook info
// Fetch webhook info
useEffect(() => {
- const fetchWebhookInfo = async () => {
+ const controller = new AbortController();
+
+ const doFetch = async () => {
if (!connector.search_space_id) return;
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
@@ -49,8 +52,11 @@ export const CirclebackConfig: FC = ({ connector, onNameC
setIsLoading(true);
try {
const response = await authenticatedFetch(
- `${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
+ `${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`,
+ { signal: controller.signal }
);
+ if (controller.signal.aborted) return;
+
if (response.ok) {
const data: unknown = await response.json();
// Runtime validation with zod schema
@@ -59,16 +65,18 @@ export const CirclebackConfig: FC = ({ connector, onNameC
setWebhookUrl(validatedData.webhook_url);
}
} catch (error) {
+ if (controller.signal.aborted) return;
console.error("Failed to fetch webhook info:", error);
// Reset state on error
setWebhookInfo(null);
setWebhookUrl("");
} finally {
- setIsLoading(false);
+ if (!controller.signal.aborted) setIsLoading(false);
}
};
- fetchWebhookInfo();
+ doFetch().catch(() => {});
+ return () => controller.abort();
}, [connector.search_space_id]);
const handleNameChange = (value: string) => {
diff --git a/surfsense_web/components/assistant-ui/image.tsx b/surfsense_web/components/assistant-ui/image.tsx
index 65059bcdc..c147eede4 100644
--- a/surfsense_web/components/assistant-ui/image.tsx
+++ b/surfsense_web/components/assistant-ui/image.tsx
@@ -6,6 +6,7 @@ import { ImageIcon, ImageOffIcon } from "lucide-react";
import { memo, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
+import NextImage from 'next/image';
const imageVariants = cva("aui-image-root relative overflow-hidden rounded-lg", {
variants: {
@@ -86,23 +87,57 @@ function ImagePreview({
>
- ) : (
+ ) : isDataOrBlobUrl(src) ? (
+ // biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
+

{
+ if (typeof src === "string") setLoadedSrc(src);
+ onLoad?.(e);
+ }}
+ onError={(e) => {
+ if (typeof src === "string") setErrorSrc(src);
+ onError?.(e);
+ }}
+ {...props}
+ />
+ ) : (
// biome-ignore lint/performance/noImgElement: intentional for dynamic external URLs
-

{
- if (typeof src === "string") setLoadedSrc(src);
- onLoad?.(e);
- }}
- onError={(e) => {
- if (typeof src === "string") setErrorSrc(src);
- onError?.(e);
- }}
- {...props}
- />
+ //

{
+ // if (typeof src === "string") setLoadedSrc(src);
+ // onLoad?.(e);
+ // }}
+ // onError={(e) => {
+ // if (typeof src === "string") setErrorSrc(src);
+ // onError?.(e);
+ // }}
+ // {...props}
+ // />
+
{
+ if (typeof src === "string") setLoadedSrc(src);
+ onLoad?.();
+ }}
+ onError={() => {
+ if (typeof src === "string") setErrorSrc(src);
+ onError?.();
+ }}
+ unoptimized={false}
+ {...props}
+ />
)}
);
@@ -126,7 +161,10 @@ type ImageZoomProps = PropsWithChildren<{
src: string;
alt?: string;
}>;
-
+function isDataOrBlobUrl(src: string | undefined): boolean {
+ if (!src || typeof src !== "string") return false;
+ return src.startsWith("data:") || src.startsWith("blob:");
+}
function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
const [isMounted, setIsMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@@ -177,22 +215,39 @@ function ImageZoom({ src, alt = "Image preview", children }: ImageZoomProps) {
aria-label="Close zoomed image"
>
{/** biome-ignore lint/performance/noImgElement:
*/}
-
{
- e.stopPropagation();
- handleClose();
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.stopPropagation();
- handleClose();
- }
- }}
- />
+ {isDataOrBlobUrl(src) ? (
+ // biome-ignore lint/performance/noImgElement: data/blob URLs need plain img
+
{
+ e.stopPropagation();
+ handleClose();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.stopPropagation();
+ handleClose();
+ }
+ }}
+ />
+ ) : (
+ {
+ e.stopPropagation();
+ handleClose();
+ }}
+ unoptimized={false}
+ />
+ )}
,
document.body
)}
diff --git a/surfsense_web/components/assistant-ui/thread-list.tsx b/surfsense_web/components/assistant-ui/thread-list.tsx
index f1d10ca16..e8b8db6fe 100644
--- a/surfsense_web/components/assistant-ui/thread-list.tsx
+++ b/surfsense_web/components/assistant-ui/thread-list.tsx
@@ -9,7 +9,7 @@ import {
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
-import { memo, useCallback, useEffect, useState } from "react";
+import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -224,6 +224,11 @@ const ThreadListItemComponent = memo(function ThreadListItemComponent({
onUnarchive,
onDelete,
}: ThreadListItemComponentProps) {
+ const relativeTime = useMemo(
+ () => formatRelativeTime(new Date(thread.updatedAt)),
+ [thread.updatedAt]
+ );
+
return (