Skip to content

Commit b480761

Browse files
fix(chat): improve mobile composer controls (agentscope-ai#7334)
1 parent d0a688d commit b480761

24 files changed

Lines changed: 1787 additions & 269 deletions

console/src/components/LoopInput/LoopModeSelector.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ import {
1010
} from "../../stores/loopStore";
1111
import { LoopModeSelector } from "./LoopModeSelector";
1212

13+
const { mockUseIsMobile } = vi.hoisted(() => ({
14+
mockUseIsMobile: vi.fn(() => false),
15+
}));
16+
17+
vi.mock("../../hooks/useIsMobile", () => ({
18+
useIsMobile: mockUseIsMobile,
19+
}));
20+
1321
vi.mock("react-i18next", () => ({
1422
useTranslation: () => ({
1523
t: (key: string) => key,
@@ -52,6 +60,7 @@ const ompUltraqa: LoopModeInfo = {
5260

5361
describe("LoopModeSelector", () => {
5462
beforeEach(() => {
63+
mockUseIsMobile.mockReturnValue(false);
5564
useLoopStore.setState({
5665
selectedModeId: "default",
5766
availableModes: [DEFAULT_LOOP_MODE, goal, custom, ompUltraqa],
@@ -100,6 +109,36 @@ describe("LoopModeSelector", () => {
100109
expect(useLoopStore.getState().selectedModeId).toBe("custom:quality");
101110
});
102111

112+
it("uses a bottom drawer for mode selection on mobile", async () => {
113+
mockUseIsMobile.mockReturnValue(true);
114+
const user = userEvent.setup();
115+
renderWithProviders(<LoopModeSelector compact />);
116+
117+
const trigger = screen.getByRole("button", { name: "loop.selectorAria" });
118+
await user.click(trigger);
119+
120+
expect(
121+
await screen.findByRole("dialog", { name: "loop.selectorTitle" }),
122+
).toBeInTheDocument();
123+
expect(trigger).toHaveAttribute("aria-expanded", "true");
124+
expect(document.querySelector(".ant-popover")).not.toBeInTheDocument();
125+
126+
await user.click(screen.getByText("loop.modes.goal.name"));
127+
128+
expect(useLoopStore.getState().selectedModeId).toBe("goal");
129+
expect(trigger).toHaveAttribute("aria-expanded", "false");
130+
});
131+
132+
it("renders only the mode icon in compact mode", () => {
133+
renderWithProviders(<LoopModeSelector compact />);
134+
135+
const trigger = screen.getByRole("button", {
136+
name: "loop.selectorAria",
137+
});
138+
expect(trigger.textContent).toBe("");
139+
expect(trigger.querySelectorAll("svg")).toHaveLength(1);
140+
});
141+
103142
it("shows starting before the first response event", () => {
104143
useLoopStore.getState().setStartingMode(custom);
105144

@@ -112,6 +151,21 @@ describe("LoopModeSelector", () => {
112151
).not.toBeInTheDocument();
113152
});
114153

154+
it("keeps an active mode icon-only in compact mode", () => {
155+
useLoopStore.getState().setSessionMode(custom, "running");
156+
157+
const { container } = renderWithProviders(<LoopModeSelector compact />);
158+
159+
const activeMode = container.querySelector('[data-state="running"]');
160+
expect(activeMode).not.toBeNull();
161+
expect(activeMode?.textContent).toBe("");
162+
expect(activeMode?.querySelectorAll("svg")).toHaveLength(1);
163+
expect(activeMode).toHaveAttribute(
164+
"aria-label",
165+
"Quality Review loop.running",
166+
);
167+
});
168+
115169
it("shows running after the first response event", () => {
116170
useLoopStore.getState().setSessionMode(custom, "running");
117171

console/src/components/LoopInput/LoopModeSelector.tsx

Lines changed: 124 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Settings2,
99
Sparkles,
1010
Target,
11+
X,
1112
} from "lucide-react";
1213
import { Popover, Tooltip } from "antd";
1314
import { useMemo, useState } from "react";
@@ -20,6 +21,8 @@ import {
2021
type LoopModeInfo,
2122
useLoopStore,
2223
} from "../../stores/loopStore";
24+
import { useIsMobile } from "../../hooks/useIsMobile";
25+
import { OsDrawer } from "../../os/OsOverlay";
2326
import { InlineMarkdown } from "../Markdown/InlineMarkdown";
2427
import {
2528
resolveLoopModeDescriptionMarkdown,
@@ -35,10 +38,19 @@ function ModeIcon({ mode, size = 14 }: { mode: LoopModeInfo; size?: number }) {
3538
return <CircleDot size={size} />;
3639
}
3740

38-
export function LoopModeSelector() {
41+
interface LoopModeSelectorProps {
42+
className?: string;
43+
compact?: boolean;
44+
}
45+
46+
export function LoopModeSelector({
47+
className,
48+
compact = false,
49+
}: LoopModeSelectorProps = {}) {
3950
const { t, i18n } = useTranslation();
4051
const lang = i18n.language || "en";
4152
const navigate = useNavigate();
53+
const isMobile = useIsMobile();
4254
const [open, setOpen] = useState(false);
4355
const availableModes = useLoopStore((state) => state.availableModes);
4456
const selectedModeId = useLoopStore((state) => state.selectedModeId);
@@ -61,14 +73,16 @@ export function LoopModeSelector() {
6173
);
6274

6375
if (sessionState !== "idle" && activeMode) {
76+
const modeName = resolveLoopModeName(activeMode, t, lang);
6477
const tooltip =
6578
activeMode.source === "custom"
6679
? t("loop.activeCustomDescription")
6780
: t("loop.activePersistentDescription");
6881
return (
6982
<Tooltip title={tooltip}>
7083
<div
71-
className={styles.activeMode}
84+
className={[styles.activeMode, className].filter(Boolean).join(" ")}
85+
aria-label={`${modeName} ${t(`loop.${sessionState}`)}`}
7286
aria-live="polite"
7387
data-state={sessionState}
7488
>
@@ -79,10 +93,14 @@ export function LoopModeSelector() {
7993
{sessionState === "awaiting_user" && (
8094
<MessageCircleQuestion size={14} />
8195
)}
82-
<span>{resolveLoopModeName(activeMode, t, lang)}</span>
83-
<span className={styles.activeState}>
84-
{t(`loop.${sessionState}`)}
85-
</span>
96+
{!compact && (
97+
<>
98+
<span>{modeName}</span>
99+
<span className={styles.activeState}>
100+
{t(`loop.${sessionState}`)}
101+
</span>
102+
</>
103+
)}
86104
</div>
87105
</Tooltip>
88106
);
@@ -130,40 +148,115 @@ export function LoopModeSelector() {
130148
);
131149
};
132150

151+
const settingsButton = (
152+
<button
153+
aria-label={t("loop.gotoSettings")}
154+
className={styles.settingsButton}
155+
onClick={() => {
156+
setOpen(false);
157+
navigate("/agent-config?tab=agentLoop");
158+
}}
159+
type="button"
160+
>
161+
<Settings2 size={16} />
162+
</button>
163+
);
164+
133165
const content = (
134-
<div className={styles.modeMenu} role="listbox">
166+
<div className={styles.modeMenu}>
135167
<div className={styles.menuHeader}>
136168
<div>
137169
<div className={styles.menuTitle}>{t("loop.selectorTitle")}</div>
138170
<div className={styles.menuHint}>{t("loop.selectorHint")}</div>
139171
</div>
140-
<Tooltip title={t("loop.gotoSettings")}>
141-
<button
142-
aria-label={t("loop.gotoSettings")}
143-
className={styles.settingsButton}
144-
onClick={() => {
145-
setOpen(false);
146-
navigate("/agent-config?tab=agentLoop");
147-
}}
148-
type="button"
149-
>
150-
<Settings2 size={16} />
151-
</button>
152-
</Tooltip>
153-
</div>
154-
{renderGroup(t("loop.builtInModes"), builtInModes)}
155-
{renderGroup(t("loop.customModes"), extendedModes)}
156-
{catalogError ? (
157-
<div className={styles.menuError}>
158-
<span>{t("loop.loadError")}</span>
159-
<button onClick={() => void fetchAvailableLoopModes()} type="button">
160-
{t("loop.retry")}
161-
</button>
172+
<div className={styles.menuActions}>
173+
{isMobile ? (
174+
settingsButton
175+
) : (
176+
<Tooltip title={t("loop.gotoSettings")}>{settingsButton}</Tooltip>
177+
)}
178+
{isMobile && (
179+
<button
180+
aria-label={t("common.close")}
181+
className={styles.settingsButton}
182+
onClick={() => setOpen(false)}
183+
type="button"
184+
>
185+
<X size={18} />
186+
</button>
187+
)}
162188
</div>
163-
) : null}
189+
</div>
190+
<div className={styles.modeList} role="listbox">
191+
{renderGroup(t("loop.builtInModes"), builtInModes)}
192+
{renderGroup(t("loop.customModes"), extendedModes)}
193+
{catalogError ? (
194+
<div className={styles.menuError}>
195+
<span>{t("loop.loadError")}</span>
196+
<button
197+
onClick={() => void fetchAvailableLoopModes()}
198+
type="button"
199+
>
200+
{t("loop.retry")}
201+
</button>
202+
</div>
203+
) : null}
204+
</div>
164205
</div>
165206
);
166207

208+
const triggerButton = (
209+
<button
210+
aria-expanded={open}
211+
aria-haspopup="listbox"
212+
aria-label={t("loop.selectorAria")}
213+
className={[styles.modeTrigger, className].filter(Boolean).join(" ")}
214+
disabled={catalogLoading && availableModes.length === 0}
215+
onClick={isMobile ? () => setOpen((current) => !current) : undefined}
216+
type="button"
217+
>
218+
{catalogLoading ? (
219+
<LoaderCircle className={styles.spin} size={14} />
220+
) : (
221+
<ModeIcon mode={selectedMode} />
222+
)}
223+
{!compact && (
224+
<>
225+
<span>{resolveLoopModeName(selectedMode, t, lang)}</span>
226+
<ChevronDown size={13} />
227+
</>
228+
)}
229+
</button>
230+
);
231+
232+
if (isMobile) {
233+
return (
234+
<>
235+
{triggerButton}
236+
<OsDrawer
237+
aria-label={t("loop.selectorTitle")}
238+
open={open}
239+
placement="bottom"
240+
height="auto"
241+
closable={false}
242+
destroyOnHidden
243+
rootClassName={styles.modeDrawer}
244+
onClose={() => setOpen(false)}
245+
styles={{
246+
body: { padding: 0, overflow: "hidden" },
247+
content: {
248+
borderRadius: "14px 14px 0 0",
249+
overflow: "hidden",
250+
},
251+
wrapper: { maxHeight: "min(48dvh, 400px)" },
252+
}}
253+
>
254+
{content}
255+
</OsDrawer>
256+
</>
257+
);
258+
}
259+
167260
return (
168261
<Popover
169262
arrow={false}
@@ -174,22 +267,7 @@ export function LoopModeSelector() {
174267
placement="topLeft"
175268
trigger="click"
176269
>
177-
<button
178-
aria-expanded={open}
179-
aria-haspopup="listbox"
180-
aria-label={t("loop.selectorAria")}
181-
className={styles.modeTrigger}
182-
disabled={catalogLoading && availableModes.length === 0}
183-
type="button"
184-
>
185-
{catalogLoading ? (
186-
<LoaderCircle className={styles.spin} size={14} />
187-
) : (
188-
<ModeIcon mode={selectedMode} />
189-
)}
190-
<span>{resolveLoopModeName(selectedMode, t, lang)}</span>
191-
<ChevronDown size={13} />
192-
</button>
270+
{triggerButton}
193271
</Popover>
194272
);
195273
}

console/src/components/LoopInput/index.module.less

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@
105105
padding: 5px 6px 10px;
106106
}
107107

108+
.menuActions {
109+
display: flex;
110+
flex: 0 0 auto;
111+
align-items: center;
112+
gap: 4px;
113+
}
114+
108115
.menuTitle {
109116
color: var(--ant-color-text);
110117
font-size: 13px;
@@ -135,6 +142,10 @@
135142
color: var(--ant-color-text);
136143
}
137144

145+
.modeList {
146+
min-height: 0;
147+
}
148+
138149
.modeGroup + .modeGroup {
139150
margin-top: 8px;
140151
padding-top: 8px;
@@ -235,3 +246,37 @@
235246
font: inherit;
236247
cursor: pointer;
237248
}
249+
250+
.modeDrawer {
251+
.modeMenu {
252+
width: 100%;
253+
height: auto;
254+
max-height: min(48dvh, 400px);
255+
padding: 10px 12px calc(12px + env(safe-area-inset-bottom, 0px));
256+
display: flex;
257+
flex-direction: column;
258+
overflow: hidden;
259+
}
260+
261+
.menuHeader {
262+
flex: 0 0 auto;
263+
align-items: center;
264+
padding: 2px 0 10px 6px;
265+
}
266+
267+
.settingsButton {
268+
width: 44px;
269+
height: 44px;
270+
}
271+
272+
.modeList {
273+
flex: 0 1 auto;
274+
overflow-y: auto;
275+
overscroll-behavior: contain;
276+
}
277+
278+
.modeOption {
279+
min-height: 56px;
280+
padding: 8px 10px;
281+
}
282+
}

0 commit comments

Comments
 (0)