Q182: dialogs stay busy while their request runs and ignore repeat submits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 06:03:09 -04:00
1 parent 3039835644
commit 3f8832702e
19 files changed
+115 -39

No files matched your search

+1
View File
@@ -695,6 +695,7 @@ export const zh: Record<string, string> = {
"Unsaved": "未保存",
"Working language": "工作语言",
"That did not work": "操作未成功",
"Working…": "处理中…",
"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 -1
View File
@@ -152,7 +152,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const existingSpaceIds = new Set(spaces.map((space) => space.id));
void actions.run(() => window.agentCompany.createChatSpace({
return actions.run(() => window.agentCompany.createChatSpace({
orgId,
spaceType: value(form, "spaceType") as SpaceType,
name: value(form, "name"),
+1 -1
View File
@@ -50,7 +50,7 @@ export function NewTaskDialog({
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createTask({
return actions.run(() => window.agentCompany.createTask({
orgId,
title: value(form, "title"),
description: value(form, "description"),
+2 -2
View File
@@ -55,7 +55,7 @@ export function TeamPage({ snapshot, actions }: PageProps) {
const form = new FormData(event.currentTarget);
const selectedProvider = providers.find((provider) => provider.id === hireProviderId) ?? null;
const modelId = selectedProvider ? hireModelId.trim() || null : null;
void actions.run(() => window.agentCompany.createAgent({
return actions.run(() => window.agentCompany.createAgent({
orgId,
name: text(form, "name"),
role: text(form, "role"),
@@ -79,7 +79,7 @@ export function TeamPage({ snapshot, actions }: PageProps) {
event.preventDefault();
if (!editingAgent) return;
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.updateAgent({
return actions.run(() => window.agentCompany.updateAgent({
orgId,
agentId: editingAgent.id,
name: text(form, "name"),
@@ -60,7 +60,7 @@ export function DocumentsPanel({
event.preventDefault();
if (!selected) return;
const form = new FormData(event.currentTarget);
void perform(() => actions.run(() => window.agentCompany.updateDocument({
return perform(() => actions.run(() => window.agentCompany.updateDocument({
orgId,
documentId: selected.id,
title: String(form.get("title") ?? ""),
@@ -141,7 +141,7 @@ export function DocumentsPanel({
onClose={() => setConfirmDelete(false)}
onSubmit={(event) => {
event.preventDefault();
void perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id, deletedByEmployeeId: employeeId })), () => {
return perform(() => actions.run(() => window.agentCompany.deleteDocument({ orgId, documentId: selected.id, deletedByEmployeeId: employeeId })), () => {
setConfirmDelete(false);
setSelectedId(null);
});
@@ -52,7 +52,7 @@ export function KnowledgePanel({
const perform = (operation: () => Promise<unknown>, done: () => void) => {
setError(null);
void operation().then(done, (failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
return operation().then(done, (failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
};
const text = (form: FormData, key: string) => String(form.get(key) ?? "");
@@ -60,7 +60,7 @@ export function KnowledgePanel({
event.preventDefault();
const form = new FormData(event.currentTarget);
const validUntil = text(form, "validUntil");
perform(() => actions.run(() => window.agentCompany.createKnowledgeEntry({
return perform(() => actions.run(() => window.agentCompany.createKnowledgeEntry({
orgId,
topic: text(form, "topic"),
category: text(form, "category"),
@@ -79,7 +79,7 @@ export function KnowledgePanel({
event.preventDefault();
if (!selected) return;
const form = new FormData(event.currentTarget);
perform(() => actions.run(() => window.agentCompany.updateKnowledgeEntry({
return perform(() => actions.run(() => window.agentCompany.updateKnowledgeEntry({
orgId,
entryId: selected.id,
topic: text(form, "topic"),
@@ -202,7 +202,7 @@ export function KnowledgePanel({
onClose={() => setDialog(null)}
onSubmit={(event) => {
event.preventDefault();
perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id, deletedByEmployeeId: employeeId })), () => {
return perform(() => actions.run(() => window.agentCompany.deleteKnowledgeEntry({ orgId, entryId: selected.id, deletedByEmployeeId: employeeId })), () => {
setDialog(null);
setSelectedId(null);
});
@@ -14,7 +14,7 @@ export function CompanyDialog({ organization, open, actions, onClose }: { organi
const form = new FormData(event.currentTarget);
const value = (key: string) => String(form.get(key) ?? "");
setError(null);
actions.run(() => window.agentCompany.updateOrganization({
return actions.run(() => window.agentCompany.updateOrganization({
orgId: organization.id,
name: value("name"),
description: value("description"),
@@ -55,7 +55,7 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
if (!active || !activeCatalogEntry || activeIsOAuth) return;
const form = new FormData(event.currentTarget);
const secret = composeSecret(activeCatalogEntry, form);
void actions.run(() => window.agentCompany.saveProviderCredential({
return actions.run(() => window.agentCompany.saveProviderCredential({
orgId,
providerId: active.id,
secret,
@@ -68,7 +68,7 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
if (!modelFor) return;
const model = String(new FormData(event.currentTarget).get("model") ?? "");
setModelError(null);
void actions.run(() => window.agentCompany.setProviderModel({ orgId, providerId: modelFor.id, model }))
return actions.run(() => window.agentCompany.setProviderModel({ orgId, providerId: modelFor.id, model }))
.then((result) => { if (result) setModelFor(null); })
.catch((reason: unknown) => setModelError(reason instanceof Error ? reason.message : String(reason)));
};
@@ -55,7 +55,7 @@ export function WorkspacePanel({
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createWorkspace({
return actions.run(() => window.agentCompany.createWorkspace({
orgId,
name: text(form, "name"),
localPath: text(form, "localPath"),
@@ -12,14 +12,14 @@ export function WorkspaceRow({ workspace, projects, focused, actions }: { worksp
const [error, setError] = useState<string | null>(null);
const act = (operation: () => Promise<unknown>, after?: () => void) => {
setError(null);
operation().then(after, (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
return operation().then(after, (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
};
const orgId = workspace.orgId;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const value = (key: string) => String(form.get(key) ?? "");
act(() => actions.run(() => window.agentCompany.updateWorkspace({
return act(() => actions.run(() => window.agentCompany.updateWorkspace({
orgId, workspaceId: workspace.id, name: value("name"), localPath: value("localPath"), projectId: value("projectId") || null,
})), () => setEditing(false));
};
+1 -1
View File
@@ -29,7 +29,7 @@ export function GoalDialog({
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createGoal({
return actions.run(() => window.agentCompany.createGoal({
orgId,
title: text(form, "title"),
description: text(form, "description"),
@@ -31,7 +31,7 @@ export function ProjectDialog({
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createProject({
return actions.run(() => window.agentCompany.createProject({
orgId,
goalId: optional(form, "goalId"),
name: text(form, "name"),
@@ -1,3 +1,4 @@
import { useBusySubmit } from "../../shared/useBusySubmit";
import { approvalStateLabel, runTriggerLabel, taskEventLabel } from "../../i18n/labels";
import { TaskHoldNotice } from "./TaskHoldNotice";
import { useState, type FormEvent } from "react";
@@ -104,7 +105,7 @@ export function TaskDetailDialog({
const form = new FormData(event.currentTarget);
const val = (key: string) => String(form.get(key) ?? "").trim();
setEditError(null);
void actions.run(() => window.agentCompany.updateTask({
return actions.run(() => window.agentCompany.updateTask({
orgId,
taskId: task.id,
title: val("title"),
@@ -323,13 +324,14 @@ 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[];
projects: ProjectRecord[]; goals: GoalRecord[]; workspaces: WorkspaceRecord[]; error: string | null;
onSubmit: (e: FormEvent<HTMLFormElement>) => void; onCancel: () => void;
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<unknown>; onCancel: () => void;
}) {
const { busy, submit } = useBusySubmit(onSubmit);
const beneath = descendantIds(task.id, allTasks);
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={onSubmit}>
<form onSubmit={submit} 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>
@@ -387,7 +389,7 @@ function EditOverlay({ task, companyLanguage, agents, allTasks, projects, goals,
{error && <div className="form-error" role="alert">{error}</div>}
<div className="modal-actions">
<Button onClick={onCancel}>{t("Cancel")}</Button>
<Button tone="primary" type="submit">{t("Save")}</Button>
<Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : t("Save")}</Button>
</div>
</form>
</div>
+6 -3
View File
@@ -1,4 +1,5 @@
import type { FormEvent, ReactNode } from "react";
import { useBusySubmit } from "./useBusySubmit";
import { Button } from "./ui";
import { t } from "../i18n/i18n";
@@ -16,18 +17,20 @@ export function Dialog({
children: ReactNode;
submitLabel: string;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
/** Returning the request's promise keeps the dialog busy until it settles: no second submit, and the button says so. */
onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<unknown>;
hideSubmit?: boolean;
}) {
const { busy, submit } = useBusySubmit(onSubmit);
if (!open) return null;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={onSubmit}>
<form className="modal" aria-busy={busy} onMouseDown={(event) => event.stopPropagation()} 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">{submitLabel}</Button>}
{!hideSubmit && <Button tone="primary" type="submit" disabled={busy}>{busy ? t("Working…") : submitLabel}</Button>}
</div>
</form>
</div>
+1 -1
View File
@@ -245,7 +245,7 @@ function ProfileDialog({ employee, onClose }: { employee: EmployeeRecord; onClos
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void window.agentCompany.updateEmployeeProfile({
return window.agentCompany.updateEmployeeProfile({
orgId: employee.orgId,
employeeId: employee.id,
displayName: String(form.get("displayName") ?? "").trim(),
+24
View File
@@ -0,0 +1,24 @@
import { useRef, useState, type FormEvent } from "react";
/** Wraps a form's submit: while the returned promise is pending the form is busy and further submits are ignored. */
export function useBusySubmit(onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<unknown>): { busy: boolean; submit: (event: FormEvent<HTMLFormElement>) => void } {
// The ref guards submits in the same tick, before the busy state has rendered.
const pendingRef = useRef(false);
const [busy, setBusy] = useState(false);
const submit = (event: FormEvent<HTMLFormElement>) => {
if (pendingRef.current) {
event.preventDefault();
return;
}
const pending = onSubmit(event);
if (!(pending instanceof Promise)) return;
pendingRef.current = true;
setBusy(true);
// A failure the caller did not handle still surfaces once, as an unhandled rejection the failure toasts show.
void pending.finally(() => {
pendingRef.current = false;
setBusy(false);
});
};
return { busy, submit };
}
+38
View File
@@ -0,0 +1,38 @@
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 { onboard } from "./onboard";
test("a dialog submitted twice in a row creates one record", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-double-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-double-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, "Double Co");
await page.getByRole("button", { name: "Work" }).click();
await page.getByRole("button", { name: "Goal", exact: true }).click();
await page.locator(".modal").getByLabel("Title").fill("Only once");
await page.locator(".modal").getByLabel("Description").fill("d");
// Two submits in the same tick, as an impatient double click sends them.
await page.locator(".modal").evaluate((form) => {
(form as HTMLFormElement).requestSubmit();
(form as HTMLFormElement).requestSubmit();
});
await expect(page.locator(".modal")).toHaveCount(0);
const titles = await page.evaluate(async () => {
const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { goals: Array<{ title: string }> } }> } }).agentCompany;
return (await api.loadSnapshot()).data.goals.map((goal) => goal.title).filter((title) => title === "Only once");
});
expect(titles).toEqual(["Only once"]);
} finally {
await app.close();
}
});
+19
View File
@@ -0,0 +1,19 @@
import { expect, 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> {
await page.getByRole("button", { name: "Get started" }).click();
await page.getByLabel("API key").fill("e2e-secret");
await page.getByLabel("Model").fill("gpt-5.4");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Company name").fill(company);
await page.getByLabel("Your name").fill("Dana Lee");
await page.getByLabel("Description").fill("d");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Goal").fill("Ship.");
await page.getByLabel("Acceptance criteria").fill("It ships.");
await page.getByRole("button", { name: "Launch" }).click();
await page.getByRole("button", { name: "Finish" }).click();
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
}
+2 -13
View File
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { seedBuiltinSkillsFixture } from "./builtin-skills-fixture";
import { onboard } from "./onboard";
test("a record that breaks one page leaves the rest of the app working", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-render-${randomUUID()}`);
@@ -16,19 +17,7 @@ test("a record that breaks one page leaves the rest of the app working", async (
});
try {
const page = await app.firstWindow();
await page.getByRole("button", { name: "Get started" }).click();
await page.getByLabel("API key").fill("e2e-secret");
await page.getByLabel("Model").fill("gpt-5.4");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Company name").fill("Render Co");
await page.getByLabel("Your name").fill("Dana Lee");
await page.getByLabel("Description").fill("d");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByLabel("Goal").fill("Ship.");
await page.getByLabel("Acceptance criteria").fill("It ships.");
await page.getByRole("button", { name: "Launch" }).click();
await page.getByRole("button", { name: "Finish" }).click();
await onboard(page, "Render Co");
// A pending request with a risk level the Approvals page cannot draw.
const db = new DatabaseSync(join(profileDir, "agentcompany.db"));