diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx
index 0d0b1392a..d7dfa29e9 100644
--- a/src/features/agents/HomeEditor.tsx
+++ b/src/features/agents/HomeEditor.tsx
@@ -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={
-
+
}
/>
diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx
index a38f42834..7e756a179 100644
--- a/src/features/agents/SessionEditor.tsx
+++ b/src/features/agents/SessionEditor.tsx
@@ -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({
-
+ />
@@ -1189,14 +1187,13 @@ function SessionComposer({
Stop
)}
-
+ />
>
}
/>
diff --git a/src/features/agents/SubmitButton.module.css b/src/features/agents/SubmitButton.module.css
new file mode 100644
index 000000000..be2574407
--- /dev/null
+++ b/src/features/agents/SubmitButton.module.css
@@ -0,0 +1,4 @@
+.submit {
+ width: var(--h-control);
+ padding: 0;
+}
diff --git a/src/features/agents/SubmitButton.tsx b/src/features/agents/SubmitButton.tsx
new file mode 100644
index 000000000..090b4e025
--- /dev/null
+++ b/src/features/agents/SubmitButton.tsx
@@ -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 & {
+ label: "Start" | "Starting…" | "Send" | "Queue";
+ pending?: boolean;
+}) {
+ const tx = useTx();
+ const name = tx(label);
+ return (
+
+ );
+}
diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx
index 41498a3f5..6558b5aec 100644
--- a/src/features/agents/composerCommands.test.tsx
+++ b/src/features/agents/composerCommands.test.tsx
@@ -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();
+ 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",
diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx
index efe5ba32e..4542fa063 100644
--- a/src/features/agents/messageEditing.test.tsx
+++ b/src/features/agents/messageEditing.test.tsx
@@ -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();
+ 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();
+ 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");
+ });
+});