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 { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
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 { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -137,6 +138,7 @@ export default function NewChatPage() {
const params = useParams();
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
const [isCompletingClone, setIsCompletingClone] = useState(false);
const [threadId, setThreadId] = useState<number | null>(null);
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
@ -323,6 +325,34 @@ export default function NewChatPage() {
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)
const searchParams = useSearchParams();
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
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
if (!threadId && urlChatId > 0) {

View file

@ -22,22 +22,15 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
setIsCloning(true);
try {
await publicChatApiService.clonePublicChat({
const response = await publicChatApiService.clonePublicChat({
share_token: shareToken,
});
// Force PGlite to resync notifications on next dashboard load
localStorage.setItem("surfsense_force_notif_resync", "true");
toast.success("Copying chat to your account...", {
description: "You'll be notified when it's ready.",
});
router.push("/dashboard");
// Redirect to the new chat page (content will be loaded there)
router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to copy chat";
toast.error(message);
} finally {
setIsCloning(false);
}
}, [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({
share_token: z.string(),
});
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(),
task_id: z.string(),
message: z.string(),
message_count: z.number(),
});
// Type exports
@ -59,3 +71,5 @@ export type GetPublicChatRequest = z.infer<typeof getPublicChatRequest>;
export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>;
export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>;
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 {
type ClonePublicChatRequest,
type ClonePublicChatResponse,
type CompleteCloneRequest,
type CompleteCloneResponse,
clonePublicChatRequest,
clonePublicChatResponse,
completeCloneRequest,
completeCloneResponse,
type GetPublicChatRequest,
type GetPublicChatResponse,
getPublicChatRequest,
@ -29,6 +33,7 @@ class PublicChatApiService {
/**
* Clone a public chat to the user's account.
* Creates an empty thread and returns thread_id for redirect.
* Requires authentication.
*/
clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => {
@ -44,6 +49,25 @@ class PublicChatApiService {
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();

View file

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