diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx
index 6ccd1bc..2be5d4a 100644
--- a/src/features/agent/components/agent-command-panel.tsx
+++ b/src/features/agent/components/agent-command-panel.tsx
@@ -610,7 +610,13 @@ export function AgentCommandPanel({
status={streaming ? "streaming" : "ready"}
onStop={onStopPrompt}
>
- {streaming ? undefined : }
+ {streaming ? undefined : (
+
+ )}
@@ -650,13 +656,13 @@ function VoiceInputButton({
title={label}
className={cn(
"group relative grid h-8 w-8 shrink-0 place-items-center overflow-visible rounded-lg border after:absolute after:-inset-1 after:content-['']",
- "border-transparent bg-transparent text-blue-600 shadow-none",
+ "border-transparent bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
- "hover:bg-slate-50 hover:text-blue-700",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-600/25 focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-45",
- isListening &&
- "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:brightness-95"
+ isListening
+ ? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
+ : "text-slate-600 [@media(hover:hover)]:hover:border-[var(--action-border-hover)] [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
)}
disabled={disabled}
onClick={onClick}
@@ -668,10 +674,19 @@ function VoiceInputButton({
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
- reduceMotion ? { opacity: 0.38, scale: 1 } : { opacity: [0.4, 0], scale: [1, 1.28] }
+ reduceMotion
+ ? { opacity: 0.38, scale: 1 }
+ : { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
- reduceMotion ? { duration: 0.16 } : { duration: 1.7, ease: "easeOut", repeat: Infinity }
+ reduceMotion
+ ? { duration: 0.16 }
+ : {
+ duration: 1.7,
+ ease: "easeOut",
+ repeat: Infinity,
+ times: [0, 0.12, 1]
+ }
}
/>
) : null}
diff --git a/src/styles.css b/src/styles.css
index e1aebdb..bca6f6b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -928,7 +928,7 @@ button.agent-history-session-trigger:not(:disabled):active {
}
.agent-panel-primary-icon {
- border: 1px solid var(--action-blue);
+ border: 0;
background: var(--action-blue);
color: #ffffff;
box-shadow: var(--action-shadow-primary);
diff --git a/tests/browser/agent-voice-hydration.e2e.ts b/tests/browser/agent-voice-hydration.e2e.ts
index 5f07889..4592334 100644
--- a/tests/browser/agent-voice-hydration.e2e.ts
+++ b/tests/browser/agent-voice-hydration.e2e.ts
@@ -83,3 +83,48 @@ test("speech recognition writes final transcript into the prompt", async ({ page
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位");
});
+
+test("speech recognition pulse stays transparent across its loop boundary", async ({ page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(window, "SpeechRecognition", {
+ configurable: true,
+ value: class SpeechRecognition {
+ start() {}
+
+ stop() {}
+
+ abort() {}
+ }
+ });
+ });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ await page.getByRole("button", { name: "语音输入" }).click();
+
+ const pulse = page.locator('[data-slot="voice-input-pulse"]');
+ await expect(pulse).toBeVisible();
+ const seamOpacity = await pulse.evaluate((element) => {
+ const animation = element
+ .getAnimations()
+ .find((candidate) =>
+ (candidate.effect as KeyframeEffect | null)
+ ?.getKeyframes()
+ .some((keyframe) => keyframe.opacity !== undefined)
+ );
+ if (!animation?.effect) {
+ throw new Error("Voice input pulse opacity animation was not found");
+ }
+
+ animation.pause();
+ const duration = Number(animation.effect.getTiming().duration);
+ animation.currentTime = duration - 1;
+ const before = Number(window.getComputedStyle(element).opacity);
+ animation.currentTime = duration + 1;
+ const after = Number(window.getComputedStyle(element).opacity);
+
+ return { before, after };
+ });
+
+ expect(seamOpacity.before).toBeLessThan(0.02);
+ expect(seamOpacity.after).toBeLessThan(0.02);
+});