feat!: move voice mode into task behavior (#2534)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-11 23:29:46 +02:00
committed by GitHub
parent ebf2fcd275
commit 9de669558b
26 changed files with 541 additions and 166 deletions
-21
View File
@@ -1,21 +0,0 @@
import { VoiceModeSettings } from "@/components/settings/voice-mode-settings";
import { StateProvider } from "@/components/state-provider";
import { fetchUserSettings } from "@/lib/api";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
export default async function VoiceModeSettingsPage() {
let initialState = {};
try {
const response = await fetchUserSettings({ cache: "no-store" });
const mapped = mapUserSettingsResponse(response);
initialState = { userSettings: mapped.loaded ? mapped : undefined };
} catch {
initialState = {};
}
return (
<StateProvider initialState={initialState}>
<VoiceModeSettings />
</StateProvider>
);
}
@@ -12,7 +12,6 @@ import {
IconKey,
IconLayoutDashboard,
IconMessageCircle,
IconMicrophone,
IconPalette,
IconPlugConnected,
IconPuzzle,
@@ -42,7 +41,6 @@ import {
TASK_BEHAVIOR_SETTINGS_HREF,
TERMINAL_EDITORS_SETTINGS_HREF,
UTILITY_AGENTS_SETTINGS_HREF,
VOICE_MODE_SETTINGS_HREF,
WORKSPACES_SETTINGS_HREF,
} from "@/lib/settings-discovery/catalog";
@@ -162,7 +160,6 @@ export const SETTINGS_MENU_SECTIONS: SettingsMenuSection[] = [
},
{ href: PROMPTS_SETTINGS_HREF, labelKey: "common:prompts", icon: IconMessageCircle },
{ href: UTILITY_AGENTS_SETTINGS_HREF, labelKey: "settings:utilityAgents", icon: IconWand },
{ href: VOICE_MODE_SETTINGS_HREF, labelKey: "settings:voiceMode", icon: IconMicrophone },
],
},
// Only rendered when the auth feature exposes at least one of its rows.
@@ -24,7 +24,6 @@ const NAV_LABELS: Array<[key: string, english: string]> = [
["settings:taskBehavior", "Task Behavior"],
["system:navDataStorage", "Data & Logs"],
["common:prompts", "Prompts"],
["settings:voiceMode", "Voice Mode"],
["settings:utilityAgents", "Utility Agents"],
["settings:globalSecrets", "Global Secrets"],
["common:externalMcp", "External MCP"],
@@ -52,12 +51,11 @@ const NAV_LABELS: Array<[key: string, english: string]> = [
* through ONE key. Two keys with identical English today is a translator's
* licence to make them disagree tomorrow.
*
* `sidebar:voiceMode` and `sidebar:utilityAgents` were exactly that mistake and
* `sidebar:utilityAgents` was exactly that mistake and
* were removed in favour of the existing `settings:` keys.
*/
const OWNED_BY_ANOTHER_NAMESPACE = [
"Prompts",
"Voice Mode",
"Utility Agents",
"Global Secrets",
"External MCP",
@@ -8,8 +8,8 @@ const MAIN_WORKSPACE_NAME = "Main Workspace";
// The active workspace carries a badge, so its row's accessible name is the
// name plus that badge — match on the name rather than the whole string.
const WORKSPACE_ROW = new RegExp(MAIN_WORKSPACE_NAME);
const VOICE_MODE_LABEL = "Voice Mode";
const SEARCH_LABEL = "Search settings";
const VOICE_MODE_LABEL = "Voice Mode";
const WORKSPACES_ROW_KEY = "row:/settings/workspaces";
const AGENTS_ROW_KEY = "row:/settings/agents";
const WORKSPACE_KEY = `workspace:${MAIN_WORKSPACE_ID}`;
@@ -188,12 +188,13 @@ describe("SettingsTree static menu", () => {
expect(screen.queryByRole("link", { name: "API Tokens" })).toBeNull();
});
it("keeps Voice Mode as a page row under Agents", () => {
render(<SettingsTree pathname="/settings/voice-mode" />);
it("keeps Voice Mode inside Task Behavior instead of as a page row", () => {
render(<SettingsTree pathname="/settings/preferences/task-behavior" />);
const link = screen.getByRole("link", { name: VOICE_MODE_LABEL });
expect(link.getAttribute("href")).toBe("/settings/voice-mode");
expect(link.getAttribute("data-active")).toBe("true");
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
expect(screen.getByRole("link", { name: "Task Behavior" }).getAttribute("data-active")).toBe(
"true",
);
});
it("puts the count straight after the title, not out at the row's edge", () => {
@@ -487,7 +488,7 @@ describe("SettingsTree search", () => {
render(<SettingsTree pathname="/settings" />);
const search = screen.getByRole("searchbox", { name: SEARCH_LABEL });
expect(screen.getByRole("link", { name: VOICE_MODE_LABEL })).toBeTruthy();
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
fireEvent.change(search, { target: { value: "font size" } });
@@ -521,7 +522,7 @@ describe("SettingsTree search", () => {
fireEvent.keyDown(search, { key: "Escape" });
expect((search as HTMLInputElement).value).toBe("");
expect(screen.getByRole("link", { name: VOICE_MODE_LABEL })).toBeTruthy();
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
});
it("announces an empty result without rendering the normal menu", () => {
@@ -532,6 +533,6 @@ describe("SettingsTree search", () => {
});
expect(screen.getByText("No matching settings")).toBeTruthy();
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
expect(screen.queryByRole("link", { name: "Voice Mode" })).toBeNull();
});
});
@@ -15,7 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Separator } from "@kandev/ui/separator";
import { SettingsSection } from "@/components/settings/settings-section";
import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { getBackendConfig } from "@/lib/config";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import {
@@ -67,7 +67,7 @@ export function ExternalMcpSettings() {
<Separator />
<SettingsSection
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.externalMcpEndpoints}
discoveryTargetId={SETTINGS_TARGETS.externalMcpEndpoints}
icon={<IconPlugConnected className="h-5 w-5" />}
title={t("settings:externalMcpEndpoints")}
// `localhost` is a hostname the user types, not copy. Interpolated so
@@ -241,7 +241,7 @@ function SnippetsSection({
return (
<SettingsSection
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.externalMcpSnippets}
discoveryTargetId={SETTINGS_TARGETS.externalMcpSnippets}
icon={<IconCode className="h-5 w-5" />}
title={t("settings:externalMcpSnippets")}
description={t("settings:externalMcpSnippetsDescription")}
@@ -81,7 +81,6 @@ const SEGMENT_LABEL_KEYS: Record<string, string> = {
updates: "system:navUpdates",
users: "system:navUsers",
"utility-agents": "settings:utilityAgents",
"voice-mode": "settings:voiceMode",
workflows: "workflows:workflows",
workspace: "common:workspace",
workspaces: "common:workspaces",
@@ -7,6 +7,7 @@ import { TaskActionsSettings } from "@/components/settings/general-settings";
import { SettingsSection } from "@/components/settings/settings-section";
import { SettingsTarget } from "@/components/settings/settings-target";
import { MessageQueueSettings } from "@/components/settings/system/message-queue-settings";
import { VoiceModeSettings } from "@/components/settings/voice-mode-settings";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
/** Task behavior: the former Task Actions and Message Queue pages as one page. */
@@ -25,6 +26,8 @@ export function TaskBehaviorSettings() {
<MessageQueueSettings />
</SettingsSection>
</SettingsTarget>
<Separator />
<VoiceModeSettings />
</div>
);
}
@@ -8,7 +8,7 @@ import { Label } from "@kandev/ui/label";
import type { UtilityAgent } from "@/lib/api/domains/utility-api";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { SettingsCard } from "@/components/settings/settings-card";
import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { isUtilityAgentDirty } from "@/components/settings/utility-dirty";
import {
UtilityAgentProfilePicker,
@@ -35,7 +35,7 @@ export function DefaultModelSection({
return (
<SettingsCard
isDirty={isDirty}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityDefaultModel}
discoveryTargetId={SETTINGS_TARGETS.utilityDefaultModel}
data-testid="utility-default-model-card"
>
<CardHeader>
@@ -152,7 +152,7 @@ export function PerActionOverridesSection({
savedBuiltins.find((saved) => saved.id === agent.id),
),
)}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityActions}
discoveryTargetId={SETTINGS_TARGETS.utilityActions}
data-testid="utility-actions-card"
>
<CardHeader>
@@ -243,7 +243,7 @@ export function CustomAgentsSection({
const { t } = useTranslation();
return (
<SettingsCard
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.utilityCustomAgents}
discoveryTargetId={SETTINGS_TARGETS.utilityCustomAgents}
data-testid="utility-custom-agents-card"
>
<CardHeader>
@@ -37,7 +37,7 @@ import type {
} from "@/lib/types/http-voice";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { SettingsCard } from "./settings-card";
import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
const VOICE_SECURE_SCHEME = "HTTPS";
const VOICE_LOCALHOST_HOST = "localhost";
@@ -267,7 +267,7 @@ function EngineCard({ caps }: { caps: VoiceCapabilities }) {
return (
<SettingsCard
isDirty={voiceMode.engine !== savedVoiceMode.engine}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceEngine}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceEngine}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceEngineTitle")}</CardTitle>
@@ -405,7 +405,7 @@ function BehaviorCard() {
voiceMode.mode !== savedVoiceMode.mode ||
voiceMode.autoSend !== savedVoiceMode.autoSend;
return (
<SettingsCard isDirty={isDirty} discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceBehavior}>
<SettingsCard isDirty={isDirty} discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceBehavior}>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceBehavior")}</CardTitle>
</CardHeader>
@@ -428,7 +428,7 @@ function WhisperModelCard() {
return (
<SettingsCard
isDirty={voiceMode.whisperWebModel !== savedVoiceMode.whisperWebModel}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceModel}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceModel}
>
<CardHeader>
<CardTitle className="text-base">{t("settings:voiceWhisperModelTitle")}</CardTitle>
@@ -476,7 +476,7 @@ function EnableCard() {
return (
<SettingsCard
isDirty={voiceMode.enabled !== savedVoiceMode.enabled}
discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceEnable}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceEnable}
data-testid="voice-enable-card"
>
<CardHeader>
@@ -558,7 +558,7 @@ function VoiceShortcutCard() {
}, [overrides, persist]);
return (
<SettingsCard isDirty={isDirty} discoveryTargetId={STANDALONE_SETTINGS_TARGETS.voiceShortcut}>
<SettingsCard isDirty={isDirty} discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceShortcut}>
<CardHeader>
<CardTitle className="text-base">
{/* The shortcut's own label comes from the shared keyboard registry,
@@ -596,6 +596,7 @@ function VoiceModeSettingsContent() {
icon={<IconMicrophone className="h-5 w-5" />}
title={t("settings:voiceMode")}
description={t("settings:voiceModePageDescription")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceMode}
>
<div className="space-y-4">
<EnableCard />
@@ -8,8 +8,8 @@ import { SessionPage } from "../../pages/session-page";
// the `mobile-chrome` Playwright project picks it up.
//
// Two assertions:
// 1. The Voice Mode settings page renders all cards at mobile width when
// navigated to directly — guards against the "setting missing" symptom.
// 1. The Task Behavior page renders the Voice Mode section and all cards at mobile width when
// reached through the settings index — guards against the "setting missing" symptom.
// 2. The mic button mounts on the mobile chat composer with the coarse-pointer
// override: a user with stored `voiceMode.mode = "hold"` gets toggle
// behaviour (data-effective-mode = "toggle") and a 40px touch target.
@@ -26,12 +26,17 @@ async function seedTask(apiClient: ApiClient, seedData: SeedData, title: string)
test.describe("Mobile voice mode", () => {
test.describe.configure({ retries: 1, timeout: 60_000 });
test("Voice Mode settings page renders every card at mobile width", async ({ testPage }) => {
await testPage.goto("/settings/voice-mode");
test("Voice Mode settings section renders every card at mobile width", async ({
testPage,
prCapture,
}) => {
await testPage.goto("/settings");
const index = testPage.getByTestId("settings-index");
await index.getByRole("link", { name: /^Task Behavior/ }).click();
await expect(testPage).toHaveURL(/\/settings\/preferences\/task-behavior$/);
// Page header confirms route resolved to the right shell. The page title
// is rendered by SettingsSection — match the heading text exactly so we
// don't pick up the sidebar entry that shares the label.
// The merged page keeps the Voice Mode section heading visible at the
// same route as the other task behavior settings.
await expect(testPage.getByRole("heading", { name: "Voice Mode", exact: true })).toBeVisible({
timeout: 15_000,
});
@@ -44,6 +49,15 @@ test.describe("Mobile voice mode", () => {
await expect(testPage.getByText("Behavior", { exact: true })).toBeVisible();
await expect(testPage.getByText("Whisper Web Model")).toBeVisible();
await expect(testPage.getByText(/Shortcut$/)).toBeVisible();
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
await testPage
.getByRole("heading", { name: "Voice Mode", exact: true })
.scrollIntoViewIfNeeded();
await prCapture.screenshot("task-behavior-voice-mode-mobile", {
caption: "Mobile Task Behavior page with Voice Mode settings",
});
// (The legacy `button[data-sidebar="trigger"]` mobile settings-sidebar
// trigger was removed by the unified-AppSidebar overhaul — settings nav is
// now the footer-gear takeover, and the global rail is hidden on mobile.)
@@ -272,6 +272,10 @@ const SCREENS: Screen[] = [
name: "settings — task behavior",
url: "/settings/preferences/task-behavior",
anchor: "[data-testid=archive-confirmation-card]",
// Voice Mode reuses the shared keyboard shortcut registry. Its persisted
// shortcut label and rendered key chord are intentionally ASCII values,
// not translatable Task Behavior copy.
allow: ["Voice Input", "Ctrl+Shift+M"],
},
{
name: "settings — plugins",
@@ -65,6 +65,28 @@ test.describe("Settings index on desktop", () => {
await expect(active).toHaveAccessibleName(/Appearance/);
});
test("owns Voice Mode inside Task Behavior", async ({ testPage, prCapture }) => {
await testPage.goto("/settings/preferences/task-behavior");
const settingsNav = testPage.getByTestId("app-sidebar-settings-mode");
await expect(
settingsNav.getByRole("link", { name: "Task Behavior", exact: true }),
).toBeVisible();
await expect(settingsNav.getByRole("link", { name: "Voice Mode", exact: true })).toHaveCount(0);
await expect(testPage.getByRole("heading", { name: "Voice Mode", exact: true })).toBeVisible();
await expect(testPage.getByTestId("voice-enable-card")).toBeVisible();
await expect(testPage.getByText("Transcription Engine", { exact: true })).toBeVisible();
await expect(testPage.getByText("Behavior", { exact: true })).toBeVisible();
await expect(testPage.getByText("Whisper Web Model", { exact: true })).toBeVisible();
await expect(testPage.getByText(/Shortcut$/, { exact: true })).toBeVisible();
await testPage
.getByRole("heading", { name: "Voice Mode", exact: true })
.scrollIntoViewIfNeeded();
await prCapture.screenshot("task-behavior-voice-mode-desktop", {
caption: "Desktop Task Behavior page with Voice Mode settings",
});
});
test("redirects the /settings/general prefix to the Appearance page", async ({ testPage }) => {
await testPage.goto("/settings/general");
+2 -3
View File
@@ -265,7 +265,7 @@ export const i18nGuardFiles = [
// The pages render from general-settings.tsx (already listed above); these
// entries add the merged pages plus the per-setting cards those pages own.
// Shortcut *names* still come from `lib/keyboard/shortcut-overrides.ts`, a
// registry shared with the un-migrated voice-mode page — deliberately not
// registry shared with the voice-mode settings section — deliberately not
// migrated here.
"components/settings/task-behavior-settings.tsx",
"components/settings/terminal-editors-settings.tsx",
@@ -1007,7 +1007,7 @@ export const i18nGuardFiles = [
// of them holds any copy at all, but the next migration should not have to
// rediscover which ones are live:
//
// - `app/settings/prompts/page.tsx` and `app/settings/voice-mode/page.tsx`
// - `app/settings/prompts/page.tsx`
// are unreferenced. `SETTINGS_ROUTES` in `src/settings-routes.tsx` renders
// `<PromptsSettings />` and `<VoiceModeSettings />` directly, so the SSR
// prefetch in those pages never runs. `external-mcp` and `utility-agents`
@@ -1096,7 +1096,6 @@ export const i18nGuardFiles = [
"app/settings/external-mcp/**/*.{ts,tsx}",
"app/settings/prompts/**/*.{ts,tsx}",
"app/settings/utility-agents/**/*.{ts,tsx}",
"app/settings/voice-mode/**/*.{ts,tsx}",
"components/settings/changelog-notification-card.tsx",
"components/settings/changelog-settings.tsx",
"components/settings/config-chat-agent-section.tsx",
+35 -24
View File
@@ -7,6 +7,29 @@ import { SETTINGS_DISCOVERY_DEFINITIONS, SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS } f
import { resolveSettingsDiscovery } from "./resolve";
const GITHUB_CONNECTION_ID = "integration-github-connection";
const STABLE_CONTROL_IDS = [
"appearance-color-theme",
"appearance-startup-page",
"appearance-display-language",
"terminal-preferred-shell",
"notifications-desktop",
"notifications-sound",
"keyboard-submit-shortcut",
"keyboard-command-shortcuts",
"task-actions-agent-profile",
"task-actions-archive-confirmation",
"task-actions-transcript-navigation",
"layouts-profiles",
"sprites-connection",
"sprites-instances",
"voice-enable",
"voice-engine",
"voice-behavior",
"utility-default-model",
"utility-actions",
"external-mcp-endpoints",
"external-mcp-snippets",
] as const;
const translate = (key: string) =>
(
@@ -44,30 +67,18 @@ describe("settings discovery catalog invariants", () => {
it("indexes stable controls across general and standalone settings", () => {
const ids = SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.id);
expect(ids).toEqual(
expect.arrayContaining([
"appearance-color-theme",
"appearance-startup-page",
"appearance-display-language",
"terminal-preferred-shell",
"notifications-desktop",
"notifications-sound",
"keyboard-submit-shortcut",
"keyboard-command-shortcuts",
"task-actions-agent-profile",
"task-actions-archive-confirmation",
"task-actions-transcript-navigation",
"layouts-profiles",
"sprites-connection",
"sprites-instances",
"voice-enable",
"voice-engine",
"voice-behavior",
"utility-default-model",
"utility-actions",
"external-mcp-endpoints",
"external-mcp-snippets",
]),
expect(ids).toEqual(expect.arrayContaining([...STABLE_CONTROL_IDS]));
const voiceEntries = SETTINGS_DISCOVERY_DEFINITIONS.filter((entry) =>
entry.id.startsWith("voice-"),
);
expect(voiceEntries).toHaveLength(6);
expect(new Set(voiceEntries.map((entry) => entry.href))).toEqual(
new Set(["/settings/preferences/task-behavior"]),
);
expect(new Set(voiceEntries.map((entry) => entry.groupId))).toEqual(new Set(["preferences"]));
expect(voiceEntries.find((entry) => entry.id === "voice-mode")?.targetId).toBe(
"setting-voice-mode",
);
});
@@ -13,6 +13,7 @@ const NOTIFICATIONS_ID = "preferences-notifications";
export const NOTIFICATIONS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/notifications`;
export const KEYBOARD_SHORTCUTS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/keyboard-shortcuts`;
const TASK_BEHAVIOR_ID = "preferences-task-behavior";
const VOICE_MODE_DISCOVERY_ID = "voice-mode";
export const TASK_BEHAVIOR_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/task-behavior`;
export const GENERAL_SETTINGS_TARGETS = {
colorTheme: "setting-color-theme",
@@ -36,6 +37,12 @@ export const GENERAL_SETTINGS_TARGETS = {
unreadMessages: "setting-unread-messages",
transcriptNavigation: "setting-transcript-navigation",
messageQueue: "setting-message-queue",
voiceMode: "setting-voice-mode",
voiceEnable: "setting-voice-enable",
voiceEngine: "setting-voice-engine",
voiceBehavior: "setting-voice-behavior",
voiceModel: "setting-voice-model",
voiceShortcut: "setting-voice-shortcut",
spritesConnection: "setting-sprites-connection",
spritesInstances: "setting-sprites-instances",
} as const;
@@ -347,4 +354,68 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
targetId: GENERAL_SETTINGS_TARGETS.messageQueue,
order: 66,
},
{
id: VOICE_MODE_DISCOVERY_ID,
kind: "section",
labelKey: "settings:voiceMode",
parentId: TASK_BEHAVIOR_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceMode,
order: 67,
},
{
id: "voice-enable",
kind: "control",
labelKey: "settings:voiceEnableTitle",
aliasesKey: "settings:discoveryAliasesVoiceEnable",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceEnable,
order: 68,
},
{
id: "voice-engine",
kind: "control",
labelKey: "settings:voiceEngineTitle",
aliasesKey: "settings:discoveryAliasesVoiceEngine",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceEngine,
order: 69,
},
{
id: "voice-behavior",
kind: "control",
labelKey: "settings:voiceBehavior",
aliasesKey: "settings:discoveryAliasesVoiceBehavior",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceBehavior,
order: 70,
},
{
id: "voice-model",
kind: "control",
labelKey: "settings:voiceWhisperModelTitle",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceModel,
order: 71,
},
{
id: "voice-shortcut",
kind: "control",
labelKey: "settings:discoveryVoiceShortcut",
aliasesKey: "settings:discoveryAliasesVoiceShortcut",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "preferences",
href: TASK_BEHAVIOR_SETTINGS_HREF,
targetId: GENERAL_SETTINGS_TARGETS.voiceShortcut,
order: 72,
},
];
@@ -1,19 +1,12 @@
import type { SettingsDiscoveryDefinition } from "../types";
export const PROMPTS_SETTINGS_HREF = "/settings/prompts";
export const VOICE_MODE_SETTINGS_HREF = "/settings/voice-mode";
export const UTILITY_AGENTS_SETTINGS_HREF = "/settings/utility-agents";
export const EXTERNAL_MCP_SETTINGS_HREF = "/settings/external-mcp";
export const PLUGINS_SETTINGS_HREF = "/settings/plugins";
export const SECRETS_SETTINGS_HREF = "/settings/secrets";
const VOICE_MODE_DISCOVERY_ID = "voice-mode";
const UTILITY_AGENTS_DISCOVERY_ID = "utility-agents";
export const STANDALONE_SETTINGS_TARGETS = {
voiceEnable: "setting-voice-enable",
voiceEngine: "setting-voice-engine",
voiceBehavior: "setting-voice-behavior",
voiceModel: "setting-voice-model",
voiceShortcut: "setting-voice-shortcut",
export const SETTINGS_TARGETS = {
utilityDefaultModel: "setting-utility-default-model",
utilityActions: "setting-utility-actions",
utilityCustomAgents: "setting-utility-custom-agents",
@@ -31,68 +24,6 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
href: PROMPTS_SETTINGS_HREF,
order: 500,
},
{
id: VOICE_MODE_DISCOVERY_ID,
kind: "page",
labelKey: "settings:voiceMode",
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
order: 510,
},
{
id: "voice-enable",
kind: "control",
labelKey: "settings:voiceEnableTitle",
aliasesKey: "settings:discoveryAliasesVoiceEnable",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceEnable,
order: 511,
},
{
id: "voice-engine",
kind: "control",
labelKey: "settings:voiceEngineTitle",
aliasesKey: "settings:discoveryAliasesVoiceEngine",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceEngine,
order: 512,
},
{
id: "voice-behavior",
kind: "control",
labelKey: "settings:voiceBehavior",
aliasesKey: "settings:discoveryAliasesVoiceBehavior",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceBehavior,
order: 513,
},
{
id: "voice-model",
kind: "control",
labelKey: "settings:voiceWhisperModelTitle",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceModel,
order: 514,
},
{
id: "voice-shortcut",
kind: "control",
labelKey: "settings:discoveryVoiceShortcut",
aliasesKey: "settings:discoveryAliasesVoiceShortcut",
parentId: VOICE_MODE_DISCOVERY_ID,
groupId: "agents",
href: VOICE_MODE_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.voiceShortcut,
order: 515,
},
{
id: UTILITY_AGENTS_DISCOVERY_ID,
kind: "page",
@@ -109,7 +40,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
parentId: UTILITY_AGENTS_DISCOVERY_ID,
groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityDefaultModel,
targetId: SETTINGS_TARGETS.utilityDefaultModel,
order: 521,
},
{
@@ -120,7 +51,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
parentId: UTILITY_AGENTS_DISCOVERY_ID,
groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityActions,
targetId: SETTINGS_TARGETS.utilityActions,
order: 522,
},
{
@@ -130,7 +61,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
parentId: UTILITY_AGENTS_DISCOVERY_ID,
groupId: "agents",
href: UTILITY_AGENTS_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.utilityCustomAgents,
targetId: SETTINGS_TARGETS.utilityCustomAgents,
order: 523,
},
{
@@ -157,7 +88,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
parentId: "external-mcp",
groupId: "workspaces",
href: EXTERNAL_MCP_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.externalMcpEndpoints,
targetId: SETTINGS_TARGETS.externalMcpEndpoints,
order: 531,
},
{
@@ -167,7 +98,7 @@ export const STANDALONE_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
parentId: "external-mcp",
groupId: "workspaces",
href: EXTERNAL_MCP_SETTINGS_HREF,
targetId: STANDALONE_SETTINGS_TARGETS.externalMcpSnippets,
targetId: SETTINGS_TARGETS.externalMcpSnippets,
order: 532,
},
{
+9
View File
@@ -171,6 +171,15 @@ describe("message queue settings route", () => {
});
});
describe("settings route ownership", () => {
it("does not register the removed standalone Voice Mode route", () => {
expect(SETTINGS_ROUTE_PATHS).not.toContain("/settings/voice-mode");
const route = renderSettingsRoute("/settings/voice-mode");
expect(isValidElement(route)).toBe(true);
expect(((route as ReactElement).type as { name?: string }).name).toBe("SettingsRouteFallback");
});
});
describe("renderSettingsRoute", () => {
it("directs update notification settings to Notifications", () => {
render(renderSettingsRoute("/settings/system/updates"));
-2
View File
@@ -58,7 +58,6 @@ import { SystemRouteShell } from "@/components/settings/system/system-route-shel
import { UIStateCard } from "@/components/settings/system/ui-state-card";
import { UpdatesCard } from "@/components/settings/system/updates-card";
import { VersionSummaryCard } from "@/components/settings/system/version-summary-card";
import { VoiceModeSettings } from "@/components/settings/voice-mode-settings";
import {
WorkspaceSettingsShell,
type WorkspaceSettingsTab,
@@ -167,7 +166,6 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
"/settings/utility-agents": () => <UtilityAgentsSettingsPage />,
"/settings/external-mcp": () => <ExternalMcpPage />,
"/settings/prompts": () => <PromptsSettings />,
"/settings/voice-mode": () => <VoiceModeSettings />,
"/settings/plugins": () => <PluginsSettingsPage />,
// Integrations are per-workspace pages now; the old install-level paths
// forward into the active workspace's Integrations tab.
+124
View File
@@ -0,0 +1,124 @@
---
spec: docs/specs/ui/voice-mode-task-behavior.md
created: 2026-08-11
status: implemented
---
# Implementation Plan: Voice Mode In Task Behavior
## Overview
Reuse the existing `VoiceModeSettings` section inside Task Behavior, then make Task Behavior the
single navigation and discovery owner for voice preferences. Remove the former route without a
compatibility redirect. Update focused component, catalog, route, desktop E2E, and mobile E2E
coverage before capturing both affected viewports and publishing those screenshots on the PR.
## Frontend
### Settings composition and navigation
- Update `apps/web/components/settings/task-behavior-settings.tsx` to render
`VoiceModeSettings` after the existing message-queue section, separated consistently from the
preceding content. Reuse the component and its `VoiceDraftProvider`; do not duplicate its save
or capability logic.
- Remove the Voice Mode row and unused microphone import from
`apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts`.
- Remove `/settings/voice-mode` from `apps/web/src/settings-routes.tsx` so Task Behavior is the
only route that renders Voice Mode.
- Remove obsolete standalone breadcrumb ownership and compatibility constants when no remaining
caller consumes them.
### Settings discovery
- Move the Voice Mode page and control definitions from
`apps/web/lib/settings-discovery/catalog/standalone.ts` into
`apps/web/lib/settings-discovery/catalog/preferences.ts` under the Task Behavior page.
- Retarget every voice result to `TASK_BEHAVIOR_SETTINGS_HREF`, preserve the current stable target
IDs used by `VoiceModeSettings`, and make Task Behavior the parent page so search breadcrumbs and
grouping describe the canonical location.
- Remove `VOICE_MODE_SETTINGS_HREF` and update catalog tests after all voice discovery entries use
`TASK_BEHAVIOR_SETTINGS_HREF`.
### Mobile design contract
- **Outcome and entry point:** desktop and mobile users enter through the existing Task Behavior
menu row and reach the same voice controls on the merged page.
- **Exemplar:** reuse the shipped Task Behavior mobile surface covered by
`mobile-message-queue-settings.spec.ts`; it contributes direct navigation, one settings scroll
owner, shared floating-save behavior, and narrow-width overflow expectations.
- **Hierarchy and action:** task actions remain first, message queue second, and Voice Mode third;
voice cards retain their current order and touch behavior. The shared **Save changes** action
remains the only persistence action.
- **Surface rationale:** these are infrequent preferences with linear content, so extending the
existing direct-navigation Settings page is more appropriate than a drawer or a new nested route.
- **Shared versus responsive behavior:** component state, draft coordination, search targeting, and
controls are shared. Mobile uses the existing settings shell without mounting a compressed
desktop-only composition. The settings content remains the single scroll owner.
- **Proof:** desktop and Pixel 5 E2E navigate through Task Behavior, verify Voice Mode controls and
the absence of the menu row, and capture the settled merged page. Mobile additionally proves
scrolling and no document horizontal overflow.
## Tests
- **What:** Task Behavior owns the voice section while the main menu does not expose a Voice Mode
page row. **Files:** `apps/web/components/settings/voice-mode-settings.test.tsx`,
`apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx`, and
`apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts` if its page-copy
contract changes. **How:** component/render tests assert composition, control availability, and
menu absence without changing voice draft semantics.
- **What:** the former URL is absent and all voice discovery entries resolve to Task Behavior with
stable target IDs. **Files:** `apps/web/src/settings-routes.test.ts` and
`apps/web/lib/settings-discovery/catalog.test.ts`. **How:** focused route-resolution and catalog
ownership tests.
## E2E Tests
- **Scenario:** desktop Settings navigation contains Task Behavior but no Voice Mode row, and the
merged page exposes the existing voice controls. **File:** extend an existing focused settings
spec or add `apps/web/e2e/tests/settings/task-behavior-voice-mode.spec.ts`.
**What to verify:** menu information architecture, section order, voice control availability,
removed legacy route, and a saved voice draft where focused setup permits.
- **Scenario:** Pixel 5 navigation reaches the merged page and all voice controls remain reachable.
**File:** migrate `apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts` to the Task Behavior entry
point. **What to verify:** menu-row absence, section/control visibility, settings-container
scrolling, and no document horizontal overflow.
- Use the existing `prCapture` fixture or a disposable capture spec to create fresh desktop and
mobile PNGs in `apps/web/.pr-assets` during the same managed E2E invocation when possible.
Validate and compress both assets before PR creation, then publish them through the PR skill's
orphan media branch flow and append a `## Screenshots` section to the PR body.
## Verification Results
Implemented. Focused unit coverage passes 95 tests across the Voice Mode component, settings menu,
discovery catalog, and route registry. Typecheck, i18n checks, i18n ratchet, formatting, public-doc
tests, desktop E2E, and mobile E2E all pass. Desktop and Pixel 5 screenshots are captured in the
ignored `.pr-assets` directory and published through the PR media branch. The ready PR is
[#2534](https://github.com/kdlbs/kandev/pull/2534); media is on
`media/pr-2534-screenshots` at commit `d8e82197519c4ef6b2bb311eb7ee8e7224d166b1`.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [Task 01: Consolidate Voice Mode settings](task-01-consolidate-settings.md)
Wave 2:
- [x] [Task 02: Verify responsive UI and publish PR](task-02-e2e-screenshots-pr.md) (depends on
Task 01)
The tasks are sequential because the E2E routes, screenshots, and PR must exercise the completed
navigation and discovery changes.
## Risks
- Moving discovery definitions without preserving target IDs can make search open the right page
but fail to scroll to the selected control.
- Rendering two independent voice draft providers on one route would create competing save
contributors; Task Behavior must mount the existing section exactly once.
- The longer merged page can expose scroll-clearance regressions around the floating Save action,
especially on mobile, so E2E must exercise the bottom controls rather than only assert headings.
- Removing the legacy route is intentionally breaking; tests must ensure no discovery or menu
entry still produces the dead URL.
- PR screenshots must come from isolated synthetic E2E state and fresh affected-viewport captures;
they must not include local user data or be committed to the feature branch.
@@ -0,0 +1,75 @@
---
id: "01-consolidate-settings"
title: "Consolidate Voice Mode settings"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/voice-mode-task-behavior.md"
---
# Task 01: Consolidate Voice Mode Settings
## Acceptance
- Task Behavior renders the existing Voice Mode section after message queue with unchanged voice
controls and shared save/discard semantics.
- The Settings menu has no Voice Mode row, and `/settings/voice-mode` is removed without a
compatibility redirect.
- Voice page/control discovery results belong to Task Behavior, retain stable target IDs, and open
the matching section or control.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web test -- --run components/settings/voice-mode-settings.test.tsx components/app-sidebar/sections/settings/settings-tree-render.test.tsx components/app-sidebar/sections/settings/settings-nav-copy.test.ts src/settings-routes.test.ts lib/settings-discovery/catalog.test.ts
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
```
Follow RED-GREEN-REFACTOR: first change the focused menu, route, and discovery expectations so they
fail against the standalone page, then implement the smallest consolidation that passes.
## Files Likely Touched
- `apps/web/components/settings/task-behavior-settings.tsx`
- `apps/web/components/settings/voice-mode-settings.tsx` only if a test seam is needed
- `apps/web/components/settings/voice-mode-settings.test.tsx`
- `apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts`
- `apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx`
- `apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts`
- `apps/web/src/settings-routes.tsx`
- `apps/web/src/settings-routes.test.ts`
- `apps/web/lib/settings-discovery/catalog/preferences.ts`
- `apps/web/lib/settings-discovery/catalog/standalone.ts`
- `apps/web/lib/settings-discovery/catalog.test.ts`
- relevant locale catalogs only if existing copy keys require relocation
- this task file and `plan.md`
## Inputs
- Spec `What` and the menu, removed-route, discovery, and persistence scenarios.
- Existing `VoiceModeSettings`, `TaskBehaviorSettings`, and settings route registry.
- Settings discovery target IDs and the shared save coordinator contract in ADR 0046.
## Mobile Design Contract
Reuse the existing Task Behavior direct-navigation page and shared Settings shell. Desktop and
mobile share the same linear section order and voice controls; the settings content stays the one
scroll owner, with no new drawer, nested route, or mobile-only state.
## Risks
- Preserve every discovery target ID and mount one voice draft provider to avoid broken search
scrolling or duplicate save contributors.
- Remove obsolete route, breadcrumb, and href constants so internal navigation cannot emit the
intentionally unsupported URL.
## Results
- Focused web tests pass: 5 files, 95 tests.
- `pnpm run typecheck` passes.
- `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass.
- The standalone route, menu row, breadcrumb ownership, and discovery target were removed without
a compatibility redirect; Voice Mode now renders once under Task Behavior.
@@ -0,0 +1,75 @@
---
id: "02-e2e-screenshots-pr"
title: "Verify responsive UI and publish PR"
status: done
wave: 2
depends_on: ["01-consolidate-settings"]
plan: "plan.md"
spec: "../../specs/ui/voice-mode-task-behavior.md"
---
# Task 02: Verify Responsive UI And Publish PR
## Acceptance
- Desktop and Pixel 5 E2E navigate through Task Behavior, prove the Voice Mode menu row is absent,
and exercise the merged voice section without horizontal overflow.
- Fresh synthetic desktop and mobile screenshots are captured, inspected, compressed, and listed
in a valid `.pr-assets/manifest.json` without entering the feature branch history.
- Task-defined checks pass, changes are conventionally committed and pushed, and a ready PR uses
the repository template with both screenshots embedded under `## Screenshots`.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps/web && pnpm e2e:run --project chromium --project mobile-chrome e2e/tests/settings/task-behavior-voice-mode.spec.ts e2e/tests/chat/mobile-voice-mode.spec.ts
cd apps/web && test -s .pr-assets/manifest.json
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
```
If the managed runner syntax cannot capture both projects in one invocation, use a single
multi-project disposable capture spec or preserve and merge the first manifest/assets before the
second run. Revalidate every manifest path immediately before publication.
## Files Likely Touched
- `apps/web/e2e/tests/settings/task-behavior-voice-mode.spec.ts` or the nearest existing desktop
Settings spec
- `apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts`
- an ignored disposable capture spec only if existing E2E cannot use `prCapture` directly
- this task file and `plan.md`
## Inputs
- Completed Task 01 UI, removed route, menu, and discovery behavior.
- `/e2e`, `/mobile-parity`, `/product-demo-seeding`, `/product-video-capture`, `/commit`, `/push`,
and `/pr` instructions.
- The existing Task Behavior mobile exemplar and `prCapture` fixture.
## Mobile Design Contract
Enter through the Task Behavior menu row on Pixel 5, scroll the existing settings content to the
Voice Mode section, operate a representative control, and assert all required cards are reachable.
The shared settings container is the single vertical scroll owner and the document must not scroll
horizontally. Capture the native mobile page rather than cropping a desktop image.
## Risks
- The merged page is taller than the former pages; exercise the last voice card and floating save
clearance rather than capturing only the heading.
- Capture only isolated synthetic E2E state. Validate that screenshots contain no credentials,
tokens, personally identifiable information, or local filesystem paths.
- GitHub screenshots belong on an orphan media branch and must be SHA-pinned in the PR body, never
committed to the feature branch.
## Results
- Desktop E2E passes for Task Behavior ownership and menu-row absence.
- Mobile E2E passes both the merged Settings page and the mobile chat mic behavior.
- Fresh synthetic desktop and Pixel 5 screenshots are captured and inspected in the ignored
`.pr-assets` directory; the manifest contains both assets.
- Public-doc tests and `git diff --check` pass.
- Ready PR: https://github.com/kdlbs/kandev/pull/2534. Screenshot media is published at commit
`d8e82197519c4ef6b2bb311eb7ee8e7224d166b1` on `media/pr-2534-screenshots`; no screenshot
binaries were added to the feature branch.
+11 -3
View File
@@ -24,7 +24,7 @@
"docs": ["use-kandev", "tasks-and-workflows", "configuration"],
"sources": ["apps/backend/internal/task/handlers/workspace_handlers.go", "apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx"],
"tests": ["apps/backend/internal/task/handlers/workspace_handlers_test.go", "apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx"],
"settingsRoutes": ["/settings/workspace"],
"settingsRoutes": ["/settings/workspace", "/settings/workspaces"],
"mcpTools": ["list_repositories_kandev", "list_workspaces_kandev"]
},
{
@@ -189,7 +189,7 @@
"apps/web/components/settings/agent-profile-page.test.tsx",
"apps/web/hooks/domains/settings/use-dynamic-models.test.ts"
],
"settingsRoutes": ["/settings/agents", "/settings/prompts", "/settings/utility-agents"],
"settingsRoutes": ["/settings/agents", "/settings/agents/browse", "/settings/prompts", "/settings/utility-agents"],
"mcpTools": [
"create_agent_profile_kandev",
"delete_agent_profile_kandev",
@@ -256,6 +256,12 @@
"sources": ["apps/web/src/settings-routes.tsx", "apps/web/components/settings/general-settings.tsx", "apps/web/components/settings/layouts/layout-settings.tsx"],
"tests": ["apps/web/src/settings-routes.test.ts", "apps/web/components/settings/terminal-settings.test.ts", "apps/web/lib/layout/layout-profiles.test.ts"],
"settingsRoutes": [
"/settings/preferences",
"/settings/preferences/appearance",
"/settings/preferences/keyboard-shortcuts",
"/settings/preferences/layouts",
"/settings/preferences/notifications",
"/settings/preferences/terminal-editors",
"/settings/general",
"/settings/general/appearance",
"/settings/general/editors",
@@ -266,7 +272,8 @@
"/settings/general/sprites",
"/settings/general/task-actions",
"/settings/general/terminal",
"/settings/voice-mode"
"/settings/secrets",
"/settings/preferences/task-behavior"
]
},
{
@@ -315,6 +322,7 @@
"/settings/general/message-queue",
"/settings/system/about",
"/settings/system/backups",
"/settings/system/data-storage",
"/settings/system/database",
"/settings/system/feature-toggles",
"/settings/system/licenses",
+2 -1
View File
@@ -116,7 +116,8 @@ A saved prompt is an instruction, not an authorization or policy boundary. Execu
<details>
<summary>Voice details</summary>
Open **Settings > Voice Mode** (`/settings/voice-mode`). Voice Mode inserts a transcript at the cursor in the active chat composer.
Open **Settings > Task Behavior** (`/settings/preferences/task-behavior`) and expand the Voice Mode
section. Voice Mode inserts a transcript at the cursor in the active chat composer.
Defaults are:
+1
View File
@@ -187,6 +187,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [executor-settings-card-spacing](ui/executor-settings-card-spacing.md) | shipped |
| [quick-chat-elevation](ui/quick-chat-elevation.md) | building |
| [transcript-navigation-settings](ui/transcript-navigation-settings.md) | shipped |
| [voice-mode-task-behavior](ui/voice-mode-task-behavior.md) | shipped |
| [app-status-bar](ui/app-status-bar.md) | shipped |
| [quick-terminal](quick-terminal/spec.md) | shipped |
| [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped |
+54
View File
@@ -0,0 +1,54 @@
---
status: shipped
created: 2026-08-11
owner: kandev
---
# Voice Mode In Task Behavior
## Why
Voice input changes how users interact with tasks, but its controls currently occupy a separate
top-level Settings destination. Keeping task interaction preferences together makes Voice Mode
easier to discover in context and reduces the main Settings menu without changing saved behavior.
## What
- **Task Behavior** contains the existing Voice Mode section after the task-action and message-queue
sections.
- The Settings main menu has no standalone **Voice Mode** row.
- Settings search continues to find the Voice Mode section and each voice control, and selecting a
result opens **Task Behavior** at the matching section or control.
- The former `/settings/voice-mode` route is removed without a compatibility redirect.
- Voice settings keep their existing values, capability detection, disabled-state presentation,
draft/save/discard behavior, and user-facing copy.
- Desktop and mobile use the existing responsive Settings page. The shared settings content remains
the single scroll owner, and every Voice Mode control remains reachable without document-level
horizontal overflow.
## Scenarios
- **GIVEN** a user opens Settings on desktop or mobile, **WHEN** the main menu is shown, **THEN** it
contains **Task Behavior** and no standalone **Voice Mode** row.
- **GIVEN** a user opens **Task Behavior**, **WHEN** the page renders, **THEN** task actions, message
queue, and Voice Mode appear in that order with all existing voice controls available.
- **GIVEN** a user searches Settings for Voice Mode or a voice control, **WHEN** they select the
result, **THEN** Task Behavior opens and scrolls to the matching section or control.
- **GIVEN** the application route registry, **WHEN** Settings routes are enumerated, **THEN**
`/settings/voice-mode` is absent and Task Behavior is the only route that renders Voice Mode.
- **GIVEN** a user changes a voice preference from Task Behavior, **WHEN** they save or discard
through the shared settings action, **THEN** persistence behavior matches the former standalone
page.
- **GIVEN** a Pixel 5-sized viewport, **WHEN** the user opens Task Behavior and scrolls through
Voice Mode, **THEN** every control is visible and operable, the settings container owns vertical
scrolling, and the document has no horizontal overflow.
## Out Of Scope
- Changing voice engines, models, shortcuts, defaults, persistence, or runtime behavior.
- Redesigning Task Behavior, the shared settings save action, or Settings navigation.
- Preserving compatibility for the former Voice Mode URL.
## Implementation Plan
See [the implementation plan](../../plans/voice-mode-task-behavior/plan.md).
+1 -1
View File
@@ -659,7 +659,7 @@ async function collectSettingsRoutes(root) {
"utf8",
);
const routeTable = source.match(
/const SETTINGS_ROUTES[\s\S]*?\n};\s*\n\s*export function SettingsRoutes/,
/const SETTINGS_ROUTES[\s\S]*?\n};[\s\S]*?export function SettingsRoutes/,
)?.[0];
if (!routeTable) {
throw new Error("could not locate SETTINGS_ROUTES in settings-routes.tsx");