fix(agents): use icon-only composer actions (#613)

The composers' primary actions were text buttons. Start, Send and Queue, plus Send in edit-and-resend, now share an icon-only submit button that keeps the translated name as its accessible label and tooltip, and shows a spinner while the action is pending. Stop is unchanged.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 04:18:32 +08:00
1 джерело 0be97c4323
коміт 2841f97145
6 files changed
+116 -17

No files matched your search

+8 -4
Переглянути файл
@@ -5,10 +5,11 @@ import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { basename, useProject } from "@/platform/project";
import { Button, Icon, Select, T, type SelectOption } from "@/ui";
import { Icon, Select, T, type SelectOption } from "@/ui";
import { defaultTaskProject } from "./project";
import { maybeEditorService } from "@/workbench/contributions";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { providerIcon } from "./model";
@@ -117,9 +118,12 @@ export function HomeEditor() {
</>
}
actions={
<Button variant="primary" size="small" disabled={!canStart} onClick={start}>
{starting ? "Starting…" : "Start"}
</Button>
<SubmitButton
label={starting ? "Starting…" : "Start"}
pending={starting}
disabled={!canStart}
onClick={start}
/>
}
/>
<div className={styles.where}>
+9 -12
Переглянути файл
@@ -27,6 +27,7 @@ import { useTx, tx } from "@/platform/i18n";
import { isImeKey } from "@/platform/ime";
import { TaskStatusIcon } from "./AgentsView";
import { Composer } from "./Composer";
import { SubmitButton } from "./SubmitButton";
import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel";
import { ConfigPickers } from "./ConfigPickers";
import { findRanges, formatDuration, formatSentAt } from "./conversation";
@@ -891,15 +892,12 @@ function MessageEditor({
<Button size="small" disabled={sending} onClick={onCancel}>
Cancel
</Button>
<Button
size="small"
variant="primary"
<SubmitButton
label="Send"
pending={sending}
disabled={(!text.trim() && images.length === 0) || sending}
onClick={() => void send()}
>
{sending && <Spinner size={12} />}
<T>Send</T>
</Button>
/>
</span>
</div>
</div>
@@ -1189,14 +1187,13 @@ function SessionComposer({
Stop
</Button>
)}
<Button
size="small"
<SubmitButton
label={busy ? "Queue" : "Send"}
pending={sending}
variant={busy ? "secondary" : "primary"}
disabled={!canSend(composer) || sending || task.status === "archived"}
onClick={() => send(false)}
>
{busy ? "Queue" : "Send"}
</Button>
/>
</>
}
/>
+4
Переглянути файл
@@ -0,0 +1,4 @@
.submit {
width: var(--h-control);
padding: 0;
}
+28
Переглянути файл
@@ -0,0 +1,28 @@
import { useTx } from "@/platform/i18n";
import { Button, Icon, Spinner, tooltipAttrs, type ButtonProps } from "@/ui";
import styles from "./SubmitButton.module.css";
export function SubmitButton({
label,
pending = false,
variant = "primary",
...props
}: Pick<ButtonProps, "disabled" | "onClick" | "variant"> & {
label: "Start" | "Starting…" | "Send" | "Queue";
pending?: boolean;
}) {
const tx = useTx();
const name = tx(label);
return (
<Button
{...props}
variant={variant}
size="small"
className={styles.submit}
aria-label={name}
{...tooltipAttrs(name)}
>
{pending ? <Spinner size={12} /> : <Icon name="arrow-up" size={14} />}
</Button>
);
}
+23 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { AgentCommand, AgentProvider } from "@/bindings";
@@ -65,6 +65,28 @@ beforeEach(() => {
});
describe("New Task composer commands", () => {
it("names the icon-only Start action and disables it while starting", async () => {
agentCommands.mockResolvedValue({ status: "ok", data: [] });
let finish!: (value: unknown) => void;
agentCreate.mockReturnValue(new Promise((resolve) => (finish = resolve)));
render(<HomeEditor />);
const start = screen.getByRole("button", { name: "Start" });
expect(start.textContent).toBe("");
expect(start).toHaveAttribute("data-tooltip", "Start");
expect(start).toBeDisabled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Do the task" } });
expect(start).toBeEnabled();
fireEvent.click(start);
const starting = screen.getByRole("button", { name: "Starting…" });
expect(starting).toBeDisabled();
expect(starting.textContent).toBe("");
expect(starting).toHaveAttribute("data-tooltip", "Starting…");
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(agentCreate).toHaveBeenCalledTimes(1);
await act(async () => finish({ status: "error", error: { kind: "invalid", message: "stop here" } }));
expect(screen.getByRole("button", { name: "Start" })).toBeEnabled();
});
it("completes the project's skills merged with the agent's commands", async () => {
agentCommands.mockResolvedValue({
status: "ok",
+44
Переглянути файл
@@ -119,6 +119,15 @@ describe("a sent message with images", () => {
});
describe("editing a sent agent message", () => {
it("names the icon-only edit-and-resend action and disables empty edits", async () => {
const { box, send } = await edit();
expect(send.textContent).toBe("");
expect(send).toHaveAttribute("data-tooltip", "Send");
expect(send).toBeEnabled();
fireEvent.change(box, { target: { value: "" } });
expect(send).toBeDisabled();
});
it("keeps Enter, Shift+Enter and IME confirmation for editing", async () => {
const { box } = await edit();
fireEvent.keyDown(box, { key: "Enter" });
@@ -156,6 +165,9 @@ describe("editing a sent agent message", () => {
expect(ipc.rollback).toHaveBeenCalledWith(task.id, 0);
expect(ipc.prompt).not.toHaveBeenCalled();
expect(send).toBeDisabled();
expect(send).toHaveAccessibleName("Send");
expect(send.textContent).toBe("");
expect(send.querySelector('[role="status"]')).toBeInTheDocument();
fireEvent.keyDown(box, { key: "Enter", metaKey: true });
expect(ipc.rollback).toHaveBeenCalledTimes(1);
await act(async () => finish({ status: "ok", data: null }));
@@ -223,3 +235,35 @@ describe("editing a sent agent message", () => {
},
);
});
describe("session composer submission", () => {
it.each(["click", "Enter"])("keeps an accessible icon-only Send action with %s submission", async (method) => {
let finish!: (value: unknown) => void;
ipc.prompt.mockReturnValue(new Promise((resolve) => (finish = resolve)));
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const send = screen.getByRole("button", { name: "Send" });
expect(send.textContent).toBe("");
expect(send).toHaveAttribute("data-tooltip", "Send");
expect(send).toBeDisabled();
const box = screen.getByRole("textbox", { name: "Message" });
fireEvent.change(box, { target: { value: "Follow up" } });
expect(send).toBeEnabled();
if (method === "click") fireEvent.click(send);
else fireEvent.keyDown(box, { key: "Enter" });
expect(ipc.prompt).toHaveBeenCalledExactlyOnceWith(task.id, { text: "Follow up", files: [], images: [] }, false);
expect(send).toBeDisabled();
await act(async () => finish({ status: "ok", data: null }));
expect(box).toHaveValue("");
});
it("keeps Queue named and leaves Stop unchanged while the agent works", async () => {
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
const queue = screen.getByRole("button", { name: "Queue" });
expect(queue.textContent).toBe("");
expect(queue).toHaveAttribute("data-tooltip", "Queue");
expect(queue).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Stop" }).some((button) => button.textContent === "Stop")).toBe(true);
await screen.findByText("Original message");
});
});