Q183: unsaved input is kept across pages, and leaving or closing asks before losing it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 07:30:13 -04:00
1 parent 3f8832702e
commit 2790d88186
27 files changed
+351 -41

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ const noInput = z.undefined();
export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType<InputOf<K>> } = {
loadSnapshot: noInput,
relaunchApp: noInput,
reportUnsaved: z.strictObject({ unsaved: z.boolean() }),
runningSessionCount: noInput,
pickDirectory: noInput,
listConnectorCatalog: noInput,
+5
View File
@@ -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", () => {
+29
View File
@@ -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;
}
+10
View File
@@ -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;
+1
View File
@@ -9,6 +9,7 @@ function invoke<T>(channel: string, payload?: unknown): Promise<T> {
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),
+29 -5
View File
@@ -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<RouteId>("home");
const [focus, setFocus] = useState<Focus | null>(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 (
<AvatarDirectoryContext.Provider value={avatarDirectory}>
<LeaveGuardContext.Provider value={requestLeave}>
<Shell
snapshot={snapshot}
route={route}
setRoute={(next) => 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))}
>
<ErrorBoundary key={pageKey} scope="page">{page}</ErrorBoundary>
</Shell>
</LeaveGuardContext.Provider>
{leaving && (
<div className="modal-backdrop" role="presentation">
<div className="modal" role="alertdialog" aria-label={t("Leave this page?")}>
<h2 className="modal-title">{t("Leave this page?")}</h2>
<p style={{ margin: 0 }}>{t("What you typed here is lost if you leave.")}</p>
<div className="modal-actions">
<Button tone="ghost" onClick={() => setLeaving(null)}>{t("Stay")}</Button>
<Button tone="primary" onClick={() => { setLeaving(null); leaving.proceed(); }}>{t("Leave")}</Button>
</div>
</div>
</div>
)}
</AvatarDirectoryContext.Provider>
);
}
+8
View File
@@ -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);
}
+7
View File
@@ -696,6 +696,13 @@ export const zh: Record<string, string> = {
"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.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。",
+6 -3
View File
@@ -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<string | null>(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<Record<string, Draft>>({});
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<Record<string, string | null>>({});
const updateDraft = useCallback((spaceId: string, change: Partial<Draft>) =>
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)}
/>
+3 -1
View File
@@ -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<SettingsTab>("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) {
<aside className="secondary-pane">
<div style={{ fontSize: 14, fontWeight: 600, padding: "4px 8px 14px" }}>{t("Settings")}</div>
<div className="pane-scroll">
<NavGroup items={TABS} active={tab} onChange={setTab} />
<NavGroup items={TABS} active={tab} onChange={(next) => requestLeave(() => setTab(next))} />
</div>
</aside>
<div className="main" style={{ flex: 1, minWidth: 0 }}>
+3 -1
View File
@@ -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 (
<form onSubmit={onSubmit} data-space-id={spaceId ?? undefined} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
<form onSubmit={onSubmit} onInput={() => { if (spaceId && ref.current) noteTyping(spaceId, ref.current.value); }} data-space-id={spaceId ?? undefined} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
<MentionTextarea
name="message"
@@ -1,3 +1,4 @@
import { useLeaveGuard } from "../../app/leave-guard";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
import { Avatar, AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
@@ -83,6 +84,8 @@ export function ChatConversation({
const [query, setQuery] = useState("");
// The component is keyed by space, so an open thread closes when the conversation changes.
const [openThreadId, setOpenThreadId] = useState<string | null>(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<string | null>(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}
/>
)}
@@ -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<string | null>(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 ? (
<div style={{ fontSize: 14, color: "var(--text-3)", fontStyle: "italic" }}>{t("Message deleted")}</div>
) : editing ? (
<form onSubmit={submitEdit}>
<form onSubmit={submitEdit} onInput={() => setChanged(true)}>
<textarea
name="body"
defaultValue={message.bodyMd}
@@ -124,7 +128,7 @@ export function ChatMessageRow({
{editError && <div className="form-error" role="alert" style={{ marginBottom: 6 }}>{t("Not saved: {reason}", { reason: editError })}</div>}
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" type="submit" disabled={saving}>{t("Save")}</Button>
<Button tone="ghost" size="small" disabled={saving} onClick={() => { setEditing(false); setEditError(null); }}>{t("Cancel")}</Button>
<Button tone="ghost" size="small" disabled={saving} onClick={() => { setEditing(false); setChanged(false); setEditError(null); }}>{t("Cancel")}</Button>
</div>
</form>
) : (
+5 -1
View File
@@ -1,3 +1,4 @@
import { useUnsaved } from "../../shared/unsaved";
import { useEffect, useState, type FormEvent } from "react";
import { Button, IconButton } from "../../shared/ui";
import type { FileObjectRecord } from "../../../../shared/domain";
@@ -48,6 +49,8 @@ export function ThreadPanel({
}, [orgId, threadId, refreshKey]);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [replyTyped, setReplyTyped] = useState(false);
useUnsaved(replyTyped);
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
@@ -59,6 +62,7 @@ export function ThreadPanel({
// The reply stays in the box until it is saved, so a refused one loses nothing.
onReply(body).then(() => {
field.value = "";
setReplyTyped(false);
setPending(false);
}).catch((reason: unknown) => {
const message = reason instanceof Error ? reason.message : String(reason);
@@ -88,7 +92,7 @@ export function ThreadPanel({
/>
))}
</div>
<form onSubmit={submit} style={{ padding: 12, borderTop: "1px solid var(--border-soft)" }}>
<form onSubmit={submit} onInput={(event) => setReplyTyped(Boolean((event.currentTarget.elements.namedItem("reply") as HTMLTextAreaElement).value.trim()))} style={{ padding: 12, borderTop: "1px solid var(--border-soft)" }}>
<MentionTextarea name="reply" rows={2} disabled={pending} ariaLabel={t("Reply")} agents={context.agents} employees={context.employees} />
{error && <div className="form-error" role="alert">{t("Not sent: {reason}", { reason: error })}</div>}
<div className="row" style={{ marginTop: 6 }}>
@@ -0,0 +1,34 @@
import { useSyncExternalStore } from "react";
import { setUnsaved } from "../../shared/unsaved";
import type { Draft } from "./ChatComposer";
/** Unsent drafts per conversation, kept outside the page so leaving Messages and coming back loses nothing. */
let drafts: Record<string, Draft> = {};
const listeners = new Set<() => void>();
const UNSENT = Symbol("chat-drafts");
function syncUnsaved(): void {
const unsent = Object.values(drafts).some((draft) => draft.body.trim() !== "" || draft.attachmentFileIds.length > 0);
setUnsaved(UNSENT, unsent ? "app" : null);
}
export function setDrafts(update: (current: Record<string, Draft>) => Record<string, Draft>): void {
drafts = update(drafts);
syncUnsaved();
for (const listener of listeners) listener();
}
/** Records live typing in place, re-rendering nothing: the composer shows its own text, the store only needs to know it is unsent. */
export function noteTyping(spaceId: string, body: string): void {
drafts[spaceId] = { attachmentFileIds: drafts[spaceId]?.attachmentFileIds ?? [], body };
syncUnsaved();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useDrafts(): Record<string, Draft> {
return useSyncExternalStore(subscribe, () => drafts);
}
@@ -1,3 +1,4 @@
import { useUnsaved } from "../../shared/unsaved";
import { useState, type FormEvent } from "react";
import type { OrganizationRecord, ProviderRecord } from "../../../../shared/domain";
import type { SnapshotActions } from "../../app/useSnapshot";
@@ -9,6 +10,7 @@ export function GoalAuditPanel({ organization, providers, actions }: { organizat
const [providerId, setProviderId] = useState(organization.auditProviderId ?? "");
const [modelId, setModelId] = useState(organization.auditModelId ?? "");
const [error, setError] = useState<string | null>(null);
useUnsaved(providerId !== (organization.auditProviderId ?? "") || modelId !== (organization.auditModelId ?? ""));
const save = (event: FormEvent) => {
event.preventDefault();
setError(null);
@@ -1,3 +1,4 @@
import { useUnsaved } from "../../shared/unsaved";
import { useState } from "react";
import { Pill } from "../../shared/ui";
import { t } from "../../i18n/i18n";
@@ -78,6 +79,7 @@ function ProviderPolicyRow({
|| Number(monthlyRequestLimit) !== provider.monthlyRequestLimit
|| Number(contextWindowTokens) !== provider.contextWindowTokens
|| workspaceScope !== savedScope;
useUnsaved(dirty);
const edited = <T,>(set: (value: T) => void) => (value: T) => { set(value); setStatus(null); };
const savePolicy = () => {
@@ -1,3 +1,4 @@
import { useUnsaved } from "../../shared/unsaved";
import { credentialSourceLabel } from "../../i18n/labels";
import { defaultModelReach } from "../../../../shared/agent-model";
import { ScopeNote } from "./ScopeNote";
@@ -201,6 +202,8 @@ function ProviderPricesRow({ orgId, provider, actions }: { orgId: string; provid
const [output, setOutput] = useState(asDollars(provider.outputCentsPerMtok));
const [error, setError] = useState<string | null>(null);
const price = (value: string) => (value.trim() === "" ? null : Number(value));
const saved = (cents: number | null) => (cents === null ? null : cents / 100);
useUnsaved(price(input) !== saved(provider.inputCentsPerMtok) || price(cached) !== saved(provider.cachedInputCentsPerMtok) || price(output) !== saved(provider.outputCentsPerMtok));
const save = () => {
setError(null);
actions.run(() => window.agentCompany.setProviderPrices({
@@ -1,3 +1,4 @@
import { useUnsaved } from "../../shared/unsaved";
import { useBusySubmit } from "../../shared/useBusySubmit";
import { approvalStateLabel, runTriggerLabel, taskEventLabel } from "../../i18n/labels";
import { TaskHoldNotice } from "./TaskHoldNotice";
@@ -86,6 +87,9 @@ export function TaskDetailDialog({
operation().then(after, (error: unknown) => setActionError(error instanceof Error ? error.message : String(error)));
};
const [editing, setEditing] = useState(false);
const [editDirty, setEditDirty] = useState(false);
const [confirmClose, setConfirmClose] = useState(false);
useUnsaved(editing && editDirty);
const [editError, setEditError] = useState<string | null>(null);
if (!task) return null;
@@ -120,7 +124,7 @@ export function TaskDetailDialog({
requiresFilesystem: form.get("requiresFilesystem") === "on",
writingMode: val("writingMode") as WritingMode,
workLanguage: readWorkLanguage(form),
})).then((r) => { if (r) setEditing(false); }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error)));
})).then((r) => { if (r) { setEditing(false); setEditDirty(false); } }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error)));
};
const askDelete = () => {
@@ -134,15 +138,23 @@ export function TaskDetailDialog({
const confirmDelete = (taskId: string) => act(async () => {
await actions.run(() => window.agentCompany.deleteTask({ orgId, taskId }));
}, onClose);
const requestClose = () => (editing && editDirty ? setConfirmClose(true) : onClose());
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
<div className="modal" style={{ maxWidth: 560 }} role="dialog" aria-label={task.title} onMouseDown={(e) => e.stopPropagation()}>
{confirmClose && (
<div role="alertdialog" aria-label={t("Discard your changes?")} className="row" style={{ position: "absolute", left: 0, right: 0, top: 0, zIndex: 3, gap: 8, padding: 12, background: "var(--panel)", borderBottom: "1px solid var(--border)", borderRadius: "14px 14px 0 0" }}>
<span style={{ marginRight: "auto", fontSize: 13 }}>{t("Discard your changes?")}</span>
<Button tone="ghost" size="small" onClick={() => setConfirmClose(false)}>{t("Keep editing")}</Button>
<Button tone="primary" size="small" onClick={onClose}>{t("Discard")}</Button>
</div>
)}
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 8 }}>
{task.priority === "high" && <span style={{ width: 3, height: 18, background: "var(--red)", borderRadius: 2, marginTop: 2, flexShrink: 0 }} />}
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{task.title}</h2>
<IconButton icon="edit" label={t("Edit")} onClick={() => { setEditError(null); setEditing(true); }} />
<IconButton icon="x" label={t("Close")} onClick={onClose} />
<IconButton icon="x" label={t("Close")} onClick={requestClose} />
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
<Pill>{prettyStatus(task.status)}</Pill>
@@ -208,6 +220,7 @@ export function TaskDetailDialog({
{editing && (
<EditOverlay
onEdited={() => setEditDirty(true)}
task={task}
companyLanguage={companyLanguage}
agents={agents}
@@ -217,7 +230,7 @@ export function TaskDetailDialog({
workspaces={workspaces}
error={editError}
onSubmit={submitEdit}
onCancel={() => { setEditing(false); setEditError(null); }}
onCancel={() => { setEditing(false); setEditDirty(false); setEditError(null); }}
/>
)}
</div>
@@ -321,8 +334,8 @@ function ActivityTab({ audit, approvals, onOpenApproval }: { audit: AuditEventRe
);
}
function EditOverlay({ task, companyLanguage, agents, allTasks, projects, goals, workspaces, error, onSubmit, onCancel }: {
task: TaskRecord; companyLanguage: WorkLanguage; agents: AgentRecord[]; allTasks: TaskRecord[];
function EditOverlay({ onEdited, task, companyLanguage, agents, allTasks, projects, goals, workspaces, error, onSubmit, onCancel }: {
onEdited: () => void; task: TaskRecord; companyLanguage: WorkLanguage; agents: AgentRecord[]; allTasks: TaskRecord[];
projects: ProjectRecord[]; goals: GoalRecord[]; workspaces: WorkspaceRecord[]; error: string | null;
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<unknown>; onCancel: () => void;
}) {
@@ -331,7 +344,7 @@ function EditOverlay({ task, companyLanguage, agents, allTasks, projects, goals,
const otherTasks = allTasks.filter((t) => t.id !== task.id && t.orgId === task.orgId && !beneath.has(t.id));
return (
<div style={{ position: "absolute", inset: 0, background: "var(--bg)", borderRadius: 14, padding: 24, overflow: "auto", zIndex: 2 }}>
<form onSubmit={submit} aria-busy={busy}>
<form onSubmit={submit} onInput={onEdited} onChange={onEdited} aria-busy={busy}>
<h3 style={{ margin: "0 0 14px", fontSize: 15, fontWeight: 600 }}>{t("Edit task")}</h3>
<Field label={t("Title")}><input className="input" name="title" defaultValue={task.title} required /></Field>
<Field label={t("Description")}><textarea className="input" name="description" rows={4} defaultValue={task.description} /></Field>
+27 -17
View File
@@ -1,17 +1,10 @@
import type { FormEvent, ReactNode } from "react";
import { useState, type FormEvent, type ReactNode } from "react";
import { useUnsaved } from "./unsaved";
import { useBusySubmit } from "./useBusySubmit";
import { Button } from "./ui";
import { t } from "../i18n/i18n";
export function Dialog({
title,
open,
children,
submitLabel,
onClose,
onSubmit,
hideSubmit = false,
}: {
export function Dialog(props: {
title: string;
open: boolean;
children: ReactNode;
@@ -21,17 +14,34 @@ export function Dialog({
onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<unknown>;
hideSubmit?: boolean;
}) {
// The body mounts per opening, so each opening starts clean.
return props.open ? <DialogBody {...props} /> : null;
}
function DialogBody({ title, children, submitLabel, onClose, onSubmit, hideSubmit = false }: Parameters<typeof Dialog>[0]) {
const { busy, submit } = useBusySubmit(onSubmit);
if (!open) return null;
const [edited, setEdited] = useState(false);
const [confirming, setConfirming] = useState(false);
useUnsaved(edited);
const requestClose = () => (edited ? setConfirming(true) : onClose());
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<form className="modal" aria-busy={busy} onMouseDown={(event) => event.stopPropagation()} onSubmit={submit}>
<div className="modal-backdrop" role="presentation" onMouseDown={requestClose}>
<form className="modal" aria-busy={busy} onMouseDown={(event) => event.stopPropagation()} onInput={() => setEdited(true)} onChange={() => setEdited(true)} onSubmit={submit}>
<h2 className="modal-title">{title}</h2>
<div style={{ display: "grid", gap: 12 }}>{children}</div>
<div className="modal-actions">
<Button tone="ghost" onClick={onClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
</div>
{confirming ? (
// Keyed rows remount, so a clicked plain button is never reused as the submit button (whose default action would then submit).
<div key="confirm" className="modal-actions" role="alertdialog" aria-label={t("Discard your changes?")}>
<span style={{ marginRight: "auto", fontSize: 13 }}>{t("Discard your changes?")}</span>
<Button tone="ghost" onClick={() => setConfirming(false)}>{t("Keep editing")}</Button>
<Button tone="primary" onClick={onClose}>{t("Discard")}</Button>
</div>
) : (
<div key="actions" className="modal-actions">
<Button tone="ghost" onClick={requestClose}>{hideSubmit ? t("Close") : t("Cancel")}</Button>
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
</div>
)}
</form>
</div>
);
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from "react";
/** "page": input lost when its page is left; "app": input kept across pages and lost only when the app closes. */
export type UnsavedScope = "page" | "app";
const sources = new Map<symbol, UnsavedScope>();
let reported = false;
/** Main asks before closing the window or quitting while anything is unsaved. */
function report(): void {
const unsaved = sources.size > 0;
if (unsaved === reported) return;
reported = unsaved;
void window.agentCompany.reportUnsaved({ unsaved }).then((result) => {
if (!result.ok) console.error(`[unsaved] could not tell the app about unsaved input: ${result.error.message}`);
});
}
export function setUnsaved(token: symbol, scope: UnsavedScope | null): void {
if (scope) sources.set(token, scope);
else sources.delete(token);
report();
}
/** Registers input that leaving would discard, for as long as `dirty` holds. */
export function useUnsaved(dirty: boolean, scope: UnsavedScope = "page"): void {
useEffect(() => {
if (!dirty) return;
const token = Symbol("unsaved");
setUnsaved(token, scope);
return () => setUnsaved(token, null);
}, [dirty, scope]);
}
/** True while input exists that switching pages would discard. */
export function hasPageUnsaved(): boolean {
return [...sources.values()].includes("page");
}
+2
View File
@@ -124,6 +124,8 @@ export interface DesktopApi {
loadSnapshot(): Promise<DesktopResult<AppSnapshot>>;
/** Restarts the app, which a display-language change needs; the user asks for it after seeing what it interrupts. */
relaunchApp(): Promise<DesktopResult<null>>;
/** Whether typed input would be lost if the app closed now; closing asks first while it is. */
reportUnsaved(input: { unsaved: boolean }): Promise<DesktopResult<null>>;
saveOnboardingDraft(input: { draft: OnboardingDraft | null }): Promise<DesktopResult<null>>;
completeSetup(input: { orgId: string }): Promise<DesktopResult<AppSnapshot>>;
/** Every agent session running now, in every company: what a restart interrupts. */
+1
View File
@@ -1,6 +1,7 @@
export const IPC_CHANNELS = {
loadSnapshot: "agentcompany:load-snapshot",
relaunchApp: "agentcompany:relaunch-app",
reportUnsaved: "agentcompany:report-unsaved",
saveOnboardingDraft: "agentcompany:save-onboarding-draft",
completeSetup: "agentcompany:complete-setup",
runningSessionCount: "agentcompany:running-session-count",
+2 -1
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
import { closeApp } from "./onboard";
/**
* The founder's direct line to the CEO: onboarding leaves a pinned CEO
@@ -418,6 +419,6 @@ test("the pinned CEO conversation is the Messages front door", async () => {
await expect(doomed).toHaveCount(0);
await expect(page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ })).toHaveCount(0);
} finally {
await app.close();
await closeApp(app);
}
});
+6
View File
@@ -136,7 +136,13 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await expect(toast).toContainText('An agent named "Alex Turing" is already on staff');
await toast.getByRole("button", { name: "Dismiss" }).click();
await expect(toast).toHaveCount(0);
// Closing a dialog with typed input asks before discarding it.
await page.locator(".modal").getByRole("button", { name: "Cancel" }).click();
await page.getByRole("alertdialog", { name: "Discard your changes?" }).getByRole("button", { name: "Keep editing" }).click();
await expect(page.locator(".modal").getByLabel("Name", { exact: true })).toHaveValue("Alex Turing");
await page.locator(".modal-backdrop").click({ position: { x: 5, y: 5 } });
await page.getByRole("alertdialog", { name: "Discard your changes?" }).getByRole("button", { name: "Discard" }).click();
await expect(page.locator(".modal")).toHaveCount(0);
// The operator edits their own name and avatar from the sidebar foot.
await page.getByRole("button", { name: "Edit profile" }).click();
+9 -1
View File
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type ElectronApplication, type Page } from "@playwright/test";
/** Runs the setup wizard with a fake OpenAI key and enters the app. */
export async function onboard(page: Page, company: string): Promise<void> {
@@ -17,3 +17,11 @@ export async function onboard(page: Page, company: string): Promise<void> {
await page.getByRole("button", { name: "Finish" }).click();
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
}
/** Closes the app, answering the unsaved-input question (a native dialog Playwright cannot click) with "discard". */
export async function closeApp(app: ElectronApplication): Promise<void> {
await app.evaluate(({ dialog }) => {
dialog.showMessageBoxSync = () => 0;
});
await app.close();
}
+87
View File
@@ -0,0 +1,87 @@
import { _electron as electron, expect, test } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
import { closeApp, onboard } from "./onboard";
test("typed input survives page switches, and leaving or closing asks before it is lost", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-unsaved-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-unsaved-builtin-${randomUUID()}`));
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
});
try {
const page = await app.firstWindow();
await onboard(page, "Unsaved Co");
const composer = page.getByRole("textbox", { name: "Message" });
// An unsent message is kept when another page is opened and comes back with the conversation.
await page.getByRole("button", { name: "Messages" }).click();
await composer.fill("hello team");
await page.getByRole("button", { name: "Send" }).click();
await expect(composer).toHaveValue("");
await composer.fill("unsent words");
await page.getByRole("button", { name: "Home" }).click();
await page.getByRole("button", { name: "Messages" }).click();
await expect(composer).toHaveValue("unsent words");
// Closing the window with it asks first; staying keeps the app running.
await app.evaluate(({ dialog }) => {
(globalThis as unknown as { asked: number }).asked = 0;
dialog.showMessageBoxSync = () => {
(globalThis as unknown as { asked: number }).asked += 1;
return 1;
};
});
await page.evaluate(() => void (window as unknown as { agentCompany: { windowClose: () => Promise<unknown> } }).agentCompany.windowClose());
await expect.poll(() => app.evaluate(() => (globalThis as unknown as { asked: number }).asked)).toBe(1);
await expect(composer).toHaveValue("unsent words");
// An edit in progress is lost by leaving the page, so leaving asks.
await page.getByRole("button", { name: "Edit message" }).first().click();
await page.getByRole("textbox", { name: "Edit message" }).fill("hello team, edited");
await page.getByRole("button", { name: "Home" }).click();
const leave = page.getByRole("alertdialog", { name: "Leave this page?" });
await leave.getByRole("button", { name: "Stay" }).click();
await expect(page.getByRole("textbox", { name: "Edit message" })).toHaveValue("hello team, edited");
await page.getByRole("button", { name: "Home" }).click();
await leave.getByRole("button", { name: "Leave" }).click();
await expect(leave).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Edit message" })).toHaveCount(0);
// A command picked from the menu is unsent input too, though no key was typed.
const asked = () => app.evaluate(() => (globalThis as unknown as { asked: number }).asked);
await page.getByRole("button", { name: "Messages" }).click();
await composer.fill("");
await page.getByRole("button", { name: "Commands" }).click();
await page.getByRole("button", { name: "/handoff" }).click();
await expect(composer).toHaveValue("/handoff ");
await page.evaluate(() => void (window as unknown as { agentCompany: { windowClose: () => Promise<unknown> } }).agentCompany.windowClose());
await expect.poll(asked).toBe(2);
// Settings sections are left like pages: an edited limit is not dropped silently.
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: "Providers" }).click();
await page.getByLabel("OpenAI monthly request limit").fill("77");
await page.getByRole("button", { name: "General" }).click();
await leave.getByRole("button", { name: "Stay" }).click();
await expect(page.getByLabel("OpenAI monthly request limit")).toHaveValue("77");
await page.getByRole("button", { name: "General" }).click();
await leave.getByRole("button", { name: "Leave" }).click();
await expect(page.getByLabel("OpenAI monthly request limit")).toHaveCount(0);
// Starting another company replaces the screen, so it asks too.
await page.getByRole("button", { name: "Providers" }).click();
await page.getByLabel("OpenAI monthly request limit").fill("78");
await page.getByRole("button", { name: "Unsaved Co — switch organization" }).click();
await page.getByRole("menuitem", { name: "New organization" }).click();
await leave.getByRole("button", { name: "Stay" }).click();
await expect(page.getByLabel("OpenAI monthly request limit")).toHaveValue("78");
} finally {
await closeApp(app);
}
});