From 2790d881862e696b0a84aca4196ed00b389cf999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 07:30:13 -0400 Subject: [PATCH] Q183: unsaved input is kept across pages, and leaving or closing asks before losing it Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/index.ts | 1 + src/main/app/ipc-handlers.ts | 5 ++ src/main/app/unsaved-guard.ts | 29 +++++++ src/main/index.ts | 10 +++ src/preload/index.ts | 1 + src/renderer/src/app/App.tsx | 34 ++++++-- src/renderer/src/app/leave-guard.ts | 8 ++ src/renderer/src/i18n/zh.ts | 7 ++ src/renderer/src/pages/ChatPage.tsx | 9 +- src/renderer/src/pages/SettingsPage.tsx | 4 +- src/renderer/src/pages/chat/ChatComposer.tsx | 4 +- .../src/pages/chat/ChatConversation.tsx | 7 +- .../src/pages/chat/ChatMessageRow.tsx | 8 +- src/renderer/src/pages/chat/ThreadPanel.tsx | 6 +- src/renderer/src/pages/chat/draft-store.ts | 34 ++++++++ .../src/pages/settings/GoalAuditPanel.tsx | 2 + .../pages/settings/ProviderPolicyTable.tsx | 2 + .../src/pages/settings/ProvidersPanel.tsx | 3 + .../src/pages/work/TaskDetailDialog.tsx | 27 ++++-- src/renderer/src/shared/Dialog.tsx | 44 ++++++---- src/renderer/src/shared/unsaved.ts | 38 ++++++++ src/shared/desktop-api.ts | 2 + src/shared/ipc.ts | 1 + tests/e2e/ceo-chat.spec.ts | 3 +- tests/e2e/desktop.spec.ts | 6 ++ tests/e2e/onboard.ts | 10 ++- tests/e2e/unsaved-guard.spec.ts | 87 +++++++++++++++++++ 27 files changed, 351 insertions(+), 41 deletions(-) create mode 100644 src/main/app/unsaved-guard.ts create mode 100644 src/renderer/src/app/leave-guard.ts create mode 100644 src/renderer/src/pages/chat/draft-store.ts create mode 100644 src/renderer/src/shared/unsaved.ts create mode 100644 tests/e2e/unsaved-guard.spec.ts diff --git a/src/main/app/ipc-contracts/index.ts b/src/main/app/ipc-contracts/index.ts index f95820d4..7b31c57b 100644 --- a/src/main/app/ipc-contracts/index.ts +++ b/src/main/app/ipc-contracts/index.ts @@ -14,6 +14,7 @@ const noInput = z.undefined(); export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType> } = { loadSnapshot: noInput, relaunchApp: noInput, + reportUnsaved: z.strictObject({ unsaved: z.boolean() }), runningSessionCount: noInput, pickDirectory: noInput, listConnectorCatalog: noInput, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index e6f0e513..4ab9c153 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -42,6 +42,7 @@ import { runHistory } from "../services/run-history"; import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../services/workspace-admin"; import { setProviderModel } from "../services/provider-default-model"; import { completeSetup, saveOnboardingDraft } from "../services/onboarding-progress"; +import { setUnsavedInput } from "./unsaved-guard"; import { providerDisconnectImpact } from "../services/provider-disconnect-impact"; export interface IpcDeps { @@ -151,6 +152,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); handle("completeSetup", (input) => mutate(() => completeSetup(deps.connection.db, input.orgId))); + handle("reportUnsaved", (input) => { + setUnsavedInput(input.unsaved); + return ok(null); + }); handle("runningSessionCount", () => ok((deps.connection.db.prepare("select count(*) as n from task_runs where state = 'running'").get() as { n: number }).n)); // Chromium's locale is fixed at launch; the reply goes out before the process exits. handle("relaunchApp", () => { diff --git a/src/main/app/unsaved-guard.ts b/src/main/app/unsaved-guard.ts new file mode 100644 index 00000000..a931373c --- /dev/null +++ b/src/main/app/unsaved-guard.ts @@ -0,0 +1,29 @@ +import { dialog, type BrowserWindow } from "electron"; + +let unsaved = false; + +/** The renderer reports whether any typed input would be lost; closing asks first while it is. */ +export function setUnsavedInput(value: boolean): void { + unsaved = value; +} + +/** True when closing may go ahead: nothing is unsaved, or the user chose to discard it. */ +export function confirmDiscardOnClose(win: BrowserWindow, language: "en" | "zh"): boolean { + if (!unsaved) return true; + const zh = language === "zh"; + const choice = dialog.showMessageBoxSync(win, { + type: "warning", + buttons: [zh ? "放弃并关闭" : "Discard and close", zh ? "留下" : "Stay"], + defaultId: 1, + cancelId: 1, + message: zh ? "有尚未保存或发送的内容。" : "Some of what you typed is not saved or sent.", + detail: zh ? "关闭应用会丢失这些内容。" : "Closing the app discards it.", + }); + if (choice !== 0) { + console.info("[app] close cancelled: unsaved input kept"); + return false; + } + unsaved = false; + console.info("[app] closing with unsaved input discarded"); + return true; +} diff --git a/src/main/index.ts b/src/main/index.ts index 7dca83f4..9cfa8b84 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { SkillService } from "./services/skill-service"; import { startProviderBackgroundWorker } from "./app/provider-background-worker"; import { recoverInterruptedWork } from "./app/startup-recovery"; import { startTaskQueueWorker } from "./app/task-queue-worker"; +import { confirmDiscardOnClose } from "./app/unsaved-guard"; import { stopAllRunsAndWait } from "./services/run-control"; const QUIT_GRACE_MS = 10_000; @@ -66,8 +67,17 @@ if (!app.requestSingleInstanceLock()) { }); let shutdown: "running" | "draining" | "drained" = "running"; + const uiLanguage = language === "zh" ? "zh" : "en"; + // Asked before anything shuts down, so choosing to stay leaves the app fully running. + win.on("close", (event) => { + if (shutdown === "running" && !confirmDiscardOnClose(win, uiLanguage)) event.preventDefault(); + }); app.on("before-quit", (event) => { if (shutdown === "drained") return; + if (shutdown === "running" && !confirmDiscardOnClose(win, uiLanguage)) { + event.preventDefault(); + return; + } // Hold every exit request until running sessions are stopped and have recorded how they ended. event.preventDefault(); if (shutdown === "draining") return; diff --git a/src/preload/index.ts b/src/preload/index.ts index 3afa7697..b43f3e2f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ function invoke(channel: string, payload?: unknown): Promise { const api: DesktopApi = { loadSnapshot: () => invoke(IPC_CHANNELS.loadSnapshot), relaunchApp: () => invoke(IPC_CHANNELS.relaunchApp), + reportUnsaved: (input) => invoke(IPC_CHANNELS.reportUnsaved, input), saveOnboardingDraft: (input) => invoke(IPC_CHANNELS.saveOnboardingDraft, input), completeSetup: (input) => invoke(IPC_CHANNELS.completeSetup, input), runningSessionCount: () => invoke(IPC_CHANNELS.runningSessionCount), diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 4b5f4af2..e9dabc63 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -1,3 +1,7 @@ +import { LeaveGuardContext } from "./leave-guard"; +import { t } from "../i18n/i18n"; +import { Button } from "../shared/ui"; +import { hasPageUnsaved } from "../shared/unsaved"; import { ErrorBoundary } from "../shared/ErrorBoundary"; import { StartupScreen } from "./StartupScreen"; import type { Focus } from "../pages/types"; @@ -22,11 +26,17 @@ export function App() { const { snapshot, syncFailure, actions } = useSnapshot(); const [route, setRoute] = useState("home"); const [focus, setFocus] = useState(null); + // Leaving a page with input that would be lost asks first. + const [leaving, setLeaving] = useState<{ proceed: () => void } | null>(null); + const requestLeave = (proceed: () => void) => { + if (hasPageUnsaved()) setLeaving({ proceed }); + else proceed(); + }; // Opening a page for an object remembers it; plain navigation forgets it. - const navigate = (next: RouteId, nextFocus?: Focus) => { + const navigate = (next: RouteId, nextFocus?: Focus) => requestLeave(() => { setRoute(next); setFocus(nextFocus ?? null); - }; + }); const avatarDirectory = useMemo(() => { // Scope the directory to the active organization: names are only unique // within one org, and a same-named principal in another org must not @@ -98,16 +108,30 @@ export function App() { return ( + navigate(next)} - onSwitchOrg={(orgId) => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId }))} - onDeleteOrg={(orgId) => void actions.run(() => window.agentCompany.deleteOrganization({ orgId }))} - onNewOrg={() => setCreatingOrg(true)} + onSwitchOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId })))} + onDeleteOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.deleteOrganization({ orgId })))} + onNewOrg={() => requestLeave(() => setCreatingOrg(true))} > {page} + + {leaving && ( +
+
+

{t("Leave this page?")}

+

{t("What you typed here is lost if you leave.")}

+
+ + +
+
+
+ )}
); } diff --git a/src/renderer/src/app/leave-guard.ts b/src/renderer/src/app/leave-guard.ts new file mode 100644 index 00000000..494b7a4b --- /dev/null +++ b/src/renderer/src/app/leave-guard.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from "react"; + +/** Runs `proceed` now, or after the user confirms leaving input that would be lost (any page-scoped unsaved input). */ +export const LeaveGuardContext = createContext<(proceed: () => void) => void>((proceed) => proceed()); + +export function useLeaveGuard(): (proceed: () => void) => void { + return useContext(LeaveGuardContext); +} diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 50a8555d..1573bbc8 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -696,6 +696,13 @@ export const zh: Record = { "Working language": "工作语言", "That did not work": "操作未成功", "Working…": "处理中…", + "Discard your changes?": "放弃你的修改?", + "Keep editing": "继续编辑", + "Discard": "放弃", + "Leave this page?": "离开此页面?", + "What you typed here is lost if you leave.": "离开后,这里输入的内容会丢失。", + "Leave": "离开", + "Stay": "留下", "This page could not be shown.": "此页面无法显示。", "AgentCompany hit an error it could not recover from here.": "AgentCompany 遇到了无法在此恢复的错误。", "The rest of the app still works; go to another page or try this one again.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index f70cc9f4..e7bbaec7 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -1,3 +1,5 @@ +import { useLeaveGuard } from "../app/leave-guard"; +import { setDrafts, useDrafts } from "./chat/draft-store"; import { useCallback, useMemo, useRef, useState, type FormEvent } from "react"; import { ChatSidebar } from "./chat/ChatSidebar"; import { ChatConversation } from "./chat/ChatConversation"; @@ -66,8 +68,9 @@ export function ChatPage({ snapshot, actions }: PageProps) { ); const [selectedSpaceId, setSelectedSpaceId] = useState(null); const [createOpen, setCreateOpen] = useState(false); - // Text and attachment are kept per conversation, so switching neither loses one nor carries the other along. - const [drafts, setDrafts] = useState>({}); + const requestLeave = useLeaveGuard(); + // Text and attachment are kept per conversation, outside the page, so switching conversations or pages loses neither. + const drafts = useDrafts(); const [attachErrors, setAttachErrors] = useState>({}); const updateDraft = useCallback((spaceId: string, change: Partial) => setDrafts((current) => ({ ...current, [spaceId]: { ...(current[spaceId] ?? EMPTY_DRAFT), ...change } })), []); @@ -226,7 +229,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { conversations={conversations} presence={snapshot.chatPresence} activeSpaceId={activeSpaceId} - onSelect={setSelectedSpaceId} + onSelect={(spaceId) => requestLeave(() => setSelectedSpaceId(spaceId))} onCreateSpace={() => setCreateOpen(true)} /> diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 1be7dae7..300883a4 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -1,3 +1,4 @@ +import { useLeaveGuard } from "../app/leave-guard"; import { useMemo, useState } from "react"; import { ProvidersPanel } from "./settings/ProvidersPanel"; import { WorkspacePanel } from "./settings/WorkspacePanel"; @@ -26,6 +27,7 @@ export function SettingsPage({ snapshot, actions, focus }: PageProps) { const organization = requireActiveOrganization(snapshot); const employee = requireActiveEmployee(snapshot); const [tab, setTab] = useState("general"); + const requestLeave = useLeaveGuard(); const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]); @@ -41,7 +43,7 @@ export function SettingsPage({ snapshot, actions, focus }: PageProps) {
diff --git a/src/renderer/src/pages/chat/ChatComposer.tsx b/src/renderer/src/pages/chat/ChatComposer.tsx index 7c7bdab2..115c9b38 100644 --- a/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/src/renderer/src/pages/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { noteTyping } from "./draft-store"; import { shortcutLabel } from "../../i18n/labels"; import { useEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react"; import { Button, Icon, IconButton } from "../../shared/ui"; @@ -71,13 +72,14 @@ export function ChatComposer({ const before = node.value.slice(0, start); const after = node.value.slice(end); node.value = `${before}${text} ${after}`; + if (spaceId) noteTyping(spaceId, node.value); const caret = before.length + text.length + 1; node.setSelectionRange(caret, caret); node.focus(); }; return ( -
+ { if (spaceId && ref.current) noteTyping(spaceId, ref.current.value); }} data-space-id={spaceId ?? undefined} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
(null); + // A reply typed in one thread is lost by opening another or closing the panel. + const requestLeave = useLeaveGuard(); // The snapshot holds recent chat only; older messages come from the database, paged back from the oldest one loaded. const [oldestId, setOldestId] = useState(null); const [exhausted, setExhausted] = useState(false); @@ -222,7 +225,7 @@ export function ChatConversation({ onLoadOlder={loadOlder} threadLinks={threadLinks} replyStatuses={allReplyStatuses} - onOpenThread={setOpenThreadId} + onOpenThread={(threadId) => requestLeave(() => setOpenThreadId(threadId))} onApprove={onApprove} onDecline={onDecline} onEditMessage={onEditMessage} @@ -263,7 +266,7 @@ export function ChatConversation({ files={files} context={context} onReply={(body) => onReplyInThread(openThreadId, body)} - onClose={() => setOpenThreadId(null)} + onClose={() => requestLeave(() => setOpenThreadId(null))} onOpenFile={onOpenFile} /> )} diff --git a/src/renderer/src/pages/chat/ChatMessageRow.tsx b/src/renderer/src/pages/chat/ChatMessageRow.tsx index 3d115ce6..cacf043c 100644 --- a/src/renderer/src/pages/chat/ChatMessageRow.tsx +++ b/src/renderer/src/pages/chat/ChatMessageRow.tsx @@ -1,3 +1,4 @@ +import { useUnsaved } from "../../shared/unsaved"; import { fileKindLabel } from "../../i18n/labels"; import { useState, type FormEvent, type ReactNode } from "react"; import { Avatar, Button, Icon, IconButton } from "../../shared/ui"; @@ -48,6 +49,8 @@ export function ChatMessageRow({ onOpenFile?: (fileId: string) => void; }) { const [editing, setEditing] = useState(false); + const [changed, setChanged] = useState(false); + useUnsaved(editing && changed); const [saving, setSaving] = useState(false); const [editError, setEditError] = useState(null); const name = senderName(message, context); @@ -68,6 +71,7 @@ export function ChatMessageRow({ onEdit(value).then(() => { setSaving(false); setEditing(false); + setChanged(false); }).catch((reason: unknown) => { const message = reason instanceof Error ? reason.message : String(reason); console.error(`[chat] edit failed: ${message}`); @@ -111,7 +115,7 @@ export function ChatMessageRow({ {deleted ? (
{t("Message deleted")}
) : editing ? ( - + setChanged(true)}>