diff --git a/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
index 7f09f70904..cefc036746 100644
--- a/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
@@ -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.)
diff --git a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
index 5abf93f12d..be0685824a 100644
--- a/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
+++ b/apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts
@@ -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",
diff --git a/apps/web/e2e/tests/settings/settings-index.spec.ts b/apps/web/e2e/tests/settings/settings-index.spec.ts
index b7d47d1996..6d8c5ead81 100644
--- a/apps/web/e2e/tests/settings/settings-index.spec.ts
+++ b/apps/web/e2e/tests/settings/settings-index.spec.ts
@@ -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");
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index ad83cb4881..02dac98f0c 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -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
// `
` and `
` 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",
diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts
index 29988747e2..b45830aa12 100644
--- a/apps/web/lib/settings-discovery/catalog.test.ts
+++ b/apps/web/lib/settings-discovery/catalog.test.ts
@@ -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",
);
});
diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts
index 4288c71f71..ae2f7d0dc6 100644
--- a/apps/web/lib/settings-discovery/catalog/preferences.ts
+++ b/apps/web/lib/settings-discovery/catalog/preferences.ts
@@ -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,
+ },
];
diff --git a/apps/web/lib/settings-discovery/catalog/standalone.ts b/apps/web/lib/settings-discovery/catalog/standalone.ts
index 147640721c..d34ceb8ad3 100644
--- a/apps/web/lib/settings-discovery/catalog/standalone.ts
+++ b/apps/web/lib/settings-discovery/catalog/standalone.ts
@@ -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,
},
{
diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts
index e8c61d5159..56e16e529c 100644
--- a/apps/web/src/settings-routes.test.ts
+++ b/apps/web/src/settings-routes.test.ts
@@ -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"));
diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx
index 94889999c5..ddcbfbbb23 100644
--- a/apps/web/src/settings-routes.tsx
+++ b/apps/web/src/settings-routes.tsx
@@ -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
= {
"/settings/utility-agents": () => ,
"/settings/external-mcp": () => ,
"/settings/prompts": () => ,
- "/settings/voice-mode": () => ,
"/settings/plugins": () => ,
// Integrations are per-workspace pages now; the old install-level paths
// forward into the active workspace's Integrations tab.
diff --git a/docs/plans/voice-mode-task-behavior/plan.md b/docs/plans/voice-mode-task-behavior/plan.md
new file mode 100644
index 0000000000..cbf44e5265
--- /dev/null
+++ b/docs/plans/voice-mode-task-behavior/plan.md
@@ -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.
diff --git a/docs/plans/voice-mode-task-behavior/task-01-consolidate-settings.md b/docs/plans/voice-mode-task-behavior/task-01-consolidate-settings.md
new file mode 100644
index 0000000000..75f3eb7015
--- /dev/null
+++ b/docs/plans/voice-mode-task-behavior/task-01-consolidate-settings.md
@@ -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.
diff --git a/docs/plans/voice-mode-task-behavior/task-02-e2e-screenshots-pr.md b/docs/plans/voice-mode-task-behavior/task-02-e2e-screenshots-pr.md
new file mode 100644
index 0000000000..2527e153f4
--- /dev/null
+++ b/docs/plans/voice-mode-task-behavior/task-02-e2e-screenshots-pr.md
@@ -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.
diff --git a/docs/public/coverage.json b/docs/public/coverage.json
index 6010a35171..ed4e1574ec 100644
--- a/docs/public/coverage.json
+++ b/docs/public/coverage.json
@@ -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",
diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md
index d44cdcec85..797754cc64 100644
--- a/docs/public/developer-tools.md
+++ b/docs/public/developer-tools.md
@@ -116,7 +116,8 @@ A saved prompt is an instruction, not an authorization or policy boundary. Execu
Voice details
-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:
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 5e8fc8323c..c638d84c62 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -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 |
diff --git a/docs/specs/ui/voice-mode-task-behavior.md b/docs/specs/ui/voice-mode-task-behavior.md
new file mode 100644
index 0000000000..e58a4507fa
--- /dev/null
+++ b/docs/specs/ui/voice-mode-task-behavior.md
@@ -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).
diff --git a/scripts/validate-public-docs.mjs b/scripts/validate-public-docs.mjs
index ebe1cf5ad3..ef1c365e63 100644
--- a/scripts/validate-public-docs.mjs
+++ b/scripts/validate-public-docs.mjs
@@ -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");