From 1ef2fb2549d67d2946dde0133a02e319eb85cfcc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 09:22:33 -0400 Subject: [PATCH 1/6] V2 user settings: admin-style layout, card index, feedback fix - Fix /feedback/my, /stats and /export 500 (filter helper returns three values) - Rebuild User Settings with a collapsible rail (v2UserSettingsRailCollapsed), full-width cards and an On this page index fed by a card registry - Group Preferences into Appearance, Chat, Notifications and alerts, Diagrams and charts - Convert Stats, Feedback, Violations and workspace lists to settings cards - Always list Violations; explain when content safety is off - Bump version to 0.261.277; add tests and docs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/route_backend_feedback.py | 6 +- application/single_app/route_backend_users.py | 2 + .../src/components/settings/FeedbackTab.tsx | 264 +++++----- .../components/settings/PreferencesTab.tsx | 277 +++++----- .../src/components/settings/SettingsCard.tsx | 185 +++++++ .../src/components/settings/StatsTab.tsx | 472 ++++++++++-------- .../src/components/settings/TabScaffold.tsx | 67 --- .../src/components/settings/ViolationsTab.tsx | 322 +++++++----- .../components/settings/WorkspaceListTab.tsx | 240 ++++----- .../v2_ui/src/components/settings/tabs.tsx | 10 +- application/v2_ui/src/lib/userSettings.ts | 8 + application/v2_ui/src/pages/SettingsPage.tsx | 198 ++++++-- .../features/V2_USER_SETTINGS_REDESIGN.md | 81 +++ .../fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md | 34 ++ docs/guides/update-profile-preferences.md | 15 + .../test_feedback_my_routes_filter_unpack.py | 85 ++++ .../test_v2_settings_and_workspace_tags.py | 8 +- .../test_v2_user_settings_layout.py | 118 +++++ 19 files changed, 1567 insertions(+), 827 deletions(-) create mode 100644 application/v2_ui/src/components/settings/SettingsCard.tsx delete mode 100644 application/v2_ui/src/components/settings/TabScaffold.tsx create mode 100644 docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md create mode 100644 docs/explanation/fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md create mode 100644 functional_tests/test_feedback_my_routes_filter_unpack.py create mode 100644 functional_tests/test_v2_user_settings_layout.py diff --git a/application/single_app/config.py b/application/single_app/config.py index e4efb1724..a21fa6be3 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.276" +VERSION = "0.261.277" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_feedback.py b/application/single_app/route_backend_feedback.py index 790adefcb..98c3a6bcb 100644 --- a/application/single_app/route_backend_feedback.py +++ b/application/single_app/route_backend_feedback.py @@ -708,7 +708,7 @@ def feedback_my(): try: page = int(request.args.get('page', 1)) page_size = int(request.args.get('page_size', 10)) - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, @@ -739,7 +739,7 @@ def feedback_my_stats(): return jsonify({"error": "No user ID found in session"}), 403 try: - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, @@ -761,7 +761,7 @@ def feedback_my_export(): return jsonify({"error": "No user ID found in session"}), 403 try: - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index edd089156..dcb25ab50 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -560,6 +560,8 @@ def user_settings(): # Whether the V2 Admin Settings categories rail is collapsed to icons. # Separate from the shell and workspace rails so each keeps its own state. 'v2AdminRailCollapsed', + # Whether the V2 User Settings sections rail is collapsed to icons. + 'v2UserSettingsRailCollapsed', # V2 document explorer: how the workspace documents list is presented # (view mode, visible columns, page size, details pane) and the saved # filter combinations pinned in its navigation rail. diff --git a/application/v2_ui/src/components/settings/FeedbackTab.tsx b/application/v2_ui/src/components/settings/FeedbackTab.tsx index a7ee46d61..195244405 100644 --- a/application/v2_ui/src/components/settings/FeedbackTab.tsx +++ b/application/v2_ui/src/components/settings/FeedbackTab.tsx @@ -7,9 +7,10 @@ import { useCallback, useEffect, useState } from 'react'; import { clsx } from 'clsx'; -import { ThumbsDown, ThumbsUp } from 'lucide-react'; +import { MessageSquareHeart, PieChart, ThumbsDown, ThumbsUp } from 'lucide-react'; import { api, apiUrl } from '../../lib/apiClient'; import { GlassPanel, Skeleton } from '../ui/primitives'; +import { SettingsCard } from './SettingsCard'; const PAGE_SIZE = 10; @@ -137,135 +138,154 @@ export function FeedbackTab() { const lastPage = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); return ( -
- {stats && ( -
- - - - -
- )} - -
- - - - Export CSV - -
+
+ + {stats ? ( +
+ + + + +
+ ) : loading ? ( + + ) : ( +

The summary could not be loaded.

+ )} +
- {error ? ( -

- {error} -

- ) : loading ? ( -
- - + + Export CSV + + } + bodyClassName="space-y-3" + > +
+ +
- ) : items.length === 0 ? ( - -

- {type || ack - ? 'No feedback matches these filters.' - : 'You have not rated any replies yet. Use the thumbs on an assistant message to send feedback.'} + + {error ? ( +

+ {error}

-
- ) : ( -
    - {items.map((item) => { - const acknowledged = Boolean(item.adminReview?.acknowledged); - return ( -
  • - -
    - - - {acknowledged ? 'Reviewed' : 'Awaiting review'} - - {item.timestamp && ( - - {new Date(item.timestamp).toLocaleString()} + ) : loading ? ( +
    + + +
    + ) : items.length === 0 ? ( + +

    + {type || ack + ? 'No feedback matches these filters.' + : 'You have not rated any replies yet. Use the thumbs on an assistant message to send feedback.'} +

    +
    + ) : ( +
      + {items.map((item) => { + const acknowledged = Boolean(item.adminReview?.acknowledged); + return ( +
    • + +
      + + + {acknowledged ? 'Reviewed' : 'Awaiting review'} - )} -
      + {item.timestamp && ( + + {new Date(item.timestamp).toLocaleString()} + + )} +
    - {item.prompt && ( -

    - You asked: - {item.prompt} -

    - )} - {item.reason && ( -

    {item.reason}

    - )} + {item.prompt && ( +

    + You asked: + {item.prompt} +

    + )} + {item.reason && ( +

    {item.reason}

    + )} - {item.adminReview?.responseToUser && ( -

    - Reply: - {item.adminReview.responseToUser} -

    - )} -
    -
  • - ); - })} -
- )} + {item.adminReview?.responseToUser && ( +

+ Reply: + {item.adminReview.responseToUser} +

+ )} + + + ); + })} + + )} - {totalCount > PAGE_SIZE && ( -
- - Page {page} of {lastPage} · {totalCount} submissions - -
- - + {totalCount > PAGE_SIZE && ( +
+ + Page {page} of {lastPage} · {totalCount} submissions + +
+ + +
-
- )} + )} +
); } diff --git a/application/v2_ui/src/components/settings/PreferencesTab.tsx b/application/v2_ui/src/components/settings/PreferencesTab.tsx index 8947262c1..7dcd2b7c2 100644 --- a/application/v2_ui/src/components/settings/PreferencesTab.tsx +++ b/application/v2_ui/src/components/settings/PreferencesTab.tsx @@ -6,9 +6,22 @@ // worse, only changes the classic interface — is more confusing than its absence, so the // remaining classic preferences are deliberately left to that page until V2 implements the // behaviour behind them. +// +// Cards are arranged in groups (Appearance, Chat, Notifications and alerts, Diagrams and +// charts) so the page index on the right can list them the way Admin Settings does. import { useEffect, useSyncExternalStore } from 'react'; import { clsx } from 'clsx'; +import { + BarChart3, + Bell, + ListTree, + Siren, + Tags, + Type, + Volume2, + Workflow, +} from 'lucide-react'; import { useUserSettingsStore } from '../../stores/userSettingsStore'; import { useBootstrapStore } from '../../stores/bootstrapStore'; import { @@ -25,7 +38,7 @@ import { type FontSizePreference, } from '../../lib/userSettings'; import { Toggle, Skeleton } from '../ui/primitives'; -import { SettingsSection } from './TabScaffold'; +import { SettingsCard, SettingsGroup } from './SettingsCard'; import { VISUAL_STYLE_SETTING_KEYS } from '../../lib/blockVisualStyle'; import { DEFAULT_VISUAL_STYLE, @@ -270,139 +283,177 @@ export function PreferencesTab() { } return ( -
+
{saveError && (

{saveError}

)} - - update({ fontSizePreference: next })} - /> - - - - update({ showConversationWorkspaceTags: next })} - label="Show workspace tags" - description="Label conversations that belong to a group or public workspace, or that are shared with other people. Personal conversations stay unlabelled." - /> - - - -
- + update({ fontSizePreference: next })} /> + + + update({ showConversationWorkspaceTags: next })} + label="Show workspace tags" + description="Label conversations that belong to a group or public workspace, or that are shared with other people. Personal conversations stay unlabelled." /> -
-
+ + - - - update({ [VISUAL_STYLE_SETTING_KEYS.mermaid]: next }) - } - /> - + {(enabled('enable_conversation_contents_drawer') || enabled('enable_text_to_speech')) && ( + + {enabled('enable_conversation_contents_drawer') && ( + + + update({ conversationContentsDrawerEnabled: next }) + } + label="Show the conversation contents drawer" + /> + + )} - + + + )} + + )} + + - - update({ [VISUAL_STYLE_SETTING_KEYS.simplechart]: next }) - } - /> - + {enabled('enable_desktop_notifications') && ( + + { + update({ [DESKTOP_NOTIFICATIONS_SETTING]: next }); + // In the same click: a browser only shows its prompt for one. + if (next) { + void requestDesktopNotificationPermission({ explicit: true }); + } + }} + label="Notify me when a reply is ready" + /> + {desktopNotificationsOn && ( + + )} + + )} - {enabled('enable_conversation_contents_drawer') && ( - - +
+ +
+
+ +
+
+ + + + + + - update({ conversationContentsDrawerEnabled: next }) + update({ [VISUAL_STYLE_SETTING_KEYS.mermaid]: next }) } - label="Show the conversation contents drawer" /> - - )} - - {enabled('enable_text_to_speech') && ( - - - - )} + - {enabled('enable_desktop_notifications') && ( - - { - update({ [DESKTOP_NOTIFICATIONS_SETTING]: next }); - // In the same click: a browser only shows its prompt for one. - if (next) { - void requestDesktopNotificationPermission({ explicit: true }); - } - }} - label="Notify me when a reply is ready" + + update({ [VISUAL_STYLE_SETTING_KEYS.simplechart]: next }) + } /> - {desktopNotificationsOn && ( - - )} - - )} + +
); } diff --git a/application/v2_ui/src/components/settings/SettingsCard.tsx b/application/v2_ui/src/components/settings/SettingsCard.tsx new file mode 100644 index 000000000..8966c10ab --- /dev/null +++ b/application/v2_ui/src/components/settings/SettingsCard.tsx @@ -0,0 +1,185 @@ +// SettingsCard.tsx +// The card, group heading and "On this page" registry shared by every User Settings tab. +// +// User Settings mirrors the Admin Settings design language: each section is a bordered card +// with a tinted header, an icon tile and a title, and the page lists the sections on the +// current tab in a right-hand index. The admin card is tied to admin field definitions, so +// the user card is its own small component that reuses the same classes rather than a fork +// of that one. +// +// A card registers itself with the page when it mounts, so a tab declares its index simply +// by rendering its cards. Cards that are hidden because a capability is off never mount and +// so never appear in the index either. + +import { + createContext, + useContext, + useEffect, + type ReactNode, +} from 'react'; +import { clsx } from 'clsx'; +import type { LucideIcon } from 'lucide-react'; +import type { SectionStatus } from '../../lib/adminSections'; +import { presentSectionStatus } from '../admin/sectionStatusPresentation'; +import { GlassPanel } from '../ui/primitives'; + +export interface SettingsSectionEntry { + sectionId: string; + label: string; + groupId: string; + groupLabel: string; + Icon: LucideIcon; + status: SectionStatus; +} + +interface SettingsSectionRegistry { + register: (entry: SettingsSectionEntry) => void; + unregister: (sectionId: string) => void; +} + +export const SettingsSectionRegistryContext = createContext(null); + +interface SettingsGroupValue { + id: string; + label: string; +} + +const SettingsGroupContext = createContext(null); + +/** Turns a label into an id that is stable across renders and safe in a URL fragment. */ +export function settingsSectionId(label: string): string { + return `user-settings-${label + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '')}`; +} + +/** + * A titled run of related cards, such as "Appearance" or "Notifications". + * + * Groups give a long preferences page a shape a reader can scan, and they become the group + * headings in the page index. + */ +export function SettingsGroup({ + id, + label, + description, + children, +}: { + id: string; + label: string; + description?: string; + children: ReactNode; +}) { + return ( + +
+
+

+ {label} +

+ {description ?

{description}

: null} +
+ {children} +
+
+ ); +} + +/** + * One section of User Settings, styled to match an Admin Settings card. + * + * The description is not decoration: several settings only make sense once you know what + * they affect, and a bare label leaves the user guessing. + */ +export function SettingsCard({ + title, + description, + Icon, + sectionId, + status = 'none', + actions, + bodyClassName, + children, +}: { + title: string; + description?: ReactNode; + Icon: LucideIcon; + /** Defaults to an id derived from the title. */ + sectionId?: string; + status?: SectionStatus; + /** Controls drawn at the right of the header, such as an export button. */ + actions?: ReactNode; + bodyClassName?: string; + children?: ReactNode; +}) { + const registry = useContext(SettingsSectionRegistryContext); + const group = useContext(SettingsGroupContext); + const id = sectionId ?? settingsSectionId(title); + const groupId = group?.id ?? ''; + const groupLabel = group?.label ?? ''; + const presentation = presentSectionStatus(status); + + useEffect(() => { + if (!registry) { + return undefined; + } + registry.register({ sectionId: id, label: title, groupId, groupLabel, Icon, status }); + return () => registry.unregister(id); + }, [registry, id, title, groupId, groupLabel, Icon, status]); + + return ( + +
+
+ +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+
+ {actions || presentation ? ( +
+ {actions} + {presentation ? ( + + + {presentation.label} + + ) : null} +
+ ) : null} +
+
{children}
+
+ ); +} diff --git a/application/v2_ui/src/components/settings/StatsTab.tsx b/application/v2_ui/src/components/settings/StatsTab.tsx index a1a24ada5..36a6d6d63 100644 --- a/application/v2_ui/src/components/settings/StatsTab.tsx +++ b/application/v2_ui/src/components/settings/StatsTab.tsx @@ -14,7 +14,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { clsx } from 'clsx'; -import { CalendarRange, Download } from 'lucide-react'; +import { + CalendarRange, + Coins, + Download, + FileText, + HardDrive, + LogIn, + MessagesSquare, + Sigma, + UserRound, + type LucideIcon, +} from 'lucide-react'; import { api } from '../../lib/apiClient'; import { GlassButton, GlassPanel, Skeleton } from '../ui/primitives'; import { useBootstrapStore } from '../../stores/bootstrapStore'; @@ -45,6 +56,7 @@ import { lineDataset, } from './StatsChart'; import { StatsExportDialog } from './StatsExportDialog'; +import { SettingsCard } from './SettingsCard'; function StatCard({ label, value, caption }: { label: string; value: string; caption?: string }) { return ( @@ -59,20 +71,22 @@ function StatCard({ label, value, caption }: { label: string; value: string; cap function ChartCard({ title, value, + Icon, children, }: { title: string; value: string; + Icon: LucideIcon; children: React.ReactNode; }) { return ( - -
-

{title}

- {value} -
-
{children}
-
+ {value}} + > + {children} + ); } @@ -181,7 +195,7 @@ export function StatsTab() { const documentMetrics = metrics?.document_metrics ?? {}; return ( -
+
{STATS_WINDOWS.map((preset) => { @@ -280,242 +294,256 @@ export function StatsTab() {
) : ( <> -

- The four totals are cached figures,{' '} - {metrics?.calculated_at - ? `last worked out ${formatRelativeTime(metrics.calculated_at)}` - : 'not yet calculated for your account'} - . Everything below them covers {windowLabel.toLowerCase()}. -

- -
- - - - -
- - - ({ - type: 'line', - data: { - labels, - datasets: [ - lineDataset( - 'Sign-ins', - logins, - SERIES_COLORS.logins.line, - SERIES_COLORS.logins.fill, - ), - ], - }, - options: cartesianOptions(theme, false), - })} - /> - - - - ({ - type: 'bar', - data: { - labels, - datasets: [ - barDataset( - 'Created', - conversationCreates, - SERIES_COLORS.created.line, - SERIES_COLORS.created.fill, - ), - barDataset( - 'Deleted', - conversationDeletes, - SERIES_COLORS.deleted.line, - SERIES_COLORS.deleted.fill, - ), - ], - }, - options: cartesianOptions(theme, true), - })} - /> - - - + The four totals are cached figures,{' '} + {metrics?.calculated_at + ? `last worked out ${formatRelativeTime(metrics.calculated_at)}` + : 'not yet calculated for your account'} + . Everything below them covers {windowLabel.toLowerCase()}. + + } > - ({ - type: 'bar', - data: { - labels, - datasets: [ - barDataset( - 'Uploaded', - documentUploads, - SERIES_COLORS.uploaded.line, - SERIES_COLORS.uploaded.fill, - ), - barDataset( - 'Deleted', - documentDeletes, - SERIES_COLORS.deleted.line, - SERIES_COLORS.deleted.fill, - ), - ], - }, - options: cartesianOptions(theme, true), - })} - /> - +
+ + + + +
+ - - { - const options = cartesianOptions(theme, false); - return { +
+ + ({ type: 'line', data: { labels, datasets: [ lineDataset( - 'Tokens (millions)', - tokenMillions, - SERIES_COLORS.tokens.line, - SERIES_COLORS.tokens.fill, + 'Sign-ins', + logins, + SERIES_COLORS.logins.line, + SERIES_COLORS.logins.fill, ), ], }, - options: { - ...options, - scales: { - ...options.scales, - // Fractions of a million are the normal case, so - // the shared integer ticks are dropped here, and - // the axis has to name its unit or "0.5" means - // nothing beside a total in the millions. - y: { - ...options.scales.y, - ticks: { color: theme.text }, - title: { - display: true, - text: 'Millions of tokens', - color: theme.text, - }, - }, - }, + options: cartesianOptions(theme, false), + })} + /> + + + + ({ + type: 'bar', + data: { + labels, + datasets: [ + barDataset( + 'Created', + conversationCreates, + SERIES_COLORS.created.line, + SERIES_COLORS.created.fill, + ), + barDataset( + 'Deleted', + conversationDeletes, + SERIES_COLORS.deleted.line, + SERIES_COLORS.deleted.fill, + ), + ], }, - }; - }} - /> - + options: cartesianOptions(theme, true), + })} + /> + - -
-

Storage used

- - {formatBytes(aiSearchSize + blobStorageSize)} - -
- {hasStorage ? ( -
- ({ - type: 'doughnut', + + ({ + type: 'bar', + data: { + labels, + datasets: [ + barDataset( + 'Uploaded', + documentUploads, + SERIES_COLORS.uploaded.line, + SERIES_COLORS.uploaded.fill, + ), + barDataset( + 'Deleted', + documentDeletes, + SERIES_COLORS.deleted.line, + SERIES_COLORS.deleted.fill, + ), + ], + }, + options: cartesianOptions(theme, true), + })} + /> + + + + { + const options = cartesianOptions(theme, false); + return { + type: 'line', data: { - labels: ['AI Search', 'Blob storage'], + labels, datasets: [ - { - data: [aiSearchSize, blobStorageSize], - backgroundColor: [ - SERIES_COLORS.aiSearch, - SERIES_COLORS.blobStorage, - ], - borderColor: theme.surface, - borderWidth: 2, - }, + lineDataset( + 'Tokens (millions)', + tokenMillions, + SERIES_COLORS.tokens.line, + SERIES_COLORS.tokens.fill, + ), ], }, options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom', - labels: { + ...options, + scales: { + ...options.scales, + // Fractions of a million are the normal case, so + // the shared integer ticks are dropped here, and + // the axis has to name its unit or "0.5" means + // nothing beside a total in the millions. + y: { + ...options.scales.y, + ticks: { color: theme.text }, + title: { + display: true, + text: 'Millions of tokens', color: theme.text, - boxWidth: 10, - boxHeight: 10, - usePointStyle: true, }, }, }, }, - })} - /> -
- ) : ( -

- Nothing stored yet. Documents you upload to your workspace are counted - here. -

- )} -
+ }; + }} + /> + - -

Account

-
-
-
Name
-
- {user?.display_name || 'Not available'} -
-
-
-
Email
-
- {user?.email || 'Not available'} -
-
-
-
User ID
-
- {user?.id || 'Not available'} -
-
-
-
+
+ +
+ + {formatBytes(aiSearchSize + blobStorageSize)} + + } + > + {hasStorage ? ( +
+ ({ + type: 'doughnut', + data: { + labels: ['AI Search', 'Blob storage'], + datasets: [ + { + data: [aiSearchSize, blobStorageSize], + backgroundColor: [ + SERIES_COLORS.aiSearch, + SERIES_COLORS.blobStorage, + ], + borderColor: theme.surface, + borderWidth: 2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { + color: theme.text, + boxWidth: 10, + boxHeight: 10, + usePointStyle: true, + }, + }, + }, + }, + })} + /> +
+ ) : ( +

+ Nothing stored yet. Documents you upload to your workspace are counted + here. +

+ )} +
+ + +
+
+
Name
+
+ {user?.display_name || 'Not available'} +
+
+
+
Email
+
+ {user?.email || 'Not available'} +
+
+
+
User ID
+
+ {user?.id || 'Not available'} +
+
+
+
+
)} diff --git a/application/v2_ui/src/components/settings/TabScaffold.tsx b/application/v2_ui/src/components/settings/TabScaffold.tsx deleted file mode 100644 index 29c481aec..000000000 --- a/application/v2_ui/src/components/settings/TabScaffold.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// TabScaffold.tsx -// Shared pieces for the settings tabs: section framing, and the notice a tab shows before -// it has been built. - -import type { ReactNode } from 'react'; -import { ArrowUpRight } from 'lucide-react'; -import { GlassPanel } from '../ui/primitives'; - -/** - * One titled group of controls. - * - * The description is not decoration: several of these settings are only meaningful if you - * know what they affect, and a bare label leaves the user guessing. - */ -export function SettingsSection({ - title, - description, - children, -}: { - title: string; - description?: string; - children: ReactNode; -}) { - return ( - -

{title}

- {description && ( -

{description}

- )} -
{children}
-
- ); -} - -/** - * Stands in for a tab that has not been rebuilt yet. - * - * Links to the equivalent classic page rather than only apologising, so the capability - * stays reachable while V2 catches up. - */ -export function TabNotBuiltYet({ - title, - description, - classicHref, - classicLabel, -}: { - title: string; - description: string; - classicHref: string; - classicLabel: string; -}) { - return ( - -

{title}

-

- {description} -

- - {classicLabel} - - -
- ); -} diff --git a/application/v2_ui/src/components/settings/ViolationsTab.tsx b/application/v2_ui/src/components/settings/ViolationsTab.tsx index 498c1464c..ae9a1c7ee 100644 --- a/application/v2_ui/src/components/settings/ViolationsTab.tsx +++ b/application/v2_ui/src/components/settings/ViolationsTab.tsx @@ -5,12 +5,18 @@ // records and nothing else — status, action and the reviewer's notes are set by an // administrator, and PATCHing a record belonging to someone else is refused with a 403. The // interface reflects that rather than offering controls the server will reject. +// +// The tab is always listed, as it is on the classic profile page. When neither content +// safety nor content screening is on, its endpoints refuse every request, so the tab says +// that plainly instead of fetching and showing an error. import { useCallback, useEffect, useState } from 'react'; import { clsx } from 'clsx'; -import { Check, Loader2 } from 'lucide-react'; +import { Check, Loader2, PieChart, ShieldAlert } from 'lucide-react'; import { api, apiUrl, ApiError } from '../../lib/apiClient'; import { GlassPanel, Skeleton } from '../ui/primitives'; +import { SettingsCard } from './SettingsCard'; +import { useBootstrapStore } from '../../stores/bootstrapStore'; const PAGE_SIZE = 10; @@ -129,6 +135,11 @@ function UserNotes({ log, onSaved }: { log: SafetyLog; onSaved: () => void }) { } export function ViolationsTab() { + const available = useBootstrapStore( + (state) => + state.data?.features?.enable_content_safety === true || + state.data?.features?.enable_content_screening === true, + ); const [logs, setLogs] = useState([]); const [stats, setStats] = useState(null); const [page, setPage] = useState(1); @@ -185,156 +196,193 @@ export function ViolationsTab() { ); useEffect(() => { - void load(1); - }, [load]); + if (available) { + void load(1); + } + }, [available, load]); const lastPage = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); - return ( -
- {stats && ( -
- - - - -
- )} + if (!available) { + return ( + +

+ Content safety is not turned on for this application, so nothing is checked or + recorded against your account. +

+
+ ); + } -
- - - - Export CSV - -
+ return ( +
+ + {stats ? ( +
+ + + + +
+ ) : loading ? ( + + ) : ( +

The summary could not be loaded.

+ )} +
- {error ? ( -

- {error} -

- ) : loading ? ( -
- - + + Export CSV + + } + bodyClassName="space-y-3" + > +
+ +
- ) : logs.length === 0 ? ( - -

- {status || action - ? 'No violations match these filters.' - : 'Nothing has been flagged on your account.'} + + {error ? ( +

+ {error}

-
- ) : ( -
    - {logs.map((log) => ( -
  • - -
    - - {log.status || 'Unknown'} - - {log.action && log.action !== 'None' && ( - - {log.action} - - )} - {log.created_at && ( - - {new Date(log.created_at).toLocaleString()} + ) : loading ? ( +
    + + +
    + ) : logs.length === 0 ? ( + +

    + {status || action + ? 'No violations match these filters.' + : 'Nothing has been flagged on your account.'} +

    +
    + ) : ( +
      + {logs.map((log) => ( +
    • + +
      + + {log.status || 'Unknown'} - )} -
      + {log.action && log.action !== 'None' && ( + + {log.action} + + )} + {log.created_at && ( + + {new Date(log.created_at).toLocaleString()} + + )} +
    - {log.message && ( -

    - {log.message} -

    - )} + {log.message && ( +

    + {log.message} +

    + )} - {(log.triggered_categories ?? []).length > 0 && ( -

    - {(log.triggered_categories ?? []) - .filter((entry) => entry?.category) - .map( - (entry) => - `${entry.category} (severity ${entry.severity ?? '?'})`, - ) - .join(', ')} -

    - )} + {(log.triggered_categories ?? []).length > 0 && ( +

    + {(log.triggered_categories ?? []) + .filter((entry) => entry?.category) + .map( + (entry) => + `${entry.category} (severity ${entry.severity ?? '?'})`, + ) + .join(', ')} +

    + )} - {log.admin_notes && ( -

    - Reviewer: - {log.admin_notes} -

    - )} + {log.admin_notes && ( +

    + Reviewer: + {log.admin_notes} +

    + )} - void load(page)} /> -
    -
  • - ))} -
- )} + void load(page)} /> + + + ))} + + )} - {totalCount > PAGE_SIZE && ( -
- - Page {page} of {lastPage} · {totalCount} records - -
- - + {totalCount > PAGE_SIZE && ( +
+ + Page {page} of {lastPage} · {totalCount} records + +
+ + +
-
- )} + )} +
); } diff --git a/application/v2_ui/src/components/settings/WorkspaceListTab.tsx b/application/v2_ui/src/components/settings/WorkspaceListTab.tsx index 4cefd3e7e..80443cae0 100644 --- a/application/v2_ui/src/components/settings/WorkspaceListTab.tsx +++ b/application/v2_ui/src/components/settings/WorkspaceListTab.tsx @@ -7,10 +7,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { clsx } from 'clsx'; -import { Check, Loader2, Search } from 'lucide-react'; +import { Check, Globe2, Loader2, Search, Users } from 'lucide-react'; import { ApiError } from '../../lib/apiClient'; import type { WorkspaceKind, WorkspaceSummary } from '../../lib/workspaces'; import { GlassPanel, Skeleton } from '../ui/primitives'; +import { SettingsCard } from './SettingsCard'; import { useBootstrapStore } from '../../stores/bootstrapStore'; import { useGroupWorkspaceStore } from '../../stores/groupWorkspaceStore'; @@ -128,128 +129,135 @@ export function WorkspaceListTab({ kind }: { kind: WorkspaceKind }) { }; const lastPage = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const title = `Your ${kind.pluralNoun}`; return ( -
- - - {actionError && ( -

- {actionError} -

- )} - {needsReconciliation ? ( - - ) : null} - - {error ? ( -

- {error} -

- ) : loading ? ( -
- - - -
- ) : items.length === 0 ? ( - -

- {effectiveSearch - ? `No ${kind.pluralNoun} match “${effectiveSearch}”.` - : `You are not a member of any ${kind.pluralNoun} yet.`} + + + + {actionError && ( +

+ {actionError}

-
- ) : ( -
    - {items.map((workspace) => ( -
  • - -
    -
    - - {workspace.name || 'Untitled'} - - - {workspace.status && workspace.status !== 'active' && ( - - {workspace.status} + )} + {needsReconciliation ? ( + + ) : null} + + {error ? ( +

    + {error} +

    + ) : loading ? ( +
    + + + +
    + ) : items.length === 0 ? ( + +

    + {effectiveSearch + ? `No ${kind.pluralNoun} match “${effectiveSearch}”.` + : `You are not a member of any ${kind.pluralNoun} yet.`} +

    +
    + ) : ( +
      + {items.map((workspace) => ( +
    • + +
      +
      + + {workspace.name || 'Untitled'} + + {workspace.status && workspace.status !== 'active' && ( + + {workspace.status} + + )} +
      + {workspace.description && ( +

      + {workspace.description} +

      )}
      - {workspace.description && ( -

      - {workspace.description} -

      + + {workspace.isActive ? ( + + Active + + ) : ( + )} -
    - - {workspace.isActive ? ( - - Active - - ) : ( - - )} - -
  • - ))} -
- )} - - {totalCount > PAGE_SIZE && ( -
- - Page {page} of {lastPage} · {totalCount} {kind.pluralNoun} - -
- - + + + ))} + + )} + + {totalCount > PAGE_SIZE && ( +
+ + Page {page} of {lastPage} · {totalCount} {kind.pluralNoun} + +
+ + +
-
- )} -
+ )} + ); } diff --git a/application/v2_ui/src/components/settings/tabs.tsx b/application/v2_ui/src/components/settings/tabs.tsx index 344b8c92e..6fce513a5 100644 --- a/application/v2_ui/src/components/settings/tabs.tsx +++ b/application/v2_ui/src/components/settings/tabs.tsx @@ -63,11 +63,7 @@ export const SETTINGS_TABS: SettingsTab[] = [ feature: 'enable_user_feedback', Component: FeedbackTab, }, - { - id: 'violations', - label: 'Violations', - icon: ShieldAlert, - feature: 'enable_content_safety', - Component: ViolationsTab, - }, + // Always listed, as on the classic profile page. The tab explains itself when neither + // content safety nor content screening is on, rather than disappearing. + { id: 'violations', label: 'Violations', icon: ShieldAlert, Component: ViolationsTab }, ]; diff --git a/application/v2_ui/src/lib/userSettings.ts b/application/v2_ui/src/lib/userSettings.ts index b7fa62b3c..0673269ae 100644 --- a/application/v2_ui/src/lib/userSettings.ts +++ b/application/v2_ui/src/lib/userSettings.ts @@ -82,6 +82,12 @@ export interface UserSettings { */ v2AdminRailCollapsed?: boolean; + /** + * Whether the User Settings sections rail shows icons only. Its own key for the same + * reason the admin and workspace rails have theirs. + */ + v2UserSettingsRailCollapsed?: boolean; + /** * How the workspace documents explorer is presented, and the views pinned in its rail. * @@ -220,6 +226,8 @@ export const WRITABLE_USER_SETTING_KEYS = [ // Whether the Admin Settings categories rail is showing icons only. Separate from the // shell and workspace rails for the same reason they are separate from each other. 'v2AdminRailCollapsed', + // Whether the User Settings sections rail is showing icons only. + 'v2UserSettingsRailCollapsed', // Workspace documents explorer: how the list is presented, and the saved filter // combinations pinned in its navigation rail. 'v2DocumentsPrefs', diff --git a/application/v2_ui/src/pages/SettingsPage.tsx b/application/v2_ui/src/pages/SettingsPage.tsx index 98e69388f..b2be8537f 100644 --- a/application/v2_ui/src/pages/SettingsPage.tsx +++ b/application/v2_ui/src/pages/SettingsPage.tsx @@ -1,9 +1,10 @@ // SettingsPage.tsx -// Personal settings: the user's own preferences, workspaces, feedback and violations. +// Personal settings: the user's own preferences, activity, workspaces, feedback and violations. // -// The classic profile page nests these behind six Bootstrap tabs. The structure is kept — -// people know where things are — but the tabs become a slim vertical rail, matching the -// left-rail-only shape of the rest of this interface and leaving the content pane free. +// Laid out like Admin Settings so the two read as one design: a left rail of tabs that +// collapses to icons, cards that fill the width of the page, and an "On this page" index on +// the right that lists the cards on the current tab and marks the one in view. Below the +// lg breakpoint the rail becomes a select, as it does on Admin Settings. // // The active tab lives in the query string so a particular tab can be linked to and // survives a reload, which the classic page also supports via ?tab=. @@ -12,37 +13,97 @@ // reached from offers Admin Settings directly beneath it, and two entries a line apart // called Settings and Admin Settings would not say which one you had landed on. -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { clsx } from 'clsx'; -import { Loader2 } from 'lucide-react'; +import { Loader2, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { PageHeader } from '../components/layout/PageHeader'; import { UserAvatar } from '../components/layout/UserAvatar'; +import { SettingsIndex } from '../components/admin/SettingsIndex'; +import { + SettingsSectionRegistryContext, + type SettingsSectionEntry, +} from '../components/settings/SettingsCard'; import { SETTINGS_TABS } from '../components/settings/tabs'; import { useBootstrapStore } from '../stores/bootstrapStore'; import { useUserSettingsStore } from '../stores/userSettingsStore'; +/** Orders index entries the way their cards appear on the page. */ +function byDocumentPosition(a: SettingsSectionEntry, b: SettingsSectionEntry): number { + const first = document.getElementById(a.sectionId); + const second = document.getElementById(b.sectionId); + if (!first || !second) { + return 0; + } + return first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; +} + export function SettingsPage() { const [searchParams, setSearchParams] = useSearchParams(); const features = useBootstrapStore((state) => state.data?.features ?? {}); const saving = useUserSettingsStore((state) => state.saving); + const updateUserSettings = useUserSettingsStore((state) => state.update); + const railCollapsed = useUserSettingsStore( + (state) => state.settings.v2UserSettingsRailCollapsed === true, + ); + const scrollRef = useRef(null); + const [sections, setSections] = useState>({}); // A tab whose capability is off is hidden rather than shown empty: its endpoints fail // in that state, so it could only ever display an error. const tabs = useMemo( - () => SETTINGS_TABS.filter((tab) => !tab.feature || features[tab.feature] === true - || (tab.id === 'violations' && features.enable_content_screening === true)), + () => SETTINGS_TABS.filter((tab) => !tab.feature || features[tab.feature] === true), [features], ); const requested = searchParams.get('tab'); const active = tabs.find((tab) => tab.id === requested) ?? tabs[0]; + const activeId = active?.id; + + const registry = useMemo( + () => ({ + register: (entry: SettingsSectionEntry) => + setSections((current) => ({ ...current, [entry.sectionId]: entry })), + unregister: (sectionId: string) => + setSections((current) => { + if (!(sectionId in current)) { + return current; + } + const next = { ...current }; + delete next[sectionId]; + return next; + }), + }), + [], + ); + + const entries = useMemo( + () => Object.values(sections).sort(byDocumentPosition), + [sections], + ); + const grouped = entries.some((entry) => entry.groupId); + + // A new tab starts at its top rather than at the scroll position of the last one. + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [activeId]); + + const jumpToSection = useCallback((sectionId: string) => { + const card = document.getElementById(sectionId); + if (!card) { + return; + } + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + card.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' }); + document.getElementById(`${sectionId}-title`)?.focus({ preventScroll: true }); + }, []); if (!active) { return null; } const { Component } = active; + const selectTab = (tabId: string) => setSearchParams({ tab: tabId }); return (
@@ -60,40 +121,101 @@ export function SettingsPage() { } /> -
- + + + -
-
- +
+
+ + +
+ +
+
+
+ + + +
+ {entries.length > 1 ? ( + + ) : null} +
); -} +} \ No newline at end of file diff --git a/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md new file mode 100644 index 000000000..3fe9b1d25 --- /dev/null +++ b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md @@ -0,0 +1,81 @@ +# V2 User Settings Redesign + +**Implemented in version: 0.261.277** + +## Overview + +V2 User Settings now uses the same design language as V2 Admin Settings, so personal +settings are laid out like the admin ones: a collapsible section rail, full-width settings +cards, and an **On this page** index. This is the first stage of bringing V2 User +Settings to parity with the classic Profile page. Later stages add the classic preferences +that V2 does not yet honour: completion audio, voice playback, microphone permission, +retention, fact memory, Microsoft 365 sharing, tutorials, and Latest Features. + +Dependencies: the V2 React interface (`application/v2_ui`) and the user settings API +(`/api/user/settings`). + +## Technical specifications + +### Layout + +- `pages/SettingsPage.tsx` renders the shell: + - The section rail collapses to labelled icons. The choice is saved as the per-user + setting `v2UserSettingsRailCollapsed`, independent of the admin rail's + `v2AdminRailCollapsed`. + - Below the `lg` breakpoint, a select replaces the rail. + - The content column fills the available width (up to `112rem`). At container widths of + `76rem` and above, a `15rem` index column sits beside it. +- `components/settings/SettingsCard.tsx` provides: + - `SettingsCard`, a card with the admin card styling (`admin-settings-distinct`, + `admin-section-body`), an icon, a description, an optional status chip, and header + actions. + - `SettingsGroup`, a labelled group of cards. + - `SettingsSectionRegistryContext`. Each card registers with it so the page can build + its index without a hand-maintained list. Entries are ordered by DOM position. +- The page reuses the admin `SettingsIndex` component. Selecting an entry scrolls to the + card, respecting reduced motion, and moves focus to the card title. + +### Tabs + +| Tab | Cards | +| --- | --- | +| Preferences | Grouped: Appearance (Text size, Conversation list), Chat (Conversation navigation, Spoken replies), Notifications and alerts (Desktop notifications, Workflow alerts on this device), Diagrams and charts (Diagrams, Charts) | +| Stats | Lifetime totals, four activity charts, Storage used, Account | +| Groups / Public workspaces | Your groups / Your public workspaces | +| Feedback | Summary, Your feedback (with Export CSV) | +| Violations | Summary, Your violations | + +Cards for capabilities that are turned off are not shown. + +### Violations visibility + +The Violations tab is always listed, as on the classic Profile page. When neither +`enable_content_safety` nor `enable_content_screening` is on, the tab shows a single +explanatory card and makes no API calls. + +### Configuration + +| Setting | Scope | Purpose | +| --- | --- | --- | +| `v2UserSettingsRailCollapsed` | Per user | Remembers whether the User Settings rail is collapsed | + +The key is allowed in `route_backend_users.py` and in `WRITABLE_USER_SETTING_KEYS` in +`lib/userSettings.ts`. + +## Usage + +Open **User Settings** from the account menu. Use the button at the top of the rail to +collapse or expand it, and the **On this page** index to move between cards. + +## Testing and validation + +- `functional_tests/test_v2_user_settings_layout.py`: rail persistence, index registry, + card usage on every tab, preference groups, and Violations visibility. +- `functional_tests/test_v2_settings_and_workspace_tags.py`: tab gating, updated for the + always-listed Violations tab. +- Related fix: [Feedback My Routes Filter Unpack Fix](../fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md). + +### Known limitations + +The index appears only when a tab has more than one card, so the Groups and Public +workspaces tabs do not show it. diff --git a/docs/explanation/fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md b/docs/explanation/fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md new file mode 100644 index 000000000..f7ecb36ad --- /dev/null +++ b/docs/explanation/fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md @@ -0,0 +1,34 @@ +# Feedback My Routes Filter Unpack Fix + +**Fixed in version: 0.261.277** + +## Issue + +The **Feedback** tab failed for every user in both the classic Profile page and V2 User +Settings. `/feedback/my`, `/feedback/my/stats`, and `/feedback/my/export` all returned +HTTP 500 with `too many values to unpack (expected 2)`. + +## Root cause + +`_parse_feedback_filters()` in `route_backend_feedback.py` gained a third return value +(the archive state) for the admin feedback views. The three user-facing routes still +unpacked two values, so each one raised before it queried anything. + +## Technical details + +### Files modified + +- `application/single_app/route_backend_feedback.py`: `feedback_my`, `feedback_my_stats`, + and `feedback_my_export` now unpack `filter_type, filter_ack_bool, _`. User routes + ignore the archive state, which only applies to the admin review queue. + +### Testing + +- `functional_tests/test_feedback_my_routes_filter_unpack.py` parses the module. It checks + that every caller of `_parse_feedback_filters()` unpacks exactly as many values as the + helper returns, so a future change to the helper fails the test instead of the page. + +## Validation + +- Before: the Feedback tab showed an error and Export CSV returned a 500. +- After: users see their submitted feedback, summary counts, and the CSV export again. diff --git a/docs/guides/update-profile-preferences.md b/docs/guides/update-profile-preferences.md index cc4f7deea..e43ae344f 100644 --- a/docs/guides/update-profile-preferences.md +++ b/docs/guides/update-profile-preferences.md @@ -47,6 +47,21 @@ Profile preferences are for personal comfort and control: font size, navigation 6. If shown, configure **Desktop Conversation Notifications**, **Fact Memory**, retention settings, microphone permission, or text-to-speech settings. 7. In **Workflow Alert Sounds**, choose whether workflow alerts can play sounds in this browser. The switch takes effect at once and applies only to this browser, so a shared operations screen and your own laptop can differ. See [Alerts that need acknowledgment]({{ '/guides/manage-notifications/#alerts-that-need-acknowledgment' | relative_url }}). +## In the new interface + +In the new interface, open **User Settings** from the account menu. The left rail lists +**Preferences**, **Stats**, **Groups**, **Public workspaces**, **Feedback**, and +**Violations**. To give the settings more room, collapse the rail to icons with the button +at its top. The rail stays collapsed until you expand it again, on any device. + +Each tab is laid out as cards. On wide screens an **On this page** index on the right +lists them, so you can jump straight to a card. Preferences are grouped under +**Appearance**, **Chat**, **Notifications and alerts**, and **Diagrams and charts**, and +changes save as soon as you make them. + +**Violations** is always listed. If content safety is off for your application, the tab +says so instead of showing an empty list. + ## Microsoft 365 data preferences Calendar, Email, OneDrive, and SharePoint have independent sharing preferences, diff --git a/functional_tests/test_feedback_my_routes_filter_unpack.py b/functional_tests/test_feedback_my_routes_filter_unpack.py new file mode 100644 index 000000000..59da2f0db --- /dev/null +++ b/functional_tests/test_feedback_my_routes_filter_unpack.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# test_feedback_my_routes_filter_unpack.py +""" +Functional test for the user feedback routes filter unpack fix. +Version: 0.261.277 +Implemented in: 0.261.277 + +_parse_feedback_filters() returns three values (type, acknowledged, archive state), but the +user-facing /feedback/my, /feedback/my/stats and /feedback/my/export routes unpacked two, +so every request failed with "too many values to unpack" and the Feedback tab in both +interfaces showed an error. This test pins the helper's arity and that every caller +unpacks all three values. +""" + +import ast +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FEEDBACK_ROUTE = REPO_ROOT / "application" / "single_app" / "route_backend_feedback.py" + + +def _module(): + return ast.parse(FEEDBACK_ROUTE.read_text(encoding="utf-8")) + + +def _helper_return_arity(tree): + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_parse_feedback_filters": + arities = { + len(ret.value.elts) + for ret in ast.walk(node) + if isinstance(ret, ast.Return) and isinstance(ret.value, ast.Tuple) + } + assert len(arities) == 1, f"_parse_feedback_filters returns mixed tuple sizes: {arities}" + return arities.pop() + raise AssertionError("_parse_feedback_filters was not found") + + +def test_version_is_at_least_the_implementing_release(): + print("Testing the application version...") + assert_app_version_at_least("0.261.277") + return True + + +def test_every_caller_unpacks_every_filter_value(): + print("Testing _parse_feedback_filters callers...") + tree = _module() + arity = _helper_return_arity(tree) + + callers = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + call = node.value + if not (isinstance(call, ast.Call) and getattr(call.func, "id", None) == "_parse_feedback_filters"): + continue + target = node.targets[0] + assert isinstance(target, ast.Tuple), f"Line {node.lineno} should unpack the filter tuple" + assert len(target.elts) == arity, ( + f"Line {node.lineno} unpacks {len(target.elts)} values but the helper returns {arity}" + ) + callers.append(node.lineno) + + assert len(callers) >= 3, "The user feedback list, stats and export routes should all use the helper" + print(f" ok {len(callers)} callers unpack {arity} values") + return True + + +if __name__ == "__main__": + tests = [test_version_is_at_least_the_implementing_release, test_every_caller_unpacks_every_filter_value] + results = [] + for test in tests: + try: + results.append(test()) + except Exception as exc: + print(f"FAILED {test.__name__}: {exc}") + results.append(False) + print(f"Results: {sum(bool(r) for r in results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/functional_tests/test_v2_settings_and_workspace_tags.py b/functional_tests/test_v2_settings_and_workspace_tags.py index 850c5cbb7..5741dc9cc 100644 --- a/functional_tests/test_v2_settings_and_workspace_tags.py +++ b/functional_tests/test_v2_settings_and_workspace_tags.py @@ -183,10 +183,16 @@ def test_gated_tabs_are_hidden_rather_than_empty(): "enable_group_workspaces", "enable_public_workspaces", "enable_user_feedback", - "enable_content_safety", ): assert flag in tabs, f"The tab registry should gate on {flag}" + # Violations is always listed, as on the classic profile page. Its tab explains that + # content safety is off instead of calling endpoints that would refuse the request. + assert "enable_content_safety" not in tabs, "The Violations tab should not be hidden" + violations = _read(V2_SRC / "components" / "settings" / "ViolationsTab.tsx") + for flag in ("enable_content_safety", "enable_content_screening"): + assert flag in violations, f"The Violations tab should check {flag} before loading" + page = _read(V2_SRC / "pages" / "SettingsPage.tsx") assert re.search(r"SETTINGS_TABS\.filter\((.|\n)*?features\[tab\.feature\] === true", page), ( "Tabs must be filtered by their capability flag before rendering" diff --git a/functional_tests/test_v2_user_settings_layout.py b/functional_tests/test_v2_user_settings_layout.py new file mode 100644 index 000000000..d0c516fac --- /dev/null +++ b/functional_tests/test_v2_user_settings_layout.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# test_v2_user_settings_layout.py +""" +Functional test for the V2 User Settings layout redesign. +Version: 0.261.277 +Implemented in: 0.261.277 + +User Settings now follows the Admin Settings design language: a collapsible section rail +remembered per user, full-width content, settings cards, and an "On this page" index on +the right that is built from the cards each tab renders. Preferences are grouped, and the +Violations tab is always listed. This file pins those contracts. +""" + +import re +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src" +SETTINGS_DIR = V2_SRC / "components" / "settings" +PAGE_TSX = V2_SRC / "pages" / "SettingsPage.tsx" +CARD_TSX = SETTINGS_DIR / "SettingsCard.tsx" +USER_SETTINGS_TS = V2_SRC / "lib" / "userSettings.ts" +USERS_ROUTE = REPO_ROOT / "application" / "single_app" / "route_backend_users.py" +PREFERENCE_KEY = "v2UserSettingsRailCollapsed" + + +def _read(path): + assert path.is_file(), f"Missing expected file: {path}" + return path.read_text(encoding="utf-8") + + +def test_version_is_at_least_the_implementing_release(): + print("Testing the application version...") + assert_app_version_at_least("0.261.277") + return True + + +def test_rail_collapse_is_remembered_per_user(): + print("Testing the user settings rail preference...") + page = _read(PAGE_TSX) + assert f"state.settings.{PREFERENCE_KEY} === true" in page + assert f"updateUserSettings({{ {PREFERENCE_KEY}: !railCollapsed }})" in page + assert 'aria-label="User settings sections"' in page + assert "Collapse user settings sections" in page and "Expand user settings sections" in page + + allowed = re.search(r"allowed_keys = \{(.*?)\}", _read(USERS_ROUTE), re.DOTALL) + assert allowed and f"'{PREFERENCE_KEY}'" in allowed.group(1), ( + "The settings route drops unlisted keys silently, so the rail key must be allowed" + ) + user_settings = _read(USER_SETTINGS_TS) + writable = re.search(r"export const WRITABLE_USER_SETTING_KEYS = \[(.*?)\] as const;", user_settings, re.DOTALL) + assert writable and f"'{PREFERENCE_KEY}'" in writable.group(1) + assert f"{PREFERENCE_KEY}?: boolean;" in user_settings + return True + + +def test_page_index_is_built_from_registered_cards(): + print("Testing the On this page index...") + page = _read(PAGE_TSX) + card = _read(CARD_TSX) + assert "SettingsSectionRegistryContext.Provider" in page + assert " Date: Wed, 7 Oct 2026 09:37:10 -0400 Subject: [PATCH 2/6] V2 user settings: completion sounds, spoken replies, microphone and retention - Completion sound card (sound, volume, preview, mute) with a reply listener - Spoken replies: live voice catalogue, speed, sample and auto-play through one shared reader - Microphone permission status without prompting on load - Personal retention card; personal retention route now accepts 'default' - Voice and audio, Data and privacy preference groups - Version 0.261.278, tests and docs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../route_backend_retention_policy.py | 8 +- .../src/components/chat/MessageActions.tsx | 63 +-- .../components/settings/PreferencesTab.tsx | 67 ++- .../src/components/settings/RetentionCard.tsx | 182 ++++++++ .../components/settings/VoiceAudioCards.tsx | 404 ++++++++++++++++++ application/v2_ui/src/lib/completionAudio.ts | 161 +++++++ application/v2_ui/src/lib/speechPlayback.ts | 182 ++++++++ .../v2_ui/src/lib/useNotificationRuntime.ts | 30 +- application/v2_ui/src/lib/voice.ts | 12 +- .../features/V2_USER_SETTINGS_REDESIGN.md | 39 +- .../PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md | 43 ++ docs/guides/update-profile-preferences.md | 23 +- .../test_v2_settings_and_workspace_tags.py | 4 +- ..._v2_user_settings_audio_voice_retention.py | 194 +++++++++ 15 files changed, 1316 insertions(+), 98 deletions(-) create mode 100644 application/v2_ui/src/components/settings/RetentionCard.tsx create mode 100644 application/v2_ui/src/components/settings/VoiceAudioCards.tsx create mode 100644 application/v2_ui/src/lib/completionAudio.ts create mode 100644 application/v2_ui/src/lib/speechPlayback.ts create mode 100644 docs/explanation/fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md create mode 100644 functional_tests/test_v2_user_settings_audio_voice_retention.py diff --git a/application/single_app/config.py b/application/single_app/config.py index a21fa6be3..7378b2436 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.277" +VERSION = "0.261.278" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_retention_policy.py b/application/single_app/route_backend_retention_policy.py index 6fd0afa96..b3969a8f7 100644 --- a/application/single_app/route_backend_retention_policy.py +++ b/application/single_app/route_backend_retention_policy.py @@ -412,8 +412,8 @@ def update_user_retention_settings(): Update retention policy settings for the current user's personal workspace. Body: - conversation_retention_days (str|int): Number of days or 'none' - document_retention_days (str|int): Number of days or 'none' + conversation_retention_days (str|int): Number of days, 'none' or 'default' + document_retention_days (str|int): Number of days, 'none' or 'default' """ try: user_id = get_current_user_id() @@ -426,6 +426,8 @@ def update_user_retention_settings(): conv_retention = data['conversation_retention_days'] if conv_retention == 'none' or conv_retention is None: retention_settings['conversation_retention_days'] = 'none' + elif conv_retention == 'default': + retention_settings['conversation_retention_days'] = 'default' else: try: days = int(conv_retention) @@ -451,6 +453,8 @@ def update_user_retention_settings(): doc_retention = data['document_retention_days'] if doc_retention == 'none' or doc_retention is None: retention_settings['document_retention_days'] = 'none' + elif doc_retention == 'default': + retention_settings['document_retention_days'] = 'default' else: try: days = int(doc_retention) diff --git a/application/v2_ui/src/components/chat/MessageActions.tsx b/application/v2_ui/src/components/chat/MessageActions.tsx index e2b4eab44..bad2ec360 100644 --- a/application/v2_ui/src/components/chat/MessageActions.tsx +++ b/application/v2_ui/src/components/chat/MessageActions.tsx @@ -35,7 +35,6 @@ import { } from 'lucide-react'; import { useChatStore } from '../../stores/chatStore'; import { useCollaborationStore } from '../../stores/collaborationStore'; -import { useUserSettingsStore } from '../../stores/userSettingsStore'; import { useBootstrapStore } from '../../stores/bootstrapStore'; import { toast } from '../../stores/toastStore'; import { ApiError } from '../../lib/apiClient'; @@ -55,7 +54,7 @@ import { type MessageExportFormat, } from '../../lib/endpoints'; import { buildMessageVisualAssets } from '../../lib/exportVisuals'; -import { synthesizeSpeech } from '../../lib/voice'; +import { speak, stopSpeech, useSpeechState } from '../../lib/speechPlayback'; import { suggestPromptName } from '../../lib/promptSlash'; import { createPrompt } from '../../lib/workspaceApi'; import { @@ -97,60 +96,30 @@ export function IconButton({ ); } -/** Play a message aloud via the speech endpoint. */ +/** + * Play a message aloud via the speech endpoint. + * + * Playback lives in the shared reader (lib/speechPlayback), so only one message is read at a + * time and a message auto-play started shows here as playing and can be stopped here. + */ function SpeakButton({ message }: { message: ChatMessage }) { - const [state, setState] = useState<'idle' | 'loading' | 'playing'>('idle'); - const audioRef = useRef(null); - const urlRef = useRef(null); - const preferredVoice = useUserSettingsStore((store) => - String(store.settings.ttsVoice ?? ''), - ); - - // Release the object URL and stop playback if the message unmounts mid-play. - useEffect( - () => () => { - audioRef.current?.pause(); - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - } - }, - [], - ); + const state = useSpeechState(message.id); - const toggle = async () => { - if (state === 'playing') { - audioRef.current?.pause(); - setState('idle'); + const toggle = () => { + if (state !== 'idle') { + stopSpeech(); return; } - - setState('loading'); - try { - // The chosen voice comes from the user's preferences, and the message is read - // through the same conversion the clipboard uses: citation markers and masked - // spans should not be spoken aloud any more than they should be pasted. - const url = await synthesizeSpeech( - messageToPlainText(message), - preferredVoice || undefined, - ); - urlRef.current = url; - const audio = new Audio(url); - audioRef.current = audio; - audio.onended = () => setState('idle'); - audio.onerror = () => setState('idle'); - await audio.play(); - setState('playing'); - } catch { - // Speech is optional; a failure leaves the control back at rest rather than - // interrupting the conversation. - setState('idle'); - } + // The message is read through the same conversion the clipboard uses: citation + // markers and masked spans should not be spoken aloud any more than they should be + // pasted. Speech is optional, so a failure just leaves the control back at rest. + void speak(message.id, messageToPlainText(message)).catch(() => undefined); }; return ( void toggle()} + onClick={toggle} active={state === 'playing'} disabled={state === 'loading'} > diff --git a/application/v2_ui/src/components/settings/PreferencesTab.tsx b/application/v2_ui/src/components/settings/PreferencesTab.tsx index 7dcd2b7c2..fbfe392da 100644 --- a/application/v2_ui/src/components/settings/PreferencesTab.tsx +++ b/application/v2_ui/src/components/settings/PreferencesTab.tsx @@ -7,8 +7,9 @@ // remaining classic preferences are deliberately left to that page until V2 implements the // behaviour behind them. // -// Cards are arranged in groups (Appearance, Chat, Notifications and alerts, Diagrams and -// charts) so the page index on the right can list them the way Admin Settings does. +// Cards are arranged in groups (Appearance, Chat, Voice and audio, Notifications and alerts, +// Data and privacy, Diagrams and charts) so the page index on the right can list them the way +// Admin Settings does. import { useEffect, useSyncExternalStore } from 'react'; import { clsx } from 'clsx'; @@ -19,7 +20,6 @@ import { Siren, Tags, Type, - Volume2, Workflow, } from 'lucide-react'; import { useUserSettingsStore } from '../../stores/userSettingsStore'; @@ -39,6 +39,8 @@ import { } from '../../lib/userSettings'; import { Toggle, Skeleton } from '../ui/primitives'; import { SettingsCard, SettingsGroup } from './SettingsCard'; +import { CompletionAudioCard, MicrophoneCard, SpokenRepliesCard } from './VoiceAudioCards'; +import { RetentionCard } from './RetentionCard'; import { VISUAL_STYLE_SETTING_KEYS } from '../../lib/blockVisualStyle'; import { DEFAULT_VISUAL_STYLE, @@ -57,17 +59,6 @@ import { useWorkflowAlertDevicePreference, } from '../../lib/workflowAlertDevicePreferences'; -/** Voices offered for spoken replies, matching what the speech endpoint accepts. */ -const TTS_VOICES = [ - { id: '', label: 'Deployment default' }, - { id: 'en-US-AriaNeural', label: 'Aria (US)' }, - { id: 'en-US-GuyNeural', label: 'Guy (US)' }, - { id: 'en-US-JennyNeural', label: 'Jenny (US)' }, - { id: 'en-GB-SoniaNeural', label: 'Sonia (UK)' }, - { id: 'en-GB-RyanNeural', label: 'Ryan (UK)' }, - { id: 'en-AU-NatashaNeural', label: 'Natasha (AU)' }, -]; - function FontSizeChoice({ value, onChange, @@ -320,7 +311,7 @@ export function PreferencesTab() { - {(enabled('enable_conversation_contents_drawer') || enabled('enable_text_to_speech')) && ( + {enabled('enable_conversation_contents_drawer') && ( )} + + )} + + {(enabled('enable_chat_completion_audio_cues') + || enabled('enable_text_to_speech') + || enabled('enable_speech_to_text_input')) && ( + + {enabled('enable_chat_completion_audio_cues') && ( + + )} {enabled('enable_text_to_speech') && ( - - - + )} + {enabled('enable_speech_to_text_input') && } )} @@ -421,6 +408,16 @@ export function PreferencesTab() { + {enabled('enable_retention_policy_personal') && ( + + + + )} + = { + 1: '1 day', + 7: '7 days (1 week)', + 14: '14 days (2 weeks)', + 21: '21 days (3 weeks)', + 90: '90 days (3 months)', + 180: '180 days (6 months)', + 365: '365 days (1 year)', + 730: '730 days (2 years)', +}; + +function dayLabel(days: number): string { + return DAY_LABELS[days] ?? `${days} days`; +} + +interface RetentionDefaults { + success?: boolean; + default_conversation_label?: string; + default_document_label?: string; +} + +/** A stored value as the select reads it: 'default' when nothing has been chosen. */ +export function readRetentionChoice(value: unknown): string { + if (value === undefined || value === null || value === '') { + return 'default'; + } + return String(value); +} + +function choices(current: string): { value: string; label: string }[] { + const days: number[] = [...PERSONAL_RETENTION_DAYS]; + const numeric = Number(current); + // A period set elsewhere that this list doesn't offer stays selectable. + if (current !== 'default' && current !== 'none' && Number.isInteger(numeric) && !days.includes(numeric)) { + days.push(numeric); + days.sort((left, right) => left - right); + } + return [ + { value: 'none', label: 'No automatic deletion' }, + ...days.map((value) => ({ value: String(value), label: dayLabel(value) })), + ]; +} + +const SELECT_CLASS = + 'mt-1.5 w-full rounded-lg border border-edge bg-surface-solid px-2.5 py-2 text-sm text-text-1'; + +export function RetentionCard({ settings }: { settings: UserSettings }) { + const stored = useMemo(() => { + const policy = (settings.retention_policy ?? {}) as Record; + return { + conversation: readRetentionChoice(policy.conversation_retention_days), + document: readRetentionChoice(policy.document_retention_days), + }; + }, [settings.retention_policy]); + const [conversation, setConversation] = useState(stored.conversation); + const [document, setDocument] = useState(stored.document); + const [defaults, setDefaults] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setConversation(stored.conversation); + setDocument(stored.document); + }, [stored]); + + useEffect(() => { + const controller = new AbortController(); + api.get('/api/retention-policy/defaults/personal', controller.signal) + .then((response) => setDefaults(response)) + .catch(() => undefined); + return () => controller.abort(); + }, []); + + const dirty = conversation !== stored.conversation || document !== stored.document; + + const save = async () => { + setSaving(true); + try { + await api.post('/api/retention-policy/user', { + conversation_retention_days: conversation, + document_retention_days: document, + }); + // The route writes `retention_policy` itself; reflect it locally without a second write. + useUserSettingsStore.setState((state) => ({ + settings: { + ...state.settings, + retention_policy: { + conversation_retention_days: conversation, + document_retention_days: document, + }, + }, + })); + toast.success('Retention settings saved.'); + } catch (error) { + toast.error(error instanceof ApiError || error instanceof Error ? error.message : 'Retention settings could not be saved.'); + } finally { + setSaving(false); + } + }; + + const field = ( + label: string, + value: string, + onChange: (next: string) => void, + defaultLabel: string | undefined, + help: string, + ) => ( + + ); + + return ( + void save()} + disabled={!dirty || saving} + className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-on-accent hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" + > + {saving ? 'Saving…' : 'Save'} + + )} + > +
+ {field( + 'Conversations', + conversation, + setConversation, + defaults?.default_conversation_label, + 'Conversations older than this are deleted.', + )} + {field( + 'Documents', + document, + setDocument, + defaults?.default_document_label, + 'Documents older than this are deleted.', + )} +
+

+ Deleted conversations are archived first when your organization has archiving turned on, and every + deletion is recorded in the activity history. +

+
+ ); +} diff --git a/application/v2_ui/src/components/settings/VoiceAudioCards.tsx b/application/v2_ui/src/components/settings/VoiceAudioCards.tsx new file mode 100644 index 000000000..284c0ce69 --- /dev/null +++ b/application/v2_ui/src/components/settings/VoiceAudioCards.tsx @@ -0,0 +1,404 @@ +// VoiceAudioCards.tsx +// The voice and audio cards on the Preferences tab: completion sounds, spoken replies and the +// microphone. +// +// Each card mirrors a section of the classic profile page and saves to the same preference +// keys, so the two interfaces stay in step. Unlike classic there is no Save button: a change +// is written as it is made, like every other preference here. + +import { useEffect, useState } from 'react'; +import { Loader2, Mic, Play, Square, Volume2, BellRing } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { + COMPLETION_AUDIO_SOUNDS, + COMPLETION_AUDIO_VOLUME_MAX, + COMPLETION_AUDIO_VOLUME_MIN, + previewCompletionSound, + readCompletionAudioPreferences, +} from '../../lib/completionAudio'; +import { + TTS_SPEED_MAX, + TTS_SPEED_MIN, + TTS_SPEED_STEP, + normalizeTtsSpeed, + speak, + stopSpeech, + useSpeechState, +} from '../../lib/speechPlayback'; +import type { UserSettings } from '../../lib/userSettings'; +import { toast } from '../../stores/toastStore'; +import { Toggle } from '../ui/primitives'; +import { SettingsCard } from './SettingsCard'; + +const SELECT_CLASS = + 'mt-1.5 w-full max-w-xs rounded-lg border border-edge bg-surface-solid px-2.5 py-2 text-sm text-text-1'; +const BUTTON_CLASS = + 'inline-flex items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5 text-sm font-medium text-text-1 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50'; + +type Update = (partial: UserSettings) => void; + +export function CompletionAudioCard({ settings, update }: { settings: UserSettings; update: Update }) { + const preferences = readCompletionAudioPreferences(settings); + const [previewFailed, setPreviewFailed] = useState(false); + + const preview = async () => { + setPreviewFailed(!(await previewCompletionSound(preferences.soundId, preferences.volume))); + }; + + return ( + +
+
+ update({ chatCompletionAudioEnabled: next })} + label="Play completion sounds" + /> +
+
+ update({ chatCompletionAudioMuted: next })} + label="Mute for now" + description="Silence the sounds without losing your choice of sound and volume." + /> +
+
+ +
+ + + +
+ {previewFailed && ( +

+ This browser would not play the sound. Check that the tab is not muted. +

+ )} +
+ ); +} + +interface TtsVoice { + name: string; + display_name?: string; + gender?: string; + language?: string; + locale?: string; +} + +interface TtsVoicesResponse { + voices?: TtsVoice[]; + default_voice?: string | null; + source?: string; + warning?: string | null; +} + +const PREVIEW_KEY = 'settings-voice-preview'; +const PREVIEW_TEXT = 'Hello! This is how your assistant replies will sound when they are read aloud.'; + +function voiceLabel(voice: TtsVoice): string { + const name = voice.display_name || voice.name; + const gender = voice.gender && voice.gender !== 'Unknown' ? `, ${voice.gender}` : ''; + return `${name} (${voice.locale || voice.language || 'voice'}${gender})`; +} + +/** Group voices by language for the picker, keeping the server's order inside each group. */ +function groupVoices(voices: TtsVoice[]): [string, TtsVoice[]][] { + const groups = new Map(); + for (const voice of voices) { + const language = voice.language || 'Other'; + groups.set(language, [...(groups.get(language) ?? []), voice]); + } + return [...groups.entries()]; +} + +export function SpokenRepliesCard({ settings, update }: { settings: UserSettings; update: Update }) { + const [voices, setVoices] = useState([]); + const [defaultVoice, setDefaultVoice] = useState(null); + const [voicesLoading, setVoicesLoading] = useState(true); + const [voicesWarning, setVoicesWarning] = useState(null); + const previewState = useSpeechState(PREVIEW_KEY); + + const voice = String(settings.ttsVoice ?? ''); + const speed = normalizeTtsSpeed(settings.ttsSpeed); + const autoplay = settings.ttsAutoplay === true; + + useEffect(() => { + const controller = new AbortController(); + api.get('/api/chat/tts/voices', controller.signal) + .then((response) => { + setVoices(Array.isArray(response?.voices) ? response.voices : []); + setDefaultVoice(response?.default_voice ?? null); + setVoicesWarning(response?.source === 'fallback' ? 'Showing a basic list of voices; the full list could not be loaded.' : null); + }) + .catch(() => { + if (!controller.signal.aborted) { + setVoicesWarning('The list of voices could not be loaded.'); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setVoicesLoading(false); + } + }); + return () => controller.abort(); + }, []); + + // Stop a preview that is still playing when the card goes away. + useEffect(() => () => { + stopSpeech(); + }, []); + + // A stored voice the list no longer offers stays selectable, so opening the page never + // silently changes it. + const storedMissing = voice !== '' && !voices.some((item) => item.name === voice); + + const playPreview = () => { + if (previewState !== 'idle') { + stopSpeech(); + return; + } + void speak(PREVIEW_KEY, PREVIEW_TEXT, { voice: voice || undefined, speed }).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : 'The sample could not be played.'); + }); + }; + + return ( + +
+ + + +
+ +
+ + Uses the voice and speed chosen above. +
+ +
+ update(next ? { ttsAutoplay: true, ttsEnabled: true } : { ttsAutoplay: false })} + label="Read replies aloud automatically" + description="Each new reply in the conversation you have open is read aloud as soon as it finishes." + /> +
+
+ ); +} + +type MicrophoneStatus = 'checking' | 'granted' | 'denied' | 'prompt' | 'unsupported'; + +const MICROPHONE_LABELS: Record = { + checking: 'Checking…', + granted: 'Allowed', + denied: 'Blocked', + prompt: 'Not decided yet', + unsupported: 'Not available in this browser', +}; + +const MICROPHONE_TONES: Record = { + checking: 'bg-surface-2 text-text-2', + granted: 'bg-ok-soft text-ok', + denied: 'bg-danger-soft text-danger', + prompt: 'bg-surface-2 text-text-2', + unsupported: 'bg-surface-2 text-text-3', +}; + +function microphoneSupported(): boolean { + return typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia); +} + +/** + * Read the permission without prompting. The Permissions API answers silently where it is + * supported; where it is not, the state is reported as undecided rather than opening the + * browser's prompt just by visiting the page. + */ +async function readMicrophonePermission(): Promise { + if (!microphoneSupported()) { + return 'unsupported'; + } + try { + const status = await navigator.permissions.query({ name: 'microphone' as PermissionName }); + return status.state === 'granted' || status.state === 'denied' ? status.state : 'prompt'; + } catch { + return 'prompt'; + } +} + +export function MicrophoneCard() { + const [status, setStatus] = useState('checking'); + const [message, setMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + let permission: PermissionStatus | null = null; + const refresh = () => { + void readMicrophonePermission().then((next) => { + if (!cancelled) { + setStatus(next); + } + }); + }; + refresh(); + // Follow a change made in the browser's own site settings while the page is open. + navigator.permissions?.query({ name: 'microphone' as PermissionName }) + .then((result) => { + permission = result; + result.addEventListener('change', refresh); + }) + .catch(() => undefined); + return () => { + cancelled = true; + permission?.removeEventListener('change', refresh); + }; + }, []); + + const request = async () => { + setMessage(null); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach((track) => track.stop()); + setStatus('granted'); + } catch (error) { + const name = error instanceof DOMException ? error.name : ''; + if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { + setStatus('denied'); + setMessage('The browser is blocking the microphone for this site. Allow it in the browser\'s site settings (the icon at the left of the address bar), then come back to this page.'); + } else if (name === 'NotFoundError') { + setMessage('No microphone was found. Connect one and try again.'); + } else { + setMessage('The microphone could not be reached.'); + } + } + }; + + return ( + +
+ Permission + + {MICROPHONE_LABELS[status]} + + {(status === 'prompt' || status === 'denied') && ( + + )} +
+ {status === 'granted' && ( +

+ To take the permission back, use the browser's site settings (the icon at the left of the + address bar) and block the microphone for this site. +

+ )} + {message &&

{message}

} +
+ ); +} diff --git a/application/v2_ui/src/lib/completionAudio.ts b/application/v2_ui/src/lib/completionAudio.ts new file mode 100644 index 000000000..ec1c0265d --- /dev/null +++ b/application/v2_ui/src/lib/completionAudio.ts @@ -0,0 +1,161 @@ +// completionAudio.ts +// A short sound when an assistant reply finishes somewhere the reader isn't looking. +// +// Mirrors the classic interface (static/js/completion-audio-cues.js) and shares its +// preferences, so a choice made on either profile page holds on both: +// +// - It plays only when the administrator turned on `enable_chat_completion_audio_cues`, the +// user turned cues on (they are off until chosen, as in classic) and has not muted them. +// - It plays only when the reply is out of sight: the tab is hidden, the window is not +// focused, the chat page is not showing, or another conversation is open there. +// - Never for a reply the safety filter replaced, and never twice for one reply. +// - The sounds are the local files classic plays, at the volume chosen on a 1-10 scale. + +import { useBootstrapStore } from '../stores/bootstrapStore'; +import { useUserSettingsStore } from '../stores/userSettingsStore'; +import type { CompletedReply } from './replyEvents'; + +export const COMPLETION_AUDIO_SOUNDS = [ + { id: 'aurora', label: 'Aurora' }, + { id: 'bell', label: 'Bell' }, + { id: 'bloom', label: 'Bloom' }, + { id: 'chime', label: 'Chime' }, + { id: 'crystal', label: 'Crystal' }, + { id: 'glimmer', label: 'Glimmer' }, + { id: 'marimba', label: 'Marimba' }, + { id: 'pulse', label: 'Pulse' }, + { id: 'spark', label: 'Spark' }, + { id: 'summit', label: 'Summit' }, +] as const; + +export type CompletionAudioSoundId = (typeof COMPLETION_AUDIO_SOUNDS)[number]['id']; + +export const DEFAULT_COMPLETION_AUDIO_SOUND: CompletionAudioSoundId = 'aurora'; +export const DEFAULT_COMPLETION_AUDIO_VOLUME = 5; +export const COMPLETION_AUDIO_VOLUME_MIN = 1; +export const COMPLETION_AUDIO_VOLUME_MAX = 10; + +const SOUND_IDS = new Set(COMPLETION_AUDIO_SOUNDS.map((sound) => sound.id)); +const HANDLED_LIMIT = 200; + +export interface CompletionAudioPreferences { + enabled: boolean; + muted: boolean; + soundId: CompletionAudioSoundId; + volume: number; +} + +export function normalizeCompletionSound(value: unknown): CompletionAudioSoundId { + const id = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return SOUND_IDS.has(id) ? (id as CompletionAudioSoundId) : DEFAULT_COMPLETION_AUDIO_SOUND; +} + +export function normalizeCompletionVolume(value: unknown): number { + const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed)) { + return DEFAULT_COMPLETION_AUDIO_VOLUME; + } + return Math.min(COMPLETION_AUDIO_VOLUME_MAX, Math.max(COMPLETION_AUDIO_VOLUME_MIN, Math.round(parsed))); +} + +/** The stored preferences, read the way classic reads them. */ +export function readCompletionAudioPreferences(settings: Record): CompletionAudioPreferences { + return { + enabled: settings.chatCompletionAudioEnabled === true, + muted: settings.chatCompletionAudioMuted === true, + soundId: normalizeCompletionSound(settings.chatCompletionAudioSound), + volume: normalizeCompletionVolume(settings.chatCompletionAudioVolume), + }; +} + +export function completionSoundUrl(soundId: string): string { + return `/static/audio/completion-cues/${normalizeCompletionSound(soundId)}.wav`; +} + +let previewAudio: HTMLAudioElement | null = null; +let cueAudio: HTMLAudioElement | null = null; +const handledReplies = new Set(); + +async function playSound(soundId: string, volume: number): Promise { + const audio = new Audio(completionSoundUrl(soundId)); + audio.volume = normalizeCompletionVolume(volume) / COMPLETION_AUDIO_VOLUME_MAX; + try { + await audio.play(); + return audio; + } catch { + // The browser refused (no interaction yet) or the file failed: a cue is a courtesy, + // so it is dropped rather than retried later, out of context. + return null; + } +} + +/** Play a sound from the settings page, replacing any preview still playing. */ +export async function previewCompletionSound(soundId: string, volume: number): Promise { + previewAudio?.pause(); + previewAudio = await playSound(soundId, volume); + return previewAudio !== null; +} + +function adminAllowsCompletionAudio(): boolean { + return useBootstrapStore.getState().data?.features?.enable_chat_completion_audio_cues === true; +} + +function replyKey(reply: CompletedReply): string { + return `${reply.conversationId}:${reply.messageId ?? reply.runId ?? ''}`; +} + +export interface CompletionCueContext { + /** Whether the chat page is showing, in a visible and focused window. */ + chatPageWatched: () => boolean; + /** The conversation open on the chat page, if any. */ + activeConversationId: () => string | null; +} + +/** + * Decide whether a finished reply should sound, given the reader's preferences and what + * they are looking at. Exported so the rule can be tested without playing anything. + */ +export function shouldPlayCompletionCue( + reply: CompletedReply, + preferences: CompletionAudioPreferences, + adminEnabled: boolean, + watchingConversation: boolean, +): boolean { + if (!adminEnabled || !preferences.enabled || preferences.muted || reply.blocked) { + return false; + } + return !watchingConversation; +} + +/** Build the completed-reply listener that plays the cue. */ +export function createCompletionCueListener(context: CompletionCueContext): (reply: CompletedReply) => void { + return (reply) => { + const { settings, loading, error } = useUserSettingsStore.getState(); + if (loading || error) { + return; + } + const preferences = readCompletionAudioPreferences(settings); + const watching = context.chatPageWatched() && context.activeConversationId() === reply.conversationId; + if (!shouldPlayCompletionCue(reply, preferences, adminAllowsCompletionAudio(), watching)) { + return; + } + const key = replyKey(reply); + if (handledReplies.has(key)) { + return; + } + handledReplies.add(key); + if (handledReplies.size > HANDLED_LIMIT) { + const oldest = handledReplies.values().next().value; + if (oldest !== undefined) { + handledReplies.delete(oldest); + } + } + // One cue at a time: replies finishing together sound once, not as a pile-up. + if (cueAudio && !cueAudio.paused && !cueAudio.ended) { + return; + } + void playSound(preferences.soundId, preferences.volume).then((audio) => { + cueAudio = audio; + }); + }; +} diff --git a/application/v2_ui/src/lib/speechPlayback.ts b/application/v2_ui/src/lib/speechPlayback.ts new file mode 100644 index 000000000..74d86efb7 --- /dev/null +++ b/application/v2_ui/src/lib/speechPlayback.ts @@ -0,0 +1,182 @@ +// speechPlayback.ts +// One reader for spoken replies, shared by every "Read aloud" button and by auto-play. +// +// Only one message is read at a time: starting another stops the first, the way classic's +// chat-tts.js behaves. Keeping the player here rather than inside each button is what lets +// auto-play start a message and have that message's button show it playing and stop it. +// +// The voice and speed come from the user's preferences (ttsVoice, ttsSpeed), shared with the +// classic profile page. Auto-play (ttsAutoplay) reads a reply aloud when it finishes in the +// conversation the reader has open on the chat page. + +import { useSyncExternalStore } from 'react'; +import { useBootstrapStore } from '../stores/bootstrapStore'; +import { useUserSettingsStore } from '../stores/userSettingsStore'; +import type { CompletedReply } from './replyEvents'; +import { synthesizeSpeech } from './voice'; + +export type SpeechState = 'idle' | 'loading' | 'playing'; + +export const TTS_SPEED_MIN = 0.5; +export const TTS_SPEED_MAX = 2; +export const TTS_SPEED_STEP = 0.1; +export const TTS_SPEED_DEFAULT = 1; + +/** The stored speed, clamped to what the route accepts and rounded to the slider's step. */ +export function normalizeTtsSpeed(value: unknown): number { + const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? '')); + if (!Number.isFinite(parsed)) { + return TTS_SPEED_DEFAULT; + } + const clamped = Math.min(TTS_SPEED_MAX, Math.max(TTS_SPEED_MIN, parsed)); + return Math.round(clamped * 10) / 10; +} + +let currentKey: string | null = null; +let currentState: SpeechState = 'idle'; +let currentAudio: HTMLAudioElement | null = null; +let currentUrl: string | null = null; +/** Bumped on every start or stop, so a slow synthesis that lost the race is discarded. */ +let generation = 0; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of [...listeners]) { + listener(); + } +} + +function release(): void { + currentAudio?.pause(); + currentAudio = null; + if (currentUrl) { + URL.revokeObjectURL(currentUrl); + currentUrl = null; + } +} + +/** Stop whatever is being read. */ +export function stopSpeech(): void { + generation += 1; + release(); + if (currentKey !== null || currentState !== 'idle') { + currentKey = null; + currentState = 'idle'; + emit(); + } +} + +export interface SpeakOptions { + voice?: string; + speed?: number; +} + +/** + * Read `text` aloud under `key`, stopping anything already being read. Voice and speed + * default to the user's preferences. Resolves once playback starts; rejects on failure. + */ +export async function speak(key: string, text: string, options: SpeakOptions = {}): Promise { + stopSpeech(); + const ticket = generation; + currentKey = key; + currentState = 'loading'; + emit(); + + const { settings } = useUserSettingsStore.getState(); + const voice = options.voice ?? (String(settings.ttsVoice ?? '') || undefined); + const speed = options.speed ?? normalizeTtsSpeed(settings.ttsSpeed); + try { + const url = await synthesizeSpeech(text, voice, speed); + if (ticket !== generation) { + URL.revokeObjectURL(url); + return; + } + currentUrl = url; + const audio = new Audio(url); + currentAudio = audio; + const finish = () => { + if (ticket === generation) { + stopSpeech(); + } + }; + audio.onended = finish; + audio.onerror = finish; + await audio.play(); + if (ticket === generation) { + currentState = 'playing'; + emit(); + } + } catch (error) { + if (ticket === generation) { + stopSpeech(); + } + throw error; + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The playback state of one message (or the settings preview), for its control. */ +export function useSpeechState(key: string): SpeechState { + return useSyncExternalStore( + subscribe, + () => (currentKey === key ? currentState : 'idle'), + () => 'idle', + ); +} + +export interface SpeechAutoplayContext { + /** Whether the chat page is showing in a visible tab. */ + chatPageShowing: () => boolean; + /** The conversation open on the chat page, if any. */ + activeConversationId: () => string | null; + /** The plain text of a finished message, or null when it is not loaded. */ + messageText: (conversationId: string, messageId: string) => string | null; +} + +/** Whether a finished reply should be read aloud straight away. */ +export function shouldAutoplayReply( + reply: CompletedReply, + adminEnabled: boolean, + autoplayOn: boolean, + inOpenConversation: boolean, +): boolean { + return adminEnabled && autoplayOn && !reply.blocked && Boolean(reply.messageId) && inOpenConversation; +} + +/** Build the completed-reply listener that reads replies aloud when auto-play is on. */ +export function createSpeechAutoplayListener(context: SpeechAutoplayContext): (reply: CompletedReply) => void { + return (reply) => { + const adminEnabled = useBootstrapStore.getState().data?.features?.enable_text_to_speech === true; + const { settings, loading, error } = useUserSettingsStore.getState(); + if (loading || error) { + return; + } + const inOpenConversation = context.chatPageShowing() + && context.activeConversationId() === reply.conversationId; + if (!shouldAutoplayReply(reply, adminEnabled, settings.ttsAutoplay === true, inOpenConversation)) { + return; + } + const messageId = reply.messageId as string; + // The finished message reaches the store a moment after it is announced, so look + // for it briefly, as classic waits for the message's button to render. + let attempts = 0; + const tryRead = () => { + const text = context.messageText(reply.conversationId, messageId); + if (text && text.trim()) { + void speak(messageId, text).catch(() => undefined); + return; + } + attempts += 1; + if (attempts < 10) { + setTimeout(tryRead, 150); + } + }; + tryRead(); + }; +} diff --git a/application/v2_ui/src/lib/useNotificationRuntime.ts b/application/v2_ui/src/lib/useNotificationRuntime.ts index fc24dda7f..ff11c2792 100644 --- a/application/v2_ui/src/lib/useNotificationRuntime.ts +++ b/application/v2_ui/src/lib/useNotificationRuntime.ts @@ -5,14 +5,23 @@ // the sidebar and is not the only thing that depends on this. The desktop notifier has to // hear about replies on every page, a desktop notification clicked on any page has to be // able to open a conversation through the router, and the chat store has to know whether -// the chat page is on screen when a reply lands. +// the chat page is on screen when a reply lands. Completion sounds and spoken-reply auto-play +// listen for finished replies here too, for the same reason. import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { announceRouteChange, registerAppNavigator } from './appNavigation'; import { showReplyNotification } from './desktopNotifications'; import { subscribeCompletedReplies } from './replyEvents'; +import { createCompletionCueListener } from './completionAudio'; +import { createSpeechAutoplayListener } from './speechPlayback'; +import { messageToPlainText } from './messageText'; import { startNotificationPolling, stopNotificationPolling } from '../stores/notificationStore'; +import { useChatStore } from '../stores/chatStore'; + +function chatPageShowing(pathname: () => string): boolean { + return document.visibilityState === 'visible' && pathname() === '/chat'; +} /** * `ready` is whether the signed-in session has loaded. Nothing starts before it, because the @@ -56,8 +65,27 @@ export function useNotificationRuntime(ready: boolean): void { } startNotificationPolling(); const stopListening = subscribeCompletedReplies(showReplyNotification); + const activeConversationId = () => useChatStore.getState().activeConversationId; + const stopCues = subscribeCompletedReplies(createCompletionCueListener({ + chatPageWatched: () => chatPageShowing(() => pathnameRef.current) && document.hasFocus(), + activeConversationId, + })); + const stopAutoplay = subscribeCompletedReplies(createSpeechAutoplayListener({ + chatPageShowing: () => chatPageShowing(() => pathnameRef.current), + activeConversationId, + messageText: (conversationId, messageId) => { + const state = useChatStore.getState(); + if (state.activeConversationId !== conversationId) { + return null; + } + const message = state.messages.find((item) => item.id === messageId); + return message ? messageToPlainText(message) : null; + }, + })); return () => { stopListening(); + stopCues(); + stopAutoplay(); stopNotificationPolling(); }; }, [ready]); diff --git a/application/v2_ui/src/lib/voice.ts b/application/v2_ui/src/lib/voice.ts index d6f125136..59ae9d0a6 100644 --- a/application/v2_ui/src/lib/voice.ts +++ b/application/v2_ui/src/lib/voice.ts @@ -113,12 +113,20 @@ export async function transcribeAudio(wav: Blob): Promise { * The endpoint returns an audio stream rather than JSON, so the response is turned into * an object URL for playback. */ -export async function synthesizeSpeech(text: string, voice?: string): Promise { +export async function synthesizeSpeech(text: string, voice?: string, speed?: number): Promise { + const body: Record = { text }; + if (voice) { + body.voice = voice; + } + // The route clamps to 0.5-2.0; normal speed is left out so the request stays as it was. + if (typeof speed === 'number' && Number.isFinite(speed) && Math.abs(speed - 1) > 0.001) { + body.speed = speed; + } const response = await fetch(apiUrl('/api/chat/tts'), { method: 'POST', credentials: CREDENTIALS_MODE, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(voice ? { text, voice } : { text }), + body: JSON.stringify(body), }); if (!response.ok) { diff --git a/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md index 3fe9b1d25..4abaac57e 100644 --- a/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md +++ b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md @@ -1,15 +1,18 @@ # V2 User Settings Redesign -**Implemented in version: 0.261.277** +**Implemented in version: 0.261.277** (layout); **0.261.278** (voice, audio and retention) ## Overview V2 User Settings now uses the same design language as V2 Admin Settings, so personal settings are laid out like the admin ones: a collapsible section rail, full-width settings -cards, and an **On this page** index. This is the first stage of bringing V2 User -Settings to parity with the classic Profile page. Later stages add the classic preferences -that V2 does not yet honour: completion audio, voice playback, microphone permission, -retention, fact memory, Microsoft 365 sharing, tutorials, and Latest Features. +cards, and an **On this page** index. It is being brought to parity with the classic +Profile page in stages: + +1. Layout (0.261.277). +2. Completion sounds, spoken replies (voice, speed and auto-play), microphone permission, + and personal retention (0.261.278). +3. Later: fact memory, Microsoft 365 sharing, tutorials, and Latest Features. Dependencies: the V2 React interface (`application/v2_ui`) and the user settings API (`/api/user/settings`). @@ -39,7 +42,7 @@ Dependencies: the V2 React interface (`application/v2_ui`) and the user settings | Tab | Cards | | --- | --- | -| Preferences | Grouped: Appearance (Text size, Conversation list), Chat (Conversation navigation, Spoken replies), Notifications and alerts (Desktop notifications, Workflow alerts on this device), Diagrams and charts (Diagrams, Charts) | +| Preferences | Grouped: Appearance (Text size, Conversation list), Chat (Conversation navigation), Voice and audio (Completion sounds, Spoken replies, Microphone), Notifications and alerts (Desktop notifications, Workflow alerts on this device), Data and privacy (Retention), Diagrams and charts (Diagrams, Charts) | | Stats | Lifetime totals, four activity charts, Storage used, Account | | Groups / Public workspaces | Your groups / Your public workspaces | | Feedback | Summary, Your feedback (with Export CSV) | @@ -47,6 +50,24 @@ Dependencies: the V2 React interface (`application/v2_ui`) and the user settings Cards for capabilities that are turned off are not shown. +### Voice, audio and retention + +Each card appears only when its capability is on. + +| Card | Capability | Settings | Behaviour | +| --- | --- | --- | --- | +| Completion sounds | `enable_chat_completion_audio_cues` | `chatCompletionAudioEnabled`, `chatCompletionAudioMuted`, `chatCompletionAudioSound`, `chatCompletionAudioVolume` | Plays the chosen cue from `static/audio/completion-cues/` when a reply finishes and you are not watching that conversation: another conversation, another page, or the window is unfocused. The cue is skipped for blocked replies and while another cue is playing. **Preview** plays the sound at the chosen volume. | +| Spoken replies | `enable_text_to_speech` | `ttsVoice`, `ttsSpeed`, `ttsAutoplay`, `ttsEnabled` | Voices come from `GET /api/chat/tts/voices`, grouped by language. A saved voice the deployment no longer lists stays selectable. Speed (0.5–2.0) is sent with every speech request. With auto-play on, a finished reply in the open conversation is read aloud. A single reader (`lib/speechPlayback.ts`) serves message playback, the sample, and auto-play, so starting one stops the other. | +| Microphone | `enable_speech_to_text_input` | None (browser permission) | Shows this browser's microphone permission from the Permissions API without prompting, and follows changes. **Allow microphone** asks for it once. Revoking it is done in the browser's site settings. | +| Retention | `enable_retention_policy_personal` | `retention_policy` (written by the route) | Organization default (with its label from `/api/retention-policy/defaults/personal`), no automatic deletion, or 1 to 730 days, for conversations and documents separately. Saved with an explicit **Save** through `POST /api/retention-policy/user`, because a shorter period deletes older items at the next run. | + +Differences from the classic page: + +- Completion sounds listen for replies finished in this browser tab. The classic page also + polls `/api/notifications/chat-completions` for replies finished in other tabs. +- Auto-play reads a reply once it has finished. The classic page turns streaming off while + auto-play is on; V2 keeps streaming. + ### Violations visibility The Violations tab is always listed, as on the classic Profile page. When neither @@ -73,7 +94,11 @@ collapse or expand it, and the **On this page** index to move between cards. card usage on every tab, preference groups, and Violations visibility. - `functional_tests/test_v2_settings_and_workspace_tags.py`: tab gating, updated for the always-listed Violations tab. -- Related fix: [Feedback My Routes Filter Unpack Fix](../fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md). +- `functional_tests/test_v2_user_settings_audio_voice_retention.py`: card gating, writable + keys, the cue catalogue, speed reaching the speech call, the reply listeners, no + microphone prompt on load, and the retention route accepting `'default'`. +- Related fixes: [Feedback My Routes Filter Unpack Fix](../fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md) + and [Personal Retention Default Value Fix](../fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md). ### Known limitations diff --git a/docs/explanation/fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md b/docs/explanation/fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md new file mode 100644 index 000000000..e8c0e1d8f --- /dev/null +++ b/docs/explanation/fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md @@ -0,0 +1,43 @@ +# Personal Retention Default Value Fix + +**Fixed in version: 0.261.278** + +## Issue + +On the Profile page, choosing **Using organization default** for personal conversation +or document retention and saving returned HTTP 400 with +`Invalid conversation retention value`, or the document equivalent. Once a user had chosen +their own period, they could not switch back to following the organization default. + +## Root cause + +`POST /api/retention-policy/user` in `route_backend_retention_policy.py` accepted only +`'none'` or a whole number of days. The select sends `'default'`, which reached `int()` and +was rejected. The group and public workspace routes already accepted `'default'`, and the +retention run already handles it: `resolve_retention_value` maps it to the organization +default. + +## Technical details + +### Files modified + +- `application/single_app/route_backend_retention_policy.py`: + `update_user_retention_settings` stores `'default'` for either field, before the integer + check, matching the group route. + +### Testing + +- `functional_tests/test_v2_user_settings_audio_voice_retention.py` + (`test_personal_retention_route_accepts_default`) checks that both fields handle + `'default'` before converting the value to a number. + +### Impact + +This fixes the classic Profile page and the new V2 **Retention** card, since both save +through this route. + +## Validation + +- Before: saving **Organization default** failed with a 400, and the previous period stayed + in force. +- After: the choice is saved, and the next retention run uses the organization's default. diff --git a/docs/guides/update-profile-preferences.md b/docs/guides/update-profile-preferences.md index e43ae344f..83e90c080 100644 --- a/docs/guides/update-profile-preferences.md +++ b/docs/guides/update-profile-preferences.md @@ -56,8 +56,27 @@ at its top. The rail stays collapsed until you expand it again, on any device. Each tab is laid out as cards. On wide screens an **On this page** index on the right lists them, so you can jump straight to a card. Preferences are grouped under -**Appearance**, **Chat**, **Notifications and alerts**, and **Diagrams and charts**, and -changes save as soon as you make them. +**Appearance**, **Chat**, **Voice and audio**, **Notifications and alerts**, **Data and +privacy**, and **Diagrams and charts**. Most changes save as soon as you make them. + +Under **Voice and audio**, which cards appear depends on what your administrator has +turned on: + +- **Completion sounds** plays a short sound when a reply finishes while you are looking at + something else, such as another conversation, another page, or another window. Pick a + sound and volume, and use **Preview** to hear it. **Mute for now** silences it without losing + your choice. +- **Spoken replies** sets the voice and speed used when a reply is read aloud, with a + sample you can play. Turn on **Read replies aloud automatically** to hear each reply in + the open conversation as soon as it finishes. +- **Microphone** shows whether this browser lets the site use your microphone for voice + input, and lets you allow it. + +Under **Data and privacy**, **Retention** sets how long your own conversations and +documents are kept. You can follow your organization's default, keep them indefinitely, or +pick a period. Select **Save** to apply it: items older than the period are deleted at the +next retention run, and deleted conversations are archived first if your organization has +archiving turned on. **Violations** is always listed. If content safety is off for your application, the tab says so instead of showing an empty list. diff --git a/functional_tests/test_v2_settings_and_workspace_tags.py b/functional_tests/test_v2_settings_and_workspace_tags.py index 5741dc9cc..e96212765 100644 --- a/functional_tests/test_v2_settings_and_workspace_tags.py +++ b/functional_tests/test_v2_settings_and_workspace_tags.py @@ -225,8 +225,10 @@ def test_only_settings_this_interface_honours_are_offered(): f"The {size!r} text scale has no rule, so choosing it would do nothing" ) + # Message playback goes through the shared reader, which reads the chosen voice and speed. actions = _read(V2_SRC / "components" / "chat" / "MessageActions.tsx") - assert "settings.ttsVoice" in actions, ( + playback = _read(V2_SRC / "lib" / "speechPlayback.ts") + assert "speechPlayback" in actions and "settings.ttsVoice" in playback, ( "The chosen voice must reach the speech call, or the picker does nothing" ) diff --git a/functional_tests/test_v2_user_settings_audio_voice_retention.py b/functional_tests/test_v2_user_settings_audio_voice_retention.py new file mode 100644 index 000000000..ffe650c6e --- /dev/null +++ b/functional_tests/test_v2_user_settings_audio_voice_retention.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# test_v2_user_settings_audio_voice_retention.py +""" +Functional test for V2 User Settings voice, audio and retention parity. +Version: 0.261.278 +Implemented in: 0.261.278 + +V2 Preferences now carries the classic profile page's completion sounds, spoken reply voice, +speed and auto-play, microphone permission status, and personal retention period, each with +the runtime behaviour behind it. The personal retention route also accepts 'default', which +the "Organization default" choice sends. This file pins those contracts. +""" + +import ast +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.versioning import assert_app_version_at_least + + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_DIR = REPO_ROOT / "application" / "single_app" +V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src" +SETTINGS_DIR = V2_SRC / "components" / "settings" +PREFERENCES_TSX = SETTINGS_DIR / "PreferencesTab.tsx" +VOICE_CARDS_TSX = SETTINGS_DIR / "VoiceAudioCards.tsx" +RETENTION_CARD_TSX = SETTINGS_DIR / "RetentionCard.tsx" +COMPLETION_AUDIO_TS = V2_SRC / "lib" / "completionAudio.ts" +SPEECH_PLAYBACK_TS = V2_SRC / "lib" / "speechPlayback.ts" +VOICE_TS = V2_SRC / "lib" / "voice.ts" +RUNTIME_TS = V2_SRC / "lib" / "useNotificationRuntime.ts" +MESSAGE_ACTIONS_TSX = V2_SRC / "components" / "chat" / "MessageActions.tsx" +USER_SETTINGS_TS = V2_SRC / "lib" / "userSettings.ts" +USERS_ROUTE = APP_DIR / "route_backend_users.py" +RETENTION_ROUTE = APP_DIR / "route_backend_retention_policy.py" +CUE_DIR = APP_DIR / "static" / "audio" / "completion-cues" + + +def _read(path): + assert path.is_file(), f"Missing expected file: {path}" + return path.read_text(encoding="utf-8") + + +def test_version_is_at_least_the_implementing_release(): + print("Testing the application version...") + assert_app_version_at_least("0.261.278") + return True + + +def test_cards_are_gated_by_their_capabilities(): + print("Testing the voice, audio and retention cards are gated...") + preferences = _read(PREFERENCES_TSX) + for flag, card in ( + ("enable_chat_completion_audio_cues", " Date: Wed, 7 Oct 2026 09:46:47 -0400 Subject: [PATCH 3/6] V2 user settings: fact memory workbench and Microsoft 365 cards Adds a fact memory workbench and Microsoft 365 sharing, chat connection, workflow connection and workflow authorization cards to V2 Preferences, grouped under Memory and data and Connected accounts. Version 0.261.279. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../components/settings/FactMemoryBench.tsx | 438 +++++++++ .../src/components/settings/M365Cards.tsx | 875 ++++++++++++++++++ .../components/settings/PreferencesTab.tsx | 29 +- application/v2_ui/src/lib/m365Connect.ts | 3 +- .../features/V2_USER_SETTINGS_REDESIGN.md | 52 +- docs/guides/update-profile-preferences.md | 19 +- ..._v2_user_settings_audio_voice_retention.py | 2 +- .../test_v2_user_settings_memory_m365.py | 214 +++++ 9 files changed, 1615 insertions(+), 19 deletions(-) create mode 100644 application/v2_ui/src/components/settings/FactMemoryBench.tsx create mode 100644 application/v2_ui/src/components/settings/M365Cards.tsx create mode 100644 functional_tests/test_v2_user_settings_memory_m365.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 7378b2436..0b2eb9674 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.278" +VERSION = "0.261.279" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/settings/FactMemoryBench.tsx b/application/v2_ui/src/components/settings/FactMemoryBench.tsx new file mode 100644 index 000000000..892e42d5a --- /dev/null +++ b/application/v2_ui/src/components/settings/FactMemoryBench.tsx @@ -0,0 +1,438 @@ +// FactMemoryBench.tsx +// The signed-in user's saved memories: instructions applied to every reply, and facts recalled +// when they are relevant. +// +// Mirrors the classic profile page's Fact Memory section and its manager dialog, and saves +// through the same routes (/api/profile/fact-memory). The classic page split adding and +// managing across a card and a modal; here they share one bench, a searchable list beside an +// editor, in the style of the workspace benches. +// +// Shown whether or not an administrator turned fact memory on, as the classic page is: saved +// memories stay manageable, they just aren't used while the capability is off. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { Brain, Loader2, Plus, Search, Trash2 } from 'lucide-react'; +import { api, ApiError } from '../../lib/apiClient'; +import { toast } from '../../stores/toastStore'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { SettingsCard } from './SettingsCard'; + +export type MemoryType = 'instruction' | 'fact'; + +export interface FactMemoryItem { + id: string; + value: string; + memory_type: MemoryType; + created_at?: string; + updated_at?: string; +} + +interface FactMemoryPayload { + enabled?: boolean; + facts?: FactMemoryItem[]; +} + +type TypeFilter = 'all' | MemoryType; + +export const MEMORY_TYPE_LABELS: Record = { + instruction: 'Instruction: always apply to future responses', + fact: 'Fact: recall only when relevant', +}; + +const SHORT_TYPE_LABELS: Record = { + instruction: 'Instruction', + fact: 'Fact', +}; + +const FIELD_CLASS = + 'w-full rounded-lg border border-edge bg-surface-solid px-2.5 py-2 text-sm text-text-1'; + +function normalizeType(value: unknown): MemoryType { + return value === 'instruction' ? 'instruction' : 'fact'; +} + +function errorText(error: unknown, fallback: string): string { + return error instanceof ApiError || error instanceof Error ? error.message : fallback; +} + +function formatDate(value?: string): string { + if (!value) return 'Unknown'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); +} + +/** Newest change first, the order the classic manager listed them in. */ +function byUpdated(left: FactMemoryItem, right: FactMemoryItem): number { + return (right.updated_at || right.created_at || '').localeCompare(left.updated_at || left.created_at || ''); +} + +function TypeSelect({ + id, + value, + onChange, + disabled, +}: { + id: string; + value: MemoryType; + onChange: (next: MemoryType) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function FactMemoryBench() { + const [enabled, setEnabled] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [newValue, setNewValue] = useState(''); + const [newType, setNewType] = useState('fact'); + const [adding, setAdding] = useState(false); + + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [selectedId, setSelectedId] = useState(null); + const [draftValue, setDraftValue] = useState(''); + const [draftType, setDraftType] = useState('fact'); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + try { + const payload = await api.get('/api/profile/fact-memory', signal); + setEnabled(payload.enabled === true); + setItems((payload.facts ?? []).map((item) => ({ ...item, memory_type: normalizeType(item.memory_type) }))); + setLoadError(null); + } catch (error) { + if (signal?.aborted) return; + setLoadError(errorText(error, 'Your memories could not be loaded.')); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + const sorted = useMemo(() => [...items].sort(byUpdated), [items]); + const instructionCount = items.filter((item) => item.memory_type === 'instruction').length; + const factCount = items.length - instructionCount; + const lastUpdated = sorted[0]?.updated_at || sorted[0]?.created_at; + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return sorted.filter((item) => ( + (typeFilter === 'all' || item.memory_type === typeFilter) + && (!needle || item.value.toLowerCase().includes(needle)) + )); + }, [sorted, query, typeFilter]); + + const selected = items.find((item) => item.id === selectedId) ?? null; + + useEffect(() => { + if (selected) { + setDraftValue(selected.value); + setDraftType(selected.memory_type); + } + // Only reset the draft when a different memory is opened, not on every list change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const replaceItem = (next: FactMemoryItem) => { + const normalized = { ...next, memory_type: normalizeType(next.memory_type) }; + setItems((current) => { + const index = current.findIndex((item) => item.id === normalized.id); + if (index === -1) return [normalized, ...current]; + const copy = [...current]; + copy[index] = normalized; + return copy; + }); + }; + + const add = async () => { + const value = newValue.trim(); + if (!value) return; + setAdding(true); + try { + const response = await api.post<{ fact?: FactMemoryItem }>('/api/profile/fact-memory', { + value, + memory_type: newType, + }); + if (response.fact) { + replaceItem(response.fact); + setSelectedId(response.fact.id); + setDraftValue(response.fact.value); + setDraftType(normalizeType(response.fact.memory_type)); + } else { + await load(); + } + setNewValue(''); + toast.success('Memory saved.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be saved.')); + } finally { + setAdding(false); + } + }; + + const save = async () => { + if (!selected) return; + const value = draftValue.trim(); + if (!value) return; + setSaving(true); + try { + const response = await api.put<{ fact?: FactMemoryItem }>( + `/api/profile/fact-memory/${encodeURIComponent(selected.id)}`, + { value, memory_type: draftType }, + ); + if (response.fact) replaceItem(response.fact); + toast.success('Memory updated.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be updated.')); + } finally { + setSaving(false); + } + }; + + const remove = async () => { + if (!selected) return; + setDeleting(true); + try { + await api.delete(`/api/profile/fact-memory/${encodeURIComponent(selected.id)}`); + setItems((current) => current.filter((item) => item.id !== selected.id)); + setSelectedId(null); + setConfirmDelete(false); + toast.success('Memory deleted.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be deleted.')); + } finally { + setDeleting(false); + } + }; + + const dirty = selected !== null + && (draftValue.trim() !== selected.value || draftType !== selected.memory_type); + + const badge = enabled === null ? null : ( + + {enabled ? 'Enabled by admin' : 'Disabled by admin'} + + ); + + return ( + + {enabled === false && ( +

+ Fact memory is turned off for your organization. You can still review and tidy your saved + memories, but they are not used in replies until an administrator turns it back on. +

+ )} + + {loadError ? ( +

+ {loadError} +

+ ) : ( + <> +

+ {loading + ? 'Loading your memories…' + : `${items.length} saved · ${instructionCount} ${instructionCount === 1 ? 'instruction' : 'instructions'}, ${factCount} ${factCount === 1 ? 'fact' : 'facts'}${lastUpdated ? ` · last updated ${formatDate(lastUpdated)}` : ''}`} +

+ +
{ + event.preventDefault(); + void add(); + }} + > +