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