diff --git a/apps/web/src/components/v2/default-view.tsx b/apps/web/src/components/v2/default-view.tsx index d4021098..863afd7c 100644 --- a/apps/web/src/components/v2/default-view.tsx +++ b/apps/web/src/components/v2/default-view.tsx @@ -20,6 +20,7 @@ import { ThreadCard, ThreadCardLoading } from "./thread-card"; import { GitHubInstallationBanner } from "../github/installation-banner"; import { QuickActions } from "./quick-actions"; import { useState } from "react"; +import { DraftsSection } from "./drafts-section"; import { GitHubLogoutButton } from "../github/github-oauth-button"; import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; import { TooltipIconButton } from "../ui/tooltip-icon-button"; @@ -33,6 +34,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) { const router = useRouter(); const [quickActionPrompt, setQuickActionPrompt] = useState(""); const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? ""; + const [draftToLoad, setDraftToLoad] = useState(""); const assistantId: string | undefined = MANAGER_GRAPH_ID; const { contentBlocks, @@ -45,6 +47,10 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) { } = useFileUpload(); const [autoAccept, setAutoAccept] = useState(false); + const handleLoadDraft = (content: string) => { + setDraftToLoad(content); + }; + if (!apiUrl) { return
Missing API URL environment variable
; } @@ -108,6 +114,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) { onPaste={handlePaste} quickActionPrompt={quickActionPrompt} setQuickActionPrompt={setQuickActionPrompt} + draftToLoad={draftToLoad} autoAcceptPlan={autoAccept} setAutoAcceptPlan={setAutoAccept} /> @@ -188,6 +195,8 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) { )} + {/* TODO: Better multiple draft handling. Not actually used right now */} + diff --git a/apps/web/src/components/v2/drafts-section.tsx b/apps/web/src/components/v2/drafts-section.tsx new file mode 100644 index 00000000..4385bb47 --- /dev/null +++ b/apps/web/src/components/v2/drafts-section.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Button } from "../ui/button"; +import { Trash2, FileText, Archive } from "lucide-react"; +import { useDraftStorage } from "@/hooks/useDraftStorage"; + +interface Draft { + id: string; + content: string; + timestamp: number; +} + +interface DraftsSectionProps { + onLoadDraft: (content: string) => void; +} + +export function DraftsSection({ onLoadDraft }: DraftsSectionProps) { + const { getAllDrafts, deleteDraft } = useDraftStorage(); + const [drafts, setDrafts] = useState([]); + + // Load drafts on mount and refresh when needed + useEffect(() => { + const loadDrafts = () => { + const allDrafts = getAllDrafts(); + setDrafts(allDrafts); + }; + + loadDrafts(); + + // Set up interval to refresh drafts periodically + const interval = setInterval(loadDrafts, 1000); + + return () => clearInterval(interval); + }, [getAllDrafts]); + + const handleLoadDraft = (draft: Draft) => { + onLoadDraft(draft.content); + }; + + const handleDeleteDraft = (draftId: string, event: React.MouseEvent) => { + event.stopPropagation(); + deleteDraft(draftId); + // Refresh drafts list + const updatedDrafts = getAllDrafts(); + setDrafts(updatedDrafts); + }; + + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const diffInMinutes = Math.floor( + (now.getTime() - date.getTime()) / (1000 * 60), + ); + + if (diffInMinutes < 1) { + return "Just now"; + } else if (diffInMinutes < 60) { + return `${diffInMinutes}m ago`; + } else if (diffInMinutes < 1440) { + const hours = Math.floor(diffInMinutes / 60); + return `${hours}h ago`; + } else { + const days = Math.floor(diffInMinutes / 1440); + return `${days}d ago`; + } + }; + + const truncateContent = (content: string, maxLength: number = 100) => { + if (content.length <= maxLength) return content; + return content.substring(0, maxLength) + "..."; + }; + + if (drafts.length === 0) { + return null; + } + + return ( +
+
+

+ Saved Drafts +

+ + {drafts.length} draft{drafts.length !== 1 ? "s" : ""} + +
+ +
+ {drafts.map((draft) => ( + handleLoadDraft(draft)} + > + +
+
+ + {truncateContent(draft.content, 60)} + +
+ + Draft +
+
+
+
+ +
+ + {formatTimestamp(draft.timestamp)} + + +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/v2/terminal-input.tsx b/apps/web/src/components/v2/terminal-input.tsx index e2bb1246..f087c6c2 100644 --- a/apps/web/src/components/v2/terminal-input.tsx +++ b/apps/web/src/components/v2/terminal-input.tsx @@ -16,6 +16,7 @@ import { toast } from "sonner"; import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore"; import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants"; import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types"; +import { useDraftStorage } from "@/hooks/useDraftStorage"; interface TerminalInputProps { placeholder?: string; @@ -29,6 +30,7 @@ interface TerminalInputProps { setQuickActionPrompt?: Dispatch>; autoAcceptPlan: boolean; setAutoAcceptPlan: Dispatch>; + draftToLoad?: string; } export function TerminalInput({ @@ -43,9 +45,10 @@ export function TerminalInput({ setQuickActionPrompt, autoAcceptPlan, setAutoAcceptPlan, + draftToLoad, }: TerminalInputProps) { const { push } = useRouter(); - const [message, setMessage] = useState(""); + const { message, setMessage, clearCurrentDraft } = useDraftStorage(); const { getConfig } = useConfigStore(); const { selectedRepository } = useGitHubAppProvider(); const [loading, setLoading] = useState(false); @@ -104,6 +107,7 @@ export function TerminalInput({ // set session storage so the stream can be resumed after redirect. sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id); push(`/chat/${newThreadId}`); + clearCurrentDraft(); setMessage(""); setContentBlocks([]); setAutoAcceptPlan(false); @@ -130,6 +134,13 @@ export function TerminalInput({ } }, [quickActionPrompt]); + // Handle draft loading from external components + useEffect(() => { + if (draftToLoad) { + setMessage(draftToLoad); + } + }, [draftToLoad, setMessage]); + return (
diff --git a/apps/web/src/hooks/useDraftStorage.tsx b/apps/web/src/hooks/useDraftStorage.tsx new file mode 100644 index 00000000..718a023a --- /dev/null +++ b/apps/web/src/hooks/useDraftStorage.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { debounce } from "lodash"; + +const DRAFT_STORAGE_KEY = "terminal-input-draft"; +const DRAFTS_STORAGE_KEY = "terminal-input-drafts"; + +interface Draft { + id: string; + content: string; + timestamp: number; +} + +interface UseDraftStorageReturn { + message: string; + setMessage: (message: string) => void; + clearCurrentDraft: () => void; + saveDraft: (content: string) => void; + loadDraft: (draftId: string) => void; + deleteDraft: (draftId: string) => void; + getAllDrafts: () => Draft[]; +} + +const saveDraftToLocalStorage = (content: string) => { + if (typeof window === "undefined") return; + try { + if (content.trim()) { + window.localStorage.setItem(DRAFT_STORAGE_KEY, content); + } else { + window.localStorage.removeItem(DRAFT_STORAGE_KEY); + } + } catch (error) { + console.warn("Failed to save draft to localStorage:", error); + } +}; + +const getDraftFromLocalStorage = (): string => { + if (typeof window === "undefined") return ""; + try { + const stored = window.localStorage.getItem(DRAFT_STORAGE_KEY); + return stored || ""; + } catch (error) { + console.warn("Failed to retrieve draft from localStorage:", error); + return ""; + } +}; + +const saveDraftsToLocalStorage = (drafts: Draft[]) => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); + } catch (error) { + console.warn("Failed to save drafts to localStorage:", error); + } +}; + +const getDraftsFromLocalStorage = (): Draft[] => { + if (typeof window === "undefined") return []; + try { + const stored = window.localStorage.getItem(DRAFTS_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } + return []; + } catch (error) { + console.warn("Failed to retrieve drafts from localStorage:", error); + return []; + } +}; + +export const useDraftStorage = (): UseDraftStorageReturn => { + const [message, setMessageState] = useState(""); + const hasLoadedInitialDraft = useRef(false); + + // Debounced auto-save function + const debouncedSave = useCallback( + debounce((content: string) => { + saveDraftToLocalStorage(content); + }, 500), + [], + ); + + // Load initial draft on mount + useEffect(() => { + if (!hasLoadedInitialDraft.current) { + const savedDraft = getDraftFromLocalStorage(); + if (savedDraft) { + setMessageState(savedDraft); + } + hasLoadedInitialDraft.current = true; + } + }, []); + + const setMessage = useCallback( + (newMessage: string) => { + setMessageState(newMessage); + debouncedSave(newMessage); + }, + [debouncedSave], + ); + + const clearCurrentDraft = useCallback(() => { + if (typeof window === "undefined") return; + window.localStorage.removeItem(DRAFT_STORAGE_KEY); + }, []); + + const saveDraft = useCallback((content: string) => { + if (!content.trim()) return; + + const drafts = getDraftsFromLocalStorage(); + const newDraft: Draft = { + id: Date.now().toString(), + content: content.trim(), + timestamp: Date.now(), + }; + + const updatedDrafts = [newDraft, ...drafts.slice(0, 9)]; // Keep only 10 most recent drafts + saveDraftsToLocalStorage(updatedDrafts); + }, []); + + const loadDraft = useCallback( + (draftId: string) => { + const drafts = getDraftsFromLocalStorage(); + const draft = drafts.find((d) => d.id === draftId); + if (draft) { + setMessage(draft.content); + } + }, + [setMessage], + ); + + const deleteDraft = useCallback((draftId: string) => { + const drafts = getDraftsFromLocalStorage(); + const updatedDrafts = drafts.filter((d) => d.id !== draftId); + saveDraftsToLocalStorage(updatedDrafts); + }, []); + + const getAllDrafts = useCallback((): Draft[] => { + return getDraftsFromLocalStorage(); + }, []); + + return { + message, + setMessage, + clearCurrentDraft, + saveDraft, + loadDraft, + deleteDraft, + getAllDrafts, + }; +};