дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
0be97c4323
коміт
2841f97145
6 files changed
+116
-17
No files matched your search
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.submit {
|
||||
width: var(--h-control);
|
||||
padding: 0;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача