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