From 7d6f0c621f6cf0024f9334df26185f13f1fa1aed Mon Sep 17 00:00:00 2001 From: Eva Ho Date: Fri, 12 Dec 2025 15:58:57 -0500 Subject: [PATCH] adding draft for each chat to remember unsent prompts --- app/package-lock.json | 6 +++ app/store/database.go | 50 ++++++++++++++++++++--- app/store/store.go | 9 +++++ app/ui/app/codegen/gotypes.gen.ts | 2 + app/ui/app/src/api.ts | 14 +++++++ app/ui/app/src/components/Chat.tsx | 1 + app/ui/app/src/components/ChatForm.tsx | 53 +++++++++++++++++++++++-- app/ui/app/src/components/Settings.tsx | 7 ++-- app/ui/app/src/hooks/useDraftMessage.ts | 34 ++++++++++++++++ app/ui/ui.go | 24 +++++++++++ 10 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 app/package-lock.json create mode 100644 app/ui/app/src/hooks/useDraftMessage.ts diff --git a/app/package-lock.json b/app/package-lock.json new file mode 100644 index 000000000..4ca926f5d --- /dev/null +++ b/app/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/app/store/database.go b/app/store/database.go index 0f268c6fa..91525abe5 100644 --- a/app/store/database.go +++ b/app/store/database.go @@ -14,7 +14,7 @@ import ( // currentSchemaVersion defines the current database schema version. // Increment this when making schema changes that require migrations. -const currentSchemaVersion = 12 +const currentSchemaVersion = 13 // database wraps the SQLite connection. // SQLite handles its own locking for concurrent access: @@ -95,7 +95,8 @@ func (db *database) init() error { id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - browser_state TEXT + browser_state TEXT, + draft TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS messages ( @@ -244,6 +245,12 @@ func (db *database) migrate() error { return fmt.Errorf("migrate v11 to v12: %w", err) } version = 12 + case 12: + // add draft column to chats table + if err := db.migrateV12ToV13(); err != nil { + return fmt.Errorf("migrate v12 to v13: %w", err) + } + version = 13 default: // If we have a version we don't recognize, just set it to current // This might happen during development @@ -452,6 +459,21 @@ func (db *database) migrateV11ToV12() error { return nil } +// migrateV12ToV13 adds the draft column to the chats table +func (db *database) migrateV12ToV13() error { + _, err := db.conn.Exec(`ALTER TABLE chats ADD COLUMN draft TEXT NOT NULL DEFAULT ''`) + if err != nil && !duplicateColumnError(err) { + return fmt.Errorf("add draft column: %w", err) + } + + _, err = db.conn.Exec(`UPDATE settings SET schema_version = 13`) + if err != nil { + return fmt.Errorf("update schema version: %w", err) + } + + return nil +} + // cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug func (db *database) cleanupOrphanedData() error { _, err := db.conn.Exec(` @@ -570,7 +592,7 @@ func (db *database) getAllChats() ([]Chat, error) { func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Chat, error) { query := ` - SELECT id, title, created_at, browser_state + SELECT id, title, created_at, browser_state, draft FROM chats WHERE id = ? ` @@ -578,12 +600,14 @@ func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Cha var chat Chat var createdAt time.Time var browserState sql.NullString + var draft sql.NullString err := db.conn.QueryRow(query, id).Scan( &chat.ID, &chat.Title, &createdAt, &browserState, + &draft, ) if err != nil { if err == sql.ErrNoRows { @@ -599,6 +623,9 @@ func (db *database) getChatWithOptions(id string, loadAttachmentData bool) (*Cha chat.BrowserState = raw } } + if draft.Valid { + chat.Draft = draft.String + } messages, err := db.getMessages(id, loadAttachmentData) if err != nil { @@ -622,11 +649,12 @@ func (db *database) saveChat(chat Chat) error { // UPSERT would overwrite browser_state with NULL, breaking revisit rendering that relies // on the last persisted full tool state. query := ` - INSERT INTO chats (id, title, created_at, browser_state) - VALUES (?, ?, ?, ?) + INSERT INTO chats (id, title, created_at, browser_state, draft) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, - browser_state = COALESCE(excluded.browser_state, chats.browser_state) + browser_state = COALESCE(excluded.browser_state, chats.browser_state), + draft = excluded.draft ` var browserState sql.NullString @@ -639,6 +667,7 @@ func (db *database) saveChat(chat Chat) error { chat.Title, chat.CreatedAt, browserState, + chat.Draft, ) if err != nil { return fmt.Errorf("save chat: %w", err) @@ -669,6 +698,15 @@ func (db *database) saveChat(chat Chat) error { return tx.Commit() } +// updateChatDraft updates only the draft for a chat +func (db *database) updateChatDraft(chatID string, draft string) error { + _, err := db.conn.Exec(`UPDATE chats SET draft = ? WHERE id = ?`, draft, chatID) + if err != nil { + return fmt.Errorf("update chat draft: %w", err) + } + return nil +} + // updateChatBrowserState updates only the browser_state for a chat func (db *database) updateChatBrowserState(chatID string, state json.RawMessage) error { _, err := db.conn.Exec(`UPDATE chats SET browser_state = ? WHERE id = ?`, string(state), chatID) diff --git a/app/store/store.go b/app/store/store.go index 052fcd617..feaa0a354 100644 --- a/app/store/store.go +++ b/app/store/store.go @@ -109,6 +109,7 @@ type Chat struct { Title string `json:"title"` CreatedAt time.Time `json:"created_at"` BrowserState json.RawMessage `json:"browser_state,omitempty" ts_type:"BrowserStateData"` + Draft string `json:"draft,omitempty"` } // NewChat creates a new Chat with the ID, with CreatedAt timestamp initialized @@ -451,6 +452,14 @@ func (s *Store) AppendMessage(chatID string, message Message) error { return s.db.appendMessage(chatID, message) } +func (s *Store) UpdateChatDraft(chatID string, draft string) error { + if err := s.ensureDB(); err != nil { + return err + } + + return s.db.updateChatDraft(chatID, draft) +} + func (s *Store) UpdateChatBrowserState(chatID string, state json.RawMessage) error { if err := s.ensureDB(); err != nil { return err diff --git a/app/ui/app/codegen/gotypes.gen.ts b/app/ui/app/codegen/gotypes.gen.ts index 0bf86f2b4..00393902a 100644 --- a/app/ui/app/codegen/gotypes.gen.ts +++ b/app/ui/app/codegen/gotypes.gen.ts @@ -159,6 +159,7 @@ export class Chat { title: string; created_at: Time; browser_state?: BrowserStateData; + draft?: string; constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); @@ -167,6 +168,7 @@ export class Chat { this.title = source["title"]; this.created_at = this.convertValues(source["created_at"], Time); this.browser_state = source["browser_state"]; + this.draft = source["draft"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index 273850d6b..00569d63c 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -299,6 +299,20 @@ export async function renameChat(chatId: string, title: string): Promise { } } +export async function updateChatDraft(chatId: string, draft: string): Promise { + const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}/draft`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ draft }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(error || "Failed to update draft"); + } +} + export async function deleteChat(chatId: string): Promise { const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, { method: "DELETE", diff --git a/app/ui/app/src/components/Chat.tsx b/app/ui/app/src/components/Chat.tsx index e0fcb4ff3..38f8d6d12 100644 --- a/app/ui/app/src/components/Chat.tsx +++ b/app/ui/app/src/components/Chat.tsx @@ -282,6 +282,7 @@ export default function Chat({ chatId }: { chatId: string }) { onSubmit={handleChatFormSubmit} chatId={chatId} autoFocus={true} + initialDraft={chatQuery?.data?.chat?.draft ?? ""} editingMessage={editingMessage} onCancelEdit={handleCancelEdit} isDisabled={isDisabled} diff --git a/app/ui/app/src/components/ChatForm.tsx b/app/ui/app/src/components/ChatForm.tsx index b13ebd802..3ce3a12f6 100644 --- a/app/ui/app/src/components/ChatForm.tsx +++ b/app/ui/app/src/components/ChatForm.tsx @@ -27,6 +27,7 @@ import { ErrorMessage } from "./ErrorMessage"; import { processFiles } from "@/utils/fileValidation"; import type { ImageData } from "@/types/webview"; import { PlusIcon } from "@heroicons/react/24/outline"; +import { useDraftMessage } from "@/hooks/useDraftMessage"; export type ThinkingLevel = "low" | "medium" | "high"; @@ -62,6 +63,7 @@ interface ChatFormProps { chatId?: string; isDownloadingModel?: boolean; isDisabled?: boolean; + initialDraft?: string; // Draft text to restore when switching chats // Editing props - when provided, ChatForm enters edit mode editingMessage?: { content: string; @@ -84,6 +86,7 @@ function ChatForm({ chatId = "new", isDownloadingModel = false, isDisabled = false, + initialDraft, editingMessage, onCancelEdit, onFilesReceived, @@ -118,6 +121,8 @@ function ChatForm({ null, ); + const { saveDraft, clearDraft } = useDraftMessage(chatId); + const handleThinkingLevelDropdownToggle = (isOpen: boolean) => { if ( isOpen && @@ -263,6 +268,8 @@ function ChatForm({ if (textareaRef.current) { textareaRef.current.style.height = "auto"; } + + clearDraft(); }; // Clear loginPromptFeature when user becomes authenticated or no features are enabled @@ -308,10 +315,39 @@ function ChatForm({ } }, [editingMessage]); - // Clear composition and reset textarea height when chatId changes useEffect(() => { - resetChatForm(); - }, [chatId]); + if (editingMessage) { + return; + } + + if (initialDraft && initialDraft.trim()) { + setMessage({ + content: initialDraft, + attachments: [], + fileErrors: [], + }); + + // Adjust textarea height after loading draft + setTimeout(() => { + if (textareaRef.current && initialDraft) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = + Math.min(textareaRef.current.scrollHeight, 24 * 8) + "px"; + } + }, 0); + } else { + resetChatForm(); + } + }, [chatId, initialDraft, editingMessage]); + + // Save draft only when navigating away or on blur + useEffect(() => { + return () => { + if (!editingMessage && message.content.trim()) { + saveDraft(message.content); + } + }; + }, [message.content, editingMessage, saveDraft]); // Auto-focus textarea when autoFocus is true or when streaming completes (but not when editing) useEffect(() => { @@ -511,12 +547,13 @@ function ChatForm({ }); } - // Clear composition after successful submission + // Clear composition and draft after successful submission setMessage({ content: "", attachments: [], fileErrors: [], }); + clearDraft(); // Reset textarea height and refocus after submit setTimeout(() => { @@ -621,6 +658,13 @@ function ChatForm({ e.target.style.height = Math.min(e.target.scrollHeight, 24 * 8) + "px"; }; + // Save draft when textarea loses focus + const handleTextareaBlur = () => { + if (!editingMessage && message.content.trim()) { + saveDraft(message.content); + } + }; + const handleFilesUpload = async () => { try { setFileUploadError(null); @@ -832,6 +876,7 @@ function ChatForm({ ref={textareaRef} value={message.content} onChange={handleTextareaChange} + onBlur={handleTextareaBlur} placeholder="Send a message" disabled={isDisabled} className={`allow-context-menu w-full overflow-y-auto text-neutral-700 outline-none resize-none border-none bg-transparent dark:text-white placeholder:text-neutral-400 dark:placeholder:text-neutral-500 min-h-[24px] leading-6 transition-opacity duration-300 ${ diff --git a/app/ui/app/src/components/Settings.tsx b/app/ui/app/src/components/Settings.tsx index 057f7477d..65c07ce6c 100644 --- a/app/ui/app/src/components/Settings.tsx +++ b/app/ui/app/src/components/Settings.tsx @@ -16,7 +16,6 @@ import { ArrowLeftIcon, } from "@heroicons/react/20/solid"; import { Settings as SettingsType } from "@/gotypes"; -import { useNavigate } from "@tanstack/react-router"; import { useUser } from "@/hooks/useUser"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getSettings, updateSettings } from "@/api"; @@ -52,7 +51,7 @@ export default function Settings() { const [isAwaitingConnection, setIsAwaitingConnection] = useState(false); const [connectionError, setConnectionError] = useState(null); const [pollingInterval, setPollingInterval] = useState(null); - const navigate = useNavigate(); + // const navigate = useNavigate(); const { data: settingsData, @@ -216,7 +215,7 @@ export default function Settings() { > {isWindows && (