Compare commits

...

3 Commits

Author SHA1 Message Date
Eva Ho
42d6a3f075 proper clear draft message 2025-12-12 16:56:44 -05:00
Eva Ho
ed553f51f7 clean up 2025-12-12 16:11:03 -05:00
Eva Ho
7d6f0c621f adding draft for each chat to remember unsent prompts 2025-12-12 15:59:47 -05:00
13 changed files with 211 additions and 14 deletions

View File

@@ -305,6 +305,9 @@ func main() {
go func() {
<-signals
slog.Info("received SIGINT or SIGTERM signal, shutting down")
if err := st.ClearAllDrafts(); err != nil {
slog.Warn("failed to clear drafts on shutdown", "error", err)
}
quit()
}()

View File

@@ -182,6 +182,11 @@ func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
}
func quit() {
if wv.Store != nil {
if err := wv.Store.ClearAllDrafts(); err != nil {
slog.Warn("failed to clear drafts on quit", "error", err)
}
}
C.quit()
}

View File

@@ -111,6 +111,11 @@ func (*appCallbacks) UIRunning() bool {
}
func (app *appCallbacks) Quit() {
if wv.Store != nil {
if err := wv.Store.ClearAllDrafts(); err != nil {
slog.Warn("failed to clear drafts on quit", "error", err)
}
}
app.t.Quit()
wv.Terminate()
}

6
app/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "app",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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,23 @@ 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
}
func (db *database) clearAllDrafts() error {
_, err := db.conn.Exec(`UPDATE chats SET draft = ''`)
if err != nil {
return fmt.Errorf("clear all drafts: %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)

View File

@@ -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,22 @@ 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) ClearAllDrafts() error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.clearAllDrafts()
}
func (s *Store) UpdateChatBrowserState(chatID string, state json.RawMessage) error {
if err := s.ensureDB(); err != nil {
return err

View File

@@ -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 {

View File

@@ -299,6 +299,20 @@ export async function renameChat(chatId: string, title: string): Promise<void> {
}
}
export async function updateChatDraft(chatId: string, draft: string): Promise<void> {
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<void> {
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, {
method: "DELETE",

View File

@@ -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}

View File

@@ -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;
// 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 &&
@@ -308,10 +313,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 +545,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 +656,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 +874,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 ${

View File

@@ -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,6 @@ export default function Settings() {
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const navigate = useNavigate();
const {
data: settingsData,
@@ -216,7 +214,7 @@ export default function Settings() {
>
{isWindows && (
<button
onClick={() => navigate({ to: "/" })}
onClick={() => window.history.back()}
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
>
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
@@ -226,7 +224,7 @@ export default function Settings() {
</h1>
{!isWindows && (
<button
onClick={() => navigate({ to: "/" })}
onClick={() => window.history.back()}
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
>
<XMarkIcon className="w-6 h-6 dark:text-white" />

View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { updateChatDraft } from "@/api";
export function useDraftMessage(chatId: string) {
const saveDraft = useCallback(async (content: string) => {
try {
if (chatId === "new") {
return;
}
await updateChatDraft(chatId, content);
} catch (error) {
console.error("Error saving draft message:", error);
}
}, [chatId]);
const clearDraft = useCallback(async () => {
try {
if (chatId === "new") {
return;
}
await updateChatDraft(chatId, "");
} catch (error) {
console.error("Error clearing draft message:", error);
}
}, [chatId]);
return {
saveDraft,
clearDraft,
};
}

View File

@@ -253,6 +253,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /api/v1/chat/{id}", handle(s.deleteChat))
mux.Handle("POST /api/v1/create-chat", handle(s.createChat))
mux.Handle("PUT /api/v1/chat/{id}/rename", handle(s.renameChat))
mux.Handle("PUT /api/v1/chat/{id}/draft", handle(s.updateDraft))
mux.Handle("GET /api/v1/inference-compute", handle(s.getInferenceCompute))
mux.Handle("POST /api/v1/model/upstream", handle(s.modelUpstream))
@@ -1276,6 +1277,28 @@ func (s *Server) renameChat(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) updateDraft(w http.ResponseWriter, r *http.Request) error {
cid := r.PathValue("id")
if cid == "" {
return fmt.Errorf("chat ID is required")
}
var req struct {
Draft string `json:"draft"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
if err := s.Store.UpdateChatDraft(cid, req.Draft); err != nil {
return fmt.Errorf("failed to update draft: %w", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
return nil
}
func (s *Server) deleteChat(w http.ResponseWriter, r *http.Request) error {
cid := r.PathValue("id")
if cid == "" {