From 2841f97145e48dc2f14ddcd77ee43c0f87a88830 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:18:32 +0800 Subject: [PATCH] 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. --- src/features/agents/HomeEditor.tsx | 12 +++-- src/features/agents/SessionEditor.tsx | 21 ++++----- src/features/agents/SubmitButton.module.css | 4 ++ src/features/agents/SubmitButton.tsx | 28 ++++++++++++ src/features/agents/composerCommands.test.tsx | 24 +++++++++- src/features/agents/messageEditing.test.tsx | 44 +++++++++++++++++++ 6 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 src/features/agents/SubmitButton.module.css create mode 100644 src/features/agents/SubmitButton.tsx 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"); + }); +});