From 9de669558b04fdb539b85330407e483ab3121439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 11 Aug 2026 23:29:46 +0200 Subject: [PATCH] feat!: move voice mode into task behavior (#2534) Co-authored-by: Kandev Agent --- apps/web/app/settings/voice-mode/page.tsx | 21 --- .../settings/settings-menu-sections.ts | 3 - .../settings/settings-nav-copy.test.ts | 4 +- .../settings/settings-tree-render.test.tsx | 19 +-- .../settings/external-mcp-settings.tsx | 6 +- .../settings/settings-breadcrumb-labels.ts | 1 - .../settings/task-behavior-settings.tsx | 3 + .../components/settings/utility-sections.tsx | 8 +- .../settings/voice-mode-settings.tsx | 13 +- .../e2e/tests/chat/mobile-voice-mode.spec.ts | 28 +++- .../e2e/tests/i18n/pseudo-coverage.spec.ts | 4 + .../e2e/tests/settings/settings-index.spec.ts | 22 ++++ apps/web/eslint.i18n.options.mjs | 5 +- .../lib/settings-discovery/catalog.test.ts | 59 +++++---- .../settings-discovery/catalog/preferences.ts | 71 ++++++++++ .../settings-discovery/catalog/standalone.ts | 81 +----------- apps/web/src/settings-routes.test.ts | 9 ++ apps/web/src/settings-routes.tsx | 2 - docs/plans/voice-mode-task-behavior/plan.md | 124 ++++++++++++++++++ .../task-01-consolidate-settings.md | 75 +++++++++++ .../task-02-e2e-screenshots-pr.md | 75 +++++++++++ docs/public/coverage.json | 14 +- docs/public/developer-tools.md | 3 +- docs/specs/INDEX.md | 1 + docs/specs/ui/voice-mode-task-behavior.md | 54 ++++++++ scripts/validate-public-docs.mjs | 2 +- 26 files changed, 541 insertions(+), 166 deletions(-) delete mode 100644 apps/web/app/settings/voice-mode/page.tsx create mode 100644 docs/plans/voice-mode-task-behavior/plan.md create mode 100644 docs/plans/voice-mode-task-behavior/task-01-consolidate-settings.md create mode 100644 docs/plans/voice-mode-task-behavior/task-02-e2e-screenshots-pr.md create mode 100644 docs/specs/ui/voice-mode-task-behavior.md diff --git a/apps/web/app/settings/voice-mode/page.tsx b/apps/web/app/settings/voice-mode/page.tsx deleted file mode 100644 index 2bcc1b8510..0000000000 --- a/apps/web/app/settings/voice-mode/page.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts index 3e77be38db..f3e9aee50c 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts @@ -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. diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts index fa20c13840..eacf1392b2 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-copy.test.ts @@ -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", diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 347f6cde52..d83ccd97a8 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -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(); + it("keeps Voice Mode inside Task Behavior instead of as a page row", () => { + render(); - 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(); 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(); }); }); diff --git a/apps/web/components/settings/external-mcp-settings.tsx b/apps/web/components/settings/external-mcp-settings.tsx index 31913e6eb1..3f79a7610a 100644 --- a/apps/web/components/settings/external-mcp-settings.tsx +++ b/apps/web/components/settings/external-mcp-settings.tsx @@ -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() { } title={t("settings:externalMcpEndpoints")} // `localhost` is a hostname the user types, not copy. Interpolated so @@ -241,7 +241,7 @@ function SnippetsSection({ return ( } title={t("settings:externalMcpSnippets")} description={t("settings:externalMcpSnippetsDescription")} diff --git a/apps/web/components/settings/settings-breadcrumb-labels.ts b/apps/web/components/settings/settings-breadcrumb-labels.ts index 07062f4cc6..6d294db66d 100644 --- a/apps/web/components/settings/settings-breadcrumb-labels.ts +++ b/apps/web/components/settings/settings-breadcrumb-labels.ts @@ -81,7 +81,6 @@ const SEGMENT_LABEL_KEYS: Record = { updates: "system:navUpdates", users: "system:navUsers", "utility-agents": "settings:utilityAgents", - "voice-mode": "settings:voiceMode", workflows: "workflows:workflows", workspace: "common:workspace", workspaces: "common:workspaces", diff --git a/apps/web/components/settings/task-behavior-settings.tsx b/apps/web/components/settings/task-behavior-settings.tsx index 60795b5070..27776e34fe 100644 --- a/apps/web/components/settings/task-behavior-settings.tsx +++ b/apps/web/components/settings/task-behavior-settings.tsx @@ -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() { + + ); } diff --git a/apps/web/components/settings/utility-sections.tsx b/apps/web/components/settings/utility-sections.tsx index ead6b3b686..9c5132381a 100644 --- a/apps/web/components/settings/utility-sections.tsx +++ b/apps/web/components/settings/utility-sections.tsx @@ -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 ( @@ -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" > @@ -243,7 +243,7 @@ export function CustomAgentsSection({ const { t } = useTranslation(); return ( diff --git a/apps/web/components/settings/voice-mode-settings.tsx b/apps/web/components/settings/voice-mode-settings.tsx index 41006583b3..ad09f50017 100644 --- a/apps/web/components/settings/voice-mode-settings.tsx +++ b/apps/web/components/settings/voice-mode-settings.tsx @@ -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 ( {t("settings:voiceEngineTitle")} @@ -405,7 +405,7 @@ function BehaviorCard() { voiceMode.mode !== savedVoiceMode.mode || voiceMode.autoSend !== savedVoiceMode.autoSend; return ( - + {t("settings:voiceBehavior")} @@ -428,7 +428,7 @@ function WhisperModelCard() { return ( {t("settings:voiceWhisperModelTitle")} @@ -476,7 +476,7 @@ function EnableCard() { return ( @@ -558,7 +558,7 @@ function VoiceShortcutCard() { }, [overrides, persist]); return ( - + {/* The shortcut's own label comes from the shared keyboard registry, @@ -596,6 +596,7 @@ function VoiceModeSettingsContent() { icon={} title={t("settings:voiceMode")} description={t("settings:voiceModePageDescription")} + discoveryTargetId={GENERAL_SETTINGS_TARGETS.voiceMode} >
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");