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:
1 parent
3f8832702e
commit
2790d88186
27 files changed
+351
-41
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.": "应用的其他部分仍可使用;可以前往其他页面,或重试此页面。",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user