feat(frontend): two-phase cloning with loading state

This commit is contained in:
CREDO23 2026-01-28 00:17:44 +02:00
parent 0c8d1f3fef
commit 9a4da10b12
5 changed files with 85 additions and 13 deletions

View file

@ -38,6 +38,7 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { useMessagesElectric } from "@/hooks/use-messages-electric";
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -137,6 +138,7 @@ export default function NewChatPage() {
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const [isCompletingClone, setIsCompletingClone] = useState(false);
const [threadId, setThreadId] = useState<number | null>(null); const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null); const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]); const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
@ -323,6 +325,34 @@ export default function NewChatPage() {
initializeThread(); initializeThread();
}, [initializeThread]); }, [initializeThread]);
// Handle clone completion when thread has clone_pending flag
useEffect(() => {
if (!currentThread?.clone_pending || isCompletingClone) return;
const completeClone = async () => {
setIsCompletingClone(true);
try {
await publicChatApiService.completeClone({ thread_id: currentThread.id });
// Re-initialize thread to fetch cloned content using existing logic
await initializeThread();
// Invalidate threads query to update sidebar
queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
} catch (error) {
console.error("[NewChatPage] Failed to complete clone:", error);
toast.error("Failed to copy chat content. Please try again.");
} finally {
setIsCompletingClone(false);
}
};
completeClone();
}, [currentThread?.clone_pending, currentThread?.id, isCompletingClone, initializeThread, queryClient]);
// Handle scroll to comment from URL query params (e.g., from inbox item click) // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId"); const targetCommentId = searchParams.get("commentId");
@ -1388,6 +1418,16 @@ export default function NewChatPage() {
); );
} }
// Show loading state while completing clone
if (isCompletingClone) {
return (
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<Spinner size="lg" />
<div className="text-sm text-muted-foreground">Copying chat content...</div>
</div>
);
}
// Show error state only if we tried to load an existing thread but failed // Show error state only if we tried to load an existing thread but failed
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation) // For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) { if (!threadId && urlChatId > 0) {

View file

@ -22,22 +22,15 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
setIsCloning(true); setIsCloning(true);
try { try {
await publicChatApiService.clonePublicChat({ const response = await publicChatApiService.clonePublicChat({
share_token: shareToken, share_token: shareToken,
}); });
// Force PGlite to resync notifications on next dashboard load // Redirect to the new chat page (content will be loaded there)
localStorage.setItem("surfsense_force_notif_resync", "true"); router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
toast.success("Copying chat to your account...", {
description: "You'll be notified when it's ready.",
});
router.push("/dashboard");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Failed to copy chat"; const message = error instanceof Error ? error.message : "Failed to copy chat";
toast.error(message); toast.error(message);
} finally {
setIsCloning(false); setIsCloning(false);
} }
}, [shareToken, router]); }, [shareToken, router]);

View file

@ -39,16 +39,28 @@ export const getPublicChatResponse = z.object({
}); });
/** /**
* Clone public chat * Clone public chat (init)
*/ */
export const clonePublicChatRequest = z.object({ export const clonePublicChatRequest = z.object({
share_token: z.string(), share_token: z.string(),
}); });
export const clonePublicChatResponse = z.object({ export const clonePublicChatResponse = z.object({
thread_id: z.number(),
search_space_id: z.number(),
share_token: z.string(),
});
/**
* Complete clone
*/
export const completeCloneRequest = z.object({
thread_id: z.number(),
});
export const completeCloneResponse = z.object({
status: z.string(), status: z.string(),
task_id: z.string(), message_count: z.number(),
message: z.string(),
}); });
// Type exports // Type exports
@ -59,3 +71,5 @@ export type GetPublicChatRequest = z.infer<typeof getPublicChatRequest>;
export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>; export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>;
export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>; export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>;
export type ClonePublicChatResponse = z.infer<typeof clonePublicChatResponse>; export type ClonePublicChatResponse = z.infer<typeof clonePublicChatResponse>;
export type CompleteCloneRequest = z.infer<typeof completeCloneRequest>;
export type CompleteCloneResponse = z.infer<typeof completeCloneResponse>;

View file

@ -1,8 +1,12 @@
import { import {
type ClonePublicChatRequest, type ClonePublicChatRequest,
type ClonePublicChatResponse, type ClonePublicChatResponse,
type CompleteCloneRequest,
type CompleteCloneResponse,
clonePublicChatRequest, clonePublicChatRequest,
clonePublicChatResponse, clonePublicChatResponse,
completeCloneRequest,
completeCloneResponse,
type GetPublicChatRequest, type GetPublicChatRequest,
type GetPublicChatResponse, type GetPublicChatResponse,
getPublicChatRequest, getPublicChatRequest,
@ -29,6 +33,7 @@ class PublicChatApiService {
/** /**
* Clone a public chat to the user's account. * Clone a public chat to the user's account.
* Creates an empty thread and returns thread_id for redirect.
* Requires authentication. * Requires authentication.
*/ */
clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => { clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => {
@ -44,6 +49,25 @@ class PublicChatApiService {
clonePublicChatResponse clonePublicChatResponse
); );
}; };
/**
* Complete the clone by copying messages and podcasts.
* Called from the chat page after redirect.
* Requires authentication.
*/
completeClone = async (request: CompleteCloneRequest): Promise<CompleteCloneResponse> => {
const parsed = completeCloneRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/threads/${parsed.data.thread_id}/complete-clone`,
completeCloneResponse
);
};
} }
export const publicChatApiService = new PublicChatApiService(); export const publicChatApiService = new PublicChatApiService();

View file

@ -26,6 +26,7 @@ export interface ThreadRecord {
has_comments?: boolean; has_comments?: boolean;
public_share_enabled?: boolean; public_share_enabled?: boolean;
public_share_token?: string | null; public_share_token?: string | null;
clone_pending?: boolean;
} }
export interface MessageRecord { export interface MessageRecord {