mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-01 23:13:15 +00:00
[open-swe] feat: Add request drafts functionality with auto-save and drafts UI (#351)
* Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * cr --------- Co-authored-by: open-swe-dev[bot] <open-swe-dev@users.noreply.github.com> Co-authored-by: Brace Sproul <braceasproul@gmail.com>
This commit is contained in:
parent
4c53b1a4e3
commit
0b5ef67527
4 changed files with 302 additions and 1 deletions
|
|
@ -20,6 +20,7 @@ import { ThreadCard, ThreadCardLoading } from "./thread-card";
|
||||||
import { GitHubInstallationBanner } from "../github/installation-banner";
|
import { GitHubInstallationBanner } from "../github/installation-banner";
|
||||||
import { QuickActions } from "./quick-actions";
|
import { QuickActions } from "./quick-actions";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { DraftsSection } from "./drafts-section";
|
||||||
import { GitHubLogoutButton } from "../github/github-oauth-button";
|
import { GitHubLogoutButton } from "../github/github-oauth-button";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
import { TooltipIconButton } from "../ui/tooltip-icon-button";
|
||||||
|
|
@ -33,6 +34,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [quickActionPrompt, setQuickActionPrompt] = useState("");
|
const [quickActionPrompt, setQuickActionPrompt] = useState("");
|
||||||
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
const [draftToLoad, setDraftToLoad] = useState("");
|
||||||
const assistantId: string | undefined = MANAGER_GRAPH_ID;
|
const assistantId: string | undefined = MANAGER_GRAPH_ID;
|
||||||
const {
|
const {
|
||||||
contentBlocks,
|
contentBlocks,
|
||||||
|
|
@ -45,6 +47,10 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
} = useFileUpload();
|
} = useFileUpload();
|
||||||
const [autoAccept, setAutoAccept] = useState(false);
|
const [autoAccept, setAutoAccept] = useState(false);
|
||||||
|
|
||||||
|
const handleLoadDraft = (content: string) => {
|
||||||
|
setDraftToLoad(content);
|
||||||
|
};
|
||||||
|
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
return <div>Missing API URL environment variable</div>;
|
return <div>Missing API URL environment variable</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +114,7 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
quickActionPrompt={quickActionPrompt}
|
quickActionPrompt={quickActionPrompt}
|
||||||
setQuickActionPrompt={setQuickActionPrompt}
|
setQuickActionPrompt={setQuickActionPrompt}
|
||||||
|
draftToLoad={draftToLoad}
|
||||||
autoAcceptPlan={autoAccept}
|
autoAcceptPlan={autoAccept}
|
||||||
setAutoAcceptPlan={setAutoAccept}
|
setAutoAcceptPlan={setAutoAccept}
|
||||||
/>
|
/>
|
||||||
|
|
@ -188,6 +195,8 @@ export function DefaultView({ threads, threadsLoading }: DefaultViewProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<QuickActions setQuickActionPrompt={setQuickActionPrompt} />
|
<QuickActions setQuickActionPrompt={setQuickActionPrompt} />
|
||||||
|
{/* TODO: Better multiple draft handling. Not actually used right now */}
|
||||||
|
<DraftsSection onLoadDraft={handleLoadDraft} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
130
apps/web/src/components/v2/drafts-section.tsx
Normal file
130
apps/web/src/components/v2/drafts-section.tsx
Normal file
|
|
@ -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<Draft[]>([]);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-foreground text-base font-semibold">
|
||||||
|
Saved Drafts
|
||||||
|
</h2>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{drafts.length} draft{drafts.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{drafts.map((draft) => (
|
||||||
|
<Card
|
||||||
|
key={draft.id}
|
||||||
|
className="border-border bg-card hover:bg-muted cursor-pointer px-0 py-3 transition-shadow hover:shadow-lg dark:bg-gray-950"
|
||||||
|
onClick={() => handleLoadDraft(draft)}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-foreground truncate text-sm font-medium">
|
||||||
|
{truncateContent(draft.content, 60)}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<FileText className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="text-muted-foreground text-xs">Draft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{formatTimestamp(draft.timestamp)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground h-5 w-5 p-0 hover:text-red-500"
|
||||||
|
onClick={(e) => handleDeleteDraft(draft.id, e)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { toast } from "sonner";
|
||||||
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
|
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
|
||||||
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
|
||||||
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
|
||||||
|
import { useDraftStorage } from "@/hooks/useDraftStorage";
|
||||||
|
|
||||||
interface TerminalInputProps {
|
interface TerminalInputProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -29,6 +30,7 @@ interface TerminalInputProps {
|
||||||
setQuickActionPrompt?: Dispatch<SetStateAction<string>>;
|
setQuickActionPrompt?: Dispatch<SetStateAction<string>>;
|
||||||
autoAcceptPlan: boolean;
|
autoAcceptPlan: boolean;
|
||||||
setAutoAcceptPlan: Dispatch<SetStateAction<boolean>>;
|
setAutoAcceptPlan: Dispatch<SetStateAction<boolean>>;
|
||||||
|
draftToLoad?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalInput({
|
export function TerminalInput({
|
||||||
|
|
@ -43,9 +45,10 @@ export function TerminalInput({
|
||||||
setQuickActionPrompt,
|
setQuickActionPrompt,
|
||||||
autoAcceptPlan,
|
autoAcceptPlan,
|
||||||
setAutoAcceptPlan,
|
setAutoAcceptPlan,
|
||||||
|
draftToLoad,
|
||||||
}: TerminalInputProps) {
|
}: TerminalInputProps) {
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
const [message, setMessage] = useState("");
|
const { message, setMessage, clearCurrentDraft } = useDraftStorage();
|
||||||
const { getConfig } = useConfigStore();
|
const { getConfig } = useConfigStore();
|
||||||
const { selectedRepository } = useGitHubAppProvider();
|
const { selectedRepository } = useGitHubAppProvider();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -104,6 +107,7 @@ export function TerminalInput({
|
||||||
// set session storage so the stream can be resumed after redirect.
|
// set session storage so the stream can be resumed after redirect.
|
||||||
sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id);
|
sessionStorage.setItem(`lg:stream:${newThreadId}`, run.run_id);
|
||||||
push(`/chat/${newThreadId}`);
|
push(`/chat/${newThreadId}`);
|
||||||
|
clearCurrentDraft();
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setContentBlocks([]);
|
setContentBlocks([]);
|
||||||
setAutoAcceptPlan(false);
|
setAutoAcceptPlan(false);
|
||||||
|
|
@ -130,6 +134,13 @@ export function TerminalInput({
|
||||||
}
|
}
|
||||||
}, [quickActionPrompt]);
|
}, [quickActionPrompt]);
|
||||||
|
|
||||||
|
// Handle draft loading from external components
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftToLoad) {
|
||||||
|
setMessage(draftToLoad);
|
||||||
|
}
|
||||||
|
}, [draftToLoad, setMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border bg-muted rounded-md border p-2 font-mono text-xs dark:bg-black">
|
<div className="border-border bg-muted rounded-md border p-2 font-mono text-xs dark:bg-black">
|
||||||
<div className="text-foreground flex items-center gap-1">
|
<div className="text-foreground flex items-center gap-1">
|
||||||
|
|
|
||||||
151
apps/web/src/hooks/useDraftStorage.tsx
Normal file
151
apps/web/src/hooks/useDraftStorage.tsx
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue