diff --git a/application/single_app/config.py b/application/single_app/config.py
index 3f693e9b4..85f9d9a22 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.280"
+VERSION = "0.261.281"
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_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/single_app/route_backend_users.py b/application/single_app/route_backend_users.py
index 00a3657c6..327106c59 100644
--- a/application/single_app/route_backend_users.py
+++ b/application/single_app/route_backend_users.py
@@ -47,6 +47,9 @@
MEMBERSHIP_STATUS_PENDING,
}
+# Guided tours the new interface ships. Must match the ids in v2_ui/src/lib/tours.ts.
+TUTORIAL_IDS = frozenset({"chat", "workspace"})
+
# /api/userSearch waits at most this long for Graph, the bound the SimpleChat
# operations' Graph calls use.
USER_SEARCH_GRAPH_TIMEOUT_SECONDS = 20
@@ -560,6 +563,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',
# Whether the V2 Control Center section rail is collapsed to icons.
'v2ControlCenterRailCollapsed',
# V2 document explorer: how the workspace documents list is presented
@@ -578,7 +583,7 @@ def user_settings():
'chatCompletionAudioEnabled', 'chatCompletionAudioMuted',
'chatCompletionAudioSound', 'chatCompletionAudioVolume',
# Tutorial visibility settings
- 'showTutorialButtons',
+ 'showTutorialButtons', 'tutorialVisibility',
# Desktop conversation notification settings
'desktopNotificationsEnabled',
'recentCollaborators',
@@ -688,6 +693,22 @@ def user_settings():
return jsonify({"error": "Completion audio volume must be between 1 and 10"}), 400
settings_to_update["chatCompletionAudioVolume"] = volume
+ if "showTutorialButtons" in settings_to_update:
+ if not isinstance(settings_to_update["showTutorialButtons"], bool):
+ return jsonify({"error": "Invalid tutorial button preference"}), 400
+
+ # Per-tutorial visibility from the new interface, { tutorialId: bool }. Only
+ # the tutorials the application ships are accepted, so the map cannot grow
+ # without bound or carry arbitrary keys into the settings document.
+ if "tutorialVisibility" in settings_to_update:
+ tutorial_visibility = settings_to_update["tutorialVisibility"]
+ if not isinstance(tutorial_visibility, dict):
+ return jsonify({"error": "Invalid tutorial visibility"}), 400
+ for tutorial_id, visible in tutorial_visibility.items():
+ if tutorial_id not in TUTORIAL_IDS or not isinstance(visible, bool):
+ return jsonify({"error": "Invalid tutorial visibility"}), 400
+ settings_to_update["tutorialVisibility"] = dict(tutorial_visibility)
+
if "desktopNotificationsEnabled" in settings_to_update:
if not isinstance(settings_to_update["desktopNotificationsEnabled"], bool):
return jsonify({"error": "Invalid desktop notification preference"}), 400
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index b6468ae01..0963e1418 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -200,7 +200,8 @@
)
from functions_documents import get_audio_runtime_capabilities
from functions_cosmos_throughput import get_cosmos_resource_config
-from config import VERSION
+from config import IS_DEVELOPMENT, VERSION
+from support_menu_config import has_visible_support_latest_features
from swagger_wrapper import are_swagger_routes_registered, get_auth_security, swagger_route
logger = logging.getLogger(__name__)
@@ -400,7 +401,7 @@ def _build_navigation(raw_settings, user_roles):
# Re-checked on the way out, not just on the way in. The V2 settings PATCH
# is the only write path that applies the scheme rule, so a link stored
# through the server-rendered admin form, or already in the document, could
- # otherwise put a javascript: URL into every user's navigation.
+ # otherwise put a script-scheme URL into every user's navigation.
if not is_safe_external_link_url(url):
log_event(
"[V2_BOOTSTRAP] Dropped an external link with an unsupported URL "
@@ -427,6 +428,41 @@ def _build_navigation(raw_settings, user_roles):
"force_menu": bool(raw_settings.get("external_links_force_menu")),
"items": external_links,
},
+ "latest_features": _build_latest_features_nav(raw_settings, user_roles),
+ }
+
+
+def _build_latest_features_nav(raw_settings, user_roles):
+ """Describe the Support menu's Latest Features shortcut for the SPA rail.
+
+ Mirrors the gate in ``_sidebar_nav.html``: a real application role, the Support menu
+ and its Latest Features page switched on, and at least one feature left visible.
+ Whether this user hid the shortcut is not decided here; the browser compares the
+ user's ``latestFeaturesHiddenVersion`` with the bootstrap ``version`` so hiding and
+ unhiding take effect without a reload.
+ """
+ may_see_support = any(role in ("Admin", "User") for role in user_roles or [])
+ available = False
+ if (
+ may_see_support
+ and raw_settings.get("enable_support_menu")
+ and raw_settings.get("enable_support_latest_features", True)
+ ):
+ try:
+ available = bool(has_visible_support_latest_features(raw_settings))
+ except Exception as exc:
+ log_event(
+ f"[V2_BOOTSTRAP] Could not resolve latest features visibility: {exc}",
+ level=logging.WARNING,
+ exceptionTraceback=True,
+ )
+ available = False
+
+ return {
+ "available": available,
+ "hidden_by_development": bool(IS_DEVELOPMENT),
+ "url": "/support/latest-features",
+ "menu_name": _menu_name(raw_settings.get("support_menu_name"), "Support"),
}
diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx
index f60bc420b..afebdae02 100644
--- a/application/v2_ui/src/components/chat/Composer.tsx
+++ b/application/v2_ui/src/components/chat/Composer.tsx
@@ -1555,7 +1555,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
)}
-
+
{savedAnalysis && (
@@ -1713,7 +1713,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
)}
-
+
{orchestrationAvailable && (
fileInputRef.current?.click()}
+ data-tour="composer-attach"
disabled={
!canPost ||
!gating.showFileUpload ||
@@ -2000,6 +2001,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
type="button"
onClick={handleStop}
aria-label="Stop generating"
+ data-tour="composer-send"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-danger-soft text-danger transition-colors hover:bg-danger hover:text-white"
>
@@ -2008,6 +2010,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
submit()}
+ data-tour="composer-send"
disabled={(!text.trim() && !attachedPrompt && !hasMentionChips) || !canPost || uploadsBlocked || workflowResultOpening || (orchestrating && orchestrationBlocked)}
aria-label={
shared && !streaming
diff --git a/application/v2_ui/src/components/chat/ConversationRail.tsx b/application/v2_ui/src/components/chat/ConversationRail.tsx
index afc2ec29b..2eebce05b 100644
--- a/application/v2_ui/src/components/chat/ConversationRail.tsx
+++ b/application/v2_ui/src/components/chat/ConversationRail.tsx
@@ -659,6 +659,7 @@ export function ConversationRail({
return (
('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/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx
index c7af94bf4..a622933ce 100644
--- a/application/v2_ui/src/components/chat/MessageList.tsx
+++ b/application/v2_ui/src/components/chat/MessageList.tsx
@@ -64,6 +64,7 @@ import { ImageLightbox } from './ImageLightbox';
import { ImageEditor } from './ImageEditor';
import { ImageReferenceThumbnail } from './ImageReferenceThumbnail';
import { ImageProposalScope } from './ImageProposalContext';
+import { normalizeFoundryAuthUrl } from '../../lib/foundryAuth';
import {
answerGeneratedImages,
extractProposalSpecs,
@@ -1492,6 +1493,7 @@ export function MessageList() {
streamAuthUrl,
activeConversationId,
} = useChatStore();
+ const safeStreamAuthUrl = normalizeFoundryAuthUrl(streamAuthUrl);
const appTitle = useBootstrapStore((state) => state.data?.branding?.app_title);
const chatWidth = useUiStore((state) => state.chatWidth);
@@ -1619,6 +1621,7 @@ export function MessageList() {
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto px-4 py-6"
+ data-tour="message-list"
>
{messagesLoading && (
@@ -1674,9 +1677,9 @@ export function MessageList() {
{streamError}
- {streamAuthUrl ? (
+ {safeStreamAuthUrl ? (
<>
-
Sign in or grant Foundry access
diff --git a/application/v2_ui/src/components/layout/LatestFeaturesLink.tsx b/application/v2_ui/src/components/layout/LatestFeaturesLink.tsx
new file mode 100644
index 000000000..c66ff97bb
--- /dev/null
+++ b/application/v2_ui/src/components/layout/LatestFeaturesLink.tsx
@@ -0,0 +1,78 @@
+// LatestFeaturesLink.tsx
+// The Support menu's Latest Features shortcut, in the rail.
+//
+// The classic navigation has carried this link for a while; V2 had nothing, so a user of
+// the new interface never heard about a release. The destination is the server-rendered
+// Support page, so this is a plain anchor rather than a router link.
+//
+// Hiding saves the running version, exactly as the classic control does, so the link stays
+// away until the next release and both interfaces agree. Preferences can bring it back.
+
+import { EyeOff, Sparkles } from 'lucide-react';
+import { clsx } from 'clsx';
+import { useBootstrapStore } from '../../stores/bootstrapStore';
+import { useUserSettingsStore } from '../../stores/userSettingsStore';
+import { resolveLatestFeaturesNav } from '../../lib/latestFeaturesNav';
+
+export function LatestFeaturesLink({ collapsed }: { collapsed: boolean }) {
+ const nav = useBootstrapStore((state) => state.data?.navigation?.latest_features);
+ const version = useBootstrapStore((state) => state.data?.version ?? '');
+ const hiddenVersion = useUserSettingsStore(
+ (state) => state.settings.latestFeaturesHiddenVersion,
+ );
+ const settingsLoading = useUserSettingsStore((state) => state.loading);
+ const update = useUserSettingsStore((state) => state.update);
+
+ const state = resolveLatestFeaturesNav(nav, hiddenVersion, version);
+ // Waiting for preferences avoids flashing a link the user already hid.
+ if (!nav || !state.showInRail || settingsLoading) {
+ return null;
+ }
+
+ const link = (
+
+
+ {!collapsed && (
+ <>
+ Latest Features
+
+ New
+
+ >
+ )}
+
+ );
+
+ if (collapsed) {
+ return
{link}
;
+ }
+
+ return (
+
+
+ {nav.menu_name}
+
+
+ {link}
+ update({ latestFeaturesHiddenVersion: version })}
+ title="Hide until the next release"
+ aria-label="Hide Latest Features until the next release"
+ className="shrink-0 rounded-lg p-1.5 text-text-3 opacity-0 transition-opacity group-hover/latest:opacity-100 hover:bg-surface-2 hover:text-text-1 focus-visible:opacity-100"
+ >
+
+
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx
index 13980426f..dfb146fa6 100644
--- a/application/v2_ui/src/components/layout/Sidebar.tsx
+++ b/application/v2_ui/src/components/layout/Sidebar.tsx
@@ -45,6 +45,7 @@ import { classicChatHref } from '../../lib/conversationUrl';
import { DEFAULT_PUBLIC_WORKSPACE_LABELS, usePublicWorkspaceLabels } from '../../lib/publicWorkspaceLabels';
import { ConversationRail } from '../chat/ConversationRail';
import { NavExtras } from './NavExtras';
+import { LatestFeaturesLink } from './LatestFeaturesLink';
import { NotificationBell } from './NotificationBell';
import { UserAvatar } from './UserAvatar';
import { useWorkflowAlertCalloutShown, WorkflowAlertRowSlot } from '../notifications/WorkflowAlertNotice';
@@ -279,6 +280,7 @@ function UserMenu({ collapsed }: { collapsed: boolean }) {
onClick={() => setOpen((isOpen) => !isOpen)}
aria-expanded={open}
aria-controls={panelId}
+ data-tour="user-menu"
// Collapsed, the avatar is the only content and carries no text of its own
// once a profile photo replaces the initials, so the button needs a name.
aria-label={collapsed ? 'Account' : undefined}
@@ -456,6 +458,7 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) {
if (mobile) setMobileNavOpen(false);
}}
title="Start a new chat"
+ data-tour="new-chat"
className={clsx(
'flex w-full items-center gap-2 rounded-xl bg-accent px-3 py-2.5',
'text-sm font-medium text-on-accent transition-colors hover:bg-accent-hover',
@@ -512,6 +515,9 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) {
nothing when neither is enabled, which is the default. */}
+ {/* The Support menu's Latest Features shortcut, until the user hides it. */}
+
+
{/* The conversation list only belongs in the rail while the chat page is open,
so other pages get the full rail height for their own navigation. */}
{showConversations &&
}
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 (
+ onChange(normalizeType(event.target.value))}
+ className={FIELD_CLASS}
+ >
+ {MEMORY_TYPE_LABELS.instruction}
+ {MEMORY_TYPE_LABELS.fact}
+
+ );
+}
+
+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)}` : ''}`}
+
+
+
+
+
+
+
+
+ Search memories
+
+ setQuery(event.target.value)}
+ placeholder="Search memories"
+ className={clsx(FIELD_CLASS, 'pl-8')}
+ />
+
+
+ Filter by type
+ setTypeFilter(event.target.value as TypeFilter)}
+ className={FIELD_CLASS}
+ >
+ All types
+ Instructions
+ Facts
+
+
+
+
+ {!loading && visible.length === 0 && (
+
+ {items.length === 0 ? 'No memories saved yet.' : 'No memories match.'}
+
+ )}
+ {visible.map((item) => (
+
+ setSelectedId(item.id)}
+ aria-current={item.id === selectedId ? 'true' : undefined}
+ className={clsx(
+ 'w-full rounded-lg px-3 py-2 text-left',
+ item.id === selectedId ? 'bg-surface-2' : 'hover:bg-surface-2/60',
+ )}
+ >
+ {item.value}
+
+ {SHORT_TYPE_LABELS[item.memory_type]} · {formatDate(item.updated_at || item.created_at)}
+
+
+
+ ))}
+
+
+
+
+ {selected ? (
+
{
+ event.preventDefault();
+ void save();
+ }}
+ >
+
+ Memory
+ setDraftValue(event.target.value)}
+ rows={5}
+ className={clsx(FIELD_CLASS, 'mt-1.5 resize-y')}
+ />
+
+
+
+ Created {formatDate(selected.created_at)} · updated {formatDate(selected.updated_at)}
+
+
+ setConfirmDelete(true)}
+ className="inline-flex items-center gap-1.5 rounded-lg border border-danger/40 px-3 py-1.5 text-sm text-danger hover:bg-danger-soft"
+ >
+
+ Delete
+
+
+ {saving ? 'Saving…' : 'Save changes'}
+
+
+
+ ) : (
+
+ Select a memory to edit its wording or type, or to delete it.
+
+ )}
+
+
+ >
+ )}
+
+ {confirmDelete && selected && (
+ }
+ busy={deleting}
+ onConfirm={() => void remove()}
+ onClose={() => setConfirmDelete(false)}
+ >
+
+ The assistant stops using it in future replies. This cannot be undone.
+
+
+ {selected.value}
+
+
+ )}
+
+ );
+}
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 && (
-
-
-
-
-
-
- )}
-
-
-
setType(event.target.value)}
- className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
- >
- Any rating
- Positive
- Negative
- Neutral
-
-
setAck(event.target.value)}
- className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
- >
- Reviewed or not
- Reviewed
- Awaiting review
-
-
- Export CSV
-
-
+
+
+ {stats ? (
+
+
+
+
+
+
+ ) : loading ? (
+
+ ) : (
+ The summary could not be loaded.
+ )}
+
- {error ? (
-
- {error}
-
- ) : loading ? (
-
-
-
+
+ Export CSV
+
+ }
+ bodyClassName="space-y-3"
+ >
+
+ setType(event.target.value)}
+ className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
+ >
+ Any rating
+ Positive
+ Negative
+ Neutral
+
+ setAck(event.target.value)}
+ className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
+ >
+ Reviewed or not
+ Reviewed
+ Awaiting review
+
- ) : 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
-
-
-
void load(page - 1)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Previous
-
-
= lastPage || loading}
- onClick={() => void load(page + 1)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Next
-
+ {totalCount > PAGE_SIZE && (
+
+
+ Page {page} of {lastPage} · {totalCount} submissions
+
+
+ void load(page - 1)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Previous
+
+ = lastPage || loading}
+ onClick={() => void load(page + 1)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Next
+
+
-
- )}
+ )}
+
);
}
diff --git a/application/v2_ui/src/components/settings/GuidanceCards.tsx b/application/v2_ui/src/components/settings/GuidanceCards.tsx
new file mode 100644
index 000000000..cd15972a2
--- /dev/null
+++ b/application/v2_ui/src/components/settings/GuidanceCards.tsx
@@ -0,0 +1,148 @@
+// GuidanceCards.tsx
+// Preferences for the help this interface offers: the Latest Features shortcut and the
+// guided tours.
+//
+// Both mirror classic profile controls and share their settings, so a choice made in one
+// interface holds in the other. The tours add a per-tour choice the classic page does not
+// have; its master switch, `showTutorialButtons`, still turns every tour off at once.
+
+import { useNavigate } from 'react-router-dom';
+import { CircleHelp, Play, Sparkles } from 'lucide-react';
+import { clsx } from 'clsx';
+import { useBootstrapStore } from '../../stores/bootstrapStore';
+import type { UserSettings } from '../../lib/userSettings';
+import {
+ describeLatestFeaturesNav,
+ resolveLatestFeaturesNav,
+ type LatestFeaturesStatus,
+} from '../../lib/latestFeaturesNav';
+import { isTourEnabled, requestTour, TOURS, withTourVisibility } from '../../lib/tours';
+import { Toggle } from '../ui/primitives';
+import { SettingsCard } from './SettingsCard';
+
+const STATUS_BADGE: Record = {
+ visible: { label: 'Visible', tone: 'bg-ok-soft text-ok' },
+ hidden: { label: 'Hidden', tone: 'bg-surface-2 text-text-2' },
+ development: { label: 'Hidden in development', tone: 'bg-warn-soft text-text-1' },
+ unavailable: { label: 'Not offered', tone: 'bg-surface-2 text-text-3' },
+};
+
+export function LatestFeaturesCard({
+ settings,
+ update,
+}: {
+ settings: UserSettings;
+ update: (partial: UserSettings) => void;
+}) {
+ const nav = useBootstrapStore((state) => state.data?.navigation?.latest_features);
+ const version = useBootstrapStore((state) => state.data?.version ?? '');
+ const state = resolveLatestFeaturesNav(nav, settings.latestFeaturesHiddenVersion, version);
+ const badge = STATUS_BADGE[state.status];
+
+ return (
+
+
+
+ {badge.label}
+
+
+ {describeLatestFeaturesNav(state, version)}
+
+ {state.status === 'visible' && (
+
update({ latestFeaturesHiddenVersion: version })}
+ className="rounded-lg border border-edge px-3 py-1.5 text-xs font-medium text-text-2 hover:bg-surface-2 hover:text-text-1"
+ >
+ Hide for this version
+
+ )}
+ {state.status === 'hidden' && (
+
update({ latestFeaturesHiddenVersion: null })}
+ className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-on-accent hover:bg-accent-hover"
+ >
+ Show again
+
+ )}
+
+ {nav?.available && state.status !== 'development' && (
+
+ Open Latest Features
+
+ )}
+
+ );
+}
+
+export function TutorialsCard({
+ settings,
+ update,
+}: {
+ settings: UserSettings;
+ update: (partial: UserSettings) => void;
+}) {
+ const navigate = useNavigate();
+ const allOn = settings.showTutorialButtons !== false;
+
+ return (
+
+ update({ showTutorialButtons: next })}
+ label="Show tour buttons"
+ description="Turns every tour button off at once. Shared with the classic interface's tutorial buttons."
+ />
+
+
+ {TOURS.map((tour) => {
+ const tourOn = isTourEnabled(tour.id, true, settings.tutorialVisibility);
+ return (
+
+
+
+ update({
+ tutorialVisibility: withTourVisibility(
+ settings.tutorialVisibility,
+ tour.id,
+ next,
+ ),
+ })
+ }
+ label={tour.title}
+ description={tour.description}
+ />
+
+ {
+ requestTour(tour.id);
+ navigate(tour.path);
+ }}
+ className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-3 py-1.5 text-xs font-medium text-text-2 hover:bg-surface-2 hover:text-text-1"
+ >
+
+ Start now
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/settings/M365Cards.tsx b/application/v2_ui/src/components/settings/M365Cards.tsx
new file mode 100644
index 000000000..90a63bfe4
--- /dev/null
+++ b/application/v2_ui/src/components/settings/M365Cards.tsx
@@ -0,0 +1,875 @@
+// M365Cards.tsx
+// Microsoft 365 sharing preferences, chat and workflow connections, and workflow
+// authorizations for the signed-in user.
+//
+// Mirrors the classic profile page's "Microsoft 365 sharing and workflows" section
+// (static/js/profile/profile-m365.js) against the same /api/m365 routes. Every write needs
+// the X-M365-CSRF-Token header; the token arrives on the GET responses and is refreshed once
+// when the server reports it stale, as the classic page does.
+//
+// Every revocation and disconnect is confirmed first. None of them retract history that was
+// already published to a conversation, and the dialogs say so.
+
+import { useCallback, useEffect, useState, type ReactNode } from 'react';
+import { clsx } from 'clsx';
+import {
+ Cable,
+ ExternalLink,
+ KeyRound,
+ Loader2,
+ MessageSquare,
+ RefreshCw,
+ Share2,
+ Unplug,
+ Workflow,
+} from 'lucide-react';
+import { ApiError, request } from '../../lib/apiClient';
+import {
+ normalizeAuthorizationUrl,
+ connectMicrosoft365,
+ m365Sources,
+ M365_APPROVALS_HREF,
+ type M365Source,
+} from '../../lib/m365Connect';
+import { toast } from '../../stores/toastStore';
+import { ConfirmDialog } from '../ui/ConfirmDialog';
+import { SettingsCard } from './SettingsCard';
+
+export const M365_SOURCES: readonly M365Source[] = ['calendar', 'email', 'onedrive', 'spo'];
+
+export const M365_SOURCE_LABELS: Record = {
+ calendar: 'Calendar',
+ email: 'Email',
+ onedrive: 'OneDrive',
+ spo: 'SharePoint Online (SPO)',
+};
+
+const SHORT_SOURCE_LABELS: Record = {
+ calendar: 'Calendar',
+ email: 'Email',
+ onedrive: 'OneDrive',
+ spo: 'SPO',
+};
+
+export type SharingChoice = 'ask' | 'request' | 'today' | 'always';
+export type AnalysisChoice = 'ask' | 'always' | 'fast';
+type AnalysisSource = 'onedrive' | 'spo';
+
+export const SHARING_LABELS: Record = {
+ ask: 'Ask before sharing',
+ request: 'Prefer this request only',
+ today: 'Prefer today',
+ always: 'Prefer always, until revoked',
+};
+
+export const ANALYSIS_LABELS: Record = {
+ ask: 'Ask before deeper analysis',
+ always: 'Always allow deeper analysis within service limits',
+ fast: 'Use a faster answer with coverage limitations',
+};
+
+const ANALYSIS_SOURCES: readonly AnalysisSource[] = ['onedrive', 'spo'];
+
+const DURATION_LABELS: Record = {
+ no: 'No',
+ request: 'Allow this request',
+ today: 'Allow for today',
+ always: 'Always allow',
+};
+
+const ANALYSIS_DECISION_LABELS: Record = {
+ request: 'Analyze more for this request',
+ always: 'Always allow deeper analysis',
+ fast: 'Use a faster answer',
+};
+
+const CHAT_STATUS_TEXT: Record = {
+ available: 'Sign-in saved for this session',
+ not_connected: 'No Microsoft 365 sign-in is saved for this session',
+ reconnect_required: 'Reconnect Microsoft 365 before using these sources in chat',
+};
+
+const SOURCE_PERMISSIONS_HELP =
+ 'Each source includes the permissions for all of its supported operations. Calendar includes reading events, creating invitations, mailbox timezone and recipient lookup. Email includes reading messages, managing drafts and read state, sending mail and recipient lookup. OneDrive and SPO include file discovery and reading. Microsoft shows the permissions before you consent.';
+
+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';
+const PRIMARY_BUTTON =
+ 'inline-flex items-center gap-1.5 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';
+const SECONDARY_BUTTON =
+ 'inline-flex items-center gap-1.5 rounded-lg border border-edge px-3 py-1.5 text-sm text-text-1 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50';
+const DANGER_BUTTON =
+ 'inline-flex items-center gap-1.5 rounded-lg border border-danger/40 px-3 py-1.5 text-sm text-danger hover:bg-danger-soft disabled:cursor-not-allowed disabled:opacity-50';
+
+// ── Requests ────────────────────────────────────────────────────────────────
+
+let csrfToken: string | null = null;
+let csrfRefresh: Promise | null = null;
+
+function rememberCsrf(payload: unknown) {
+ const token = (payload as { csrf_token?: unknown } | null)?.csrf_token;
+ if (typeof token === 'string' && token.length >= 32) {
+ csrfToken = token;
+ }
+}
+
+/**
+ * A Microsoft 365 API call. Reads keep the CSRF token they return; writes send it, and are
+ * retried once with a fresh token when the server reports the old one stale.
+ */
+export async function m365Request(
+ path: string,
+ options: { method?: string; body?: unknown; signal?: AbortSignal } = {},
+ retried = false,
+): Promise {
+ const method = options.method ?? 'GET';
+ const write = method !== 'GET';
+ if (write && !csrfToken) {
+ await m365Request('/api/m365/preferences');
+ }
+ try {
+ const result = await request(path, {
+ method,
+ body: options.body,
+ signal: options.signal,
+ headers: write && csrfToken ? { 'X-M365-CSRF-Token': csrfToken } : {},
+ });
+ rememberCsrf(result);
+ return result;
+ } catch (error) {
+ const code = error instanceof ApiError
+ ? (error.payload as { error?: unknown } | null)?.error
+ : undefined;
+ if (write && !retried && error instanceof ApiError && error.status === 403 && code === 'm365_csrf_invalid') {
+ csrfRefresh = csrfRefresh ?? m365Request('/api/m365/preferences').finally(() => {
+ csrfRefresh = null;
+ });
+ await csrfRefresh;
+ return m365Request(path, options, true);
+ }
+ throw error;
+ }
+}
+
+function errorText(error: unknown, fallback: string): string {
+ return error instanceof ApiError || error instanceof Error ? error.message || fallback : fallback;
+}
+
+function spaced(value: string): string {
+ return value.replaceAll('_', ' ');
+}
+
+export interface M365Binding {
+ id: string;
+ status?: string;
+ execution_status?: string;
+ self_authored?: boolean;
+ decisions?: Record;
+ analysis_choice?: string;
+ context?: { workflow_id?: string };
+}
+
+/** The classic Approvals wording for an authorization's state. */
+export function describeBinding(binding: M365Binding): string {
+ if (binding.self_authored === true && binding.status === 'approved') {
+ return 'Decision: approved automatically because you saved this workflow revision yourself.';
+ }
+ const status = typeof binding.status === 'string' ? binding.status : 'unknown';
+ const execution = typeof binding.execution_status === 'string' ? binding.execution_status : 'not reported';
+ const summary = `Decision: ${spaced(status)}. Execution: ${spaced(execution)}.`;
+ const outcomes = Object.entries(binding.decisions ?? {}).map(([source, decision]) => {
+ const label = M365_SOURCE_LABELS[source as M365Source] ?? source;
+ const duration = decision?.duration ?? '';
+ return `${label}: ${DURATION_LABELS[duration] ?? duration}`;
+ });
+ if (outcomes.length) {
+ return `${summary} Recorded source choices: ${outcomes.join('; ')}.`;
+ }
+ if (binding.analysis_choice && ANALYSIS_DECISION_LABELS[binding.analysis_choice]) {
+ return `${summary} Recorded analysis choice: ${ANALYSIS_DECISION_LABELS[binding.analysis_choice]}.`;
+ }
+ return summary;
+}
+
+// ── Shared pieces ───────────────────────────────────────────────────────────
+
+interface PendingRevocation {
+ title: string;
+ description: string;
+ confirmLabel: string;
+ task: () => Promise;
+ done: string;
+}
+
+function StatusNote({ tone = 'info', children }: { tone?: 'info' | 'ok' | 'warn' | 'danger'; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SourceCheckboxes({
+ legend,
+ value,
+ onChange,
+ disabled,
+ idPrefix,
+}: {
+ legend: string;
+ value: M365Source[];
+ onChange: (next: M365Source[]) => void;
+ disabled?: boolean;
+ idPrefix: string;
+}) {
+ return (
+
+ {legend}
+
+ {M365_SOURCES.map((source) => (
+
+ onChange(
+ event.target.checked
+ ? m365Sources([...value, source])
+ : value.filter((item) => item !== source),
+ )}
+ />
+ {SHORT_SOURCE_LABELS[source]}
+
+ ))}
+
+ {SOURCE_PERMISSIONS_HELP}
+
+ );
+}
+
+// ── Sharing preferences ─────────────────────────────────────────────────────
+
+interface Preferences {
+ sources: Record;
+ extended_analysis: Record;
+}
+
+/** Validates the server's preferences; an unknown value is refused rather than guessed. */
+export function readM365Preferences(value: unknown): Preferences {
+ const raw = (value ?? {}) as { sources?: Record; extended_analysis?: Record };
+ const sources = {} as Record;
+ for (const source of M365_SOURCES) {
+ const choice = raw.sources?.[source];
+ if (typeof choice !== 'string' || !(choice in SHARING_LABELS)) {
+ throw new Error('The server returned an unsupported sharing preference. No changes have been made.');
+ }
+ sources[source] = choice as SharingChoice;
+ }
+ const analysis = {} as Record;
+ for (const source of ANALYSIS_SOURCES) {
+ const choice = raw.extended_analysis?.[source];
+ if (typeof choice !== 'string' || !(choice in ANALYSIS_LABELS)) {
+ throw new Error('The server returned an unsupported analysis preference. No changes have been made.');
+ }
+ analysis[source] = choice as AnalysisChoice;
+ }
+ return { sources, extended_analysis: analysis };
+}
+
+function samePreferences(left: Preferences | null, right: Preferences | null): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+function SharingCard({
+ refreshKey,
+ onRevoke,
+}: {
+ refreshKey: number;
+ onRevoke: (pending: PendingRevocation) => void;
+}) {
+ const [stored, setStored] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ useEffect(() => {
+ const controller = new AbortController();
+ m365Request<{ preferences?: unknown }>('/api/m365/preferences', { signal: controller.signal })
+ .then((response) => {
+ const preferences = readM365Preferences(response.preferences);
+ setStored(preferences);
+ setDraft(preferences);
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!controller.signal.aborted) setLoadError(errorText(error, 'Microsoft 365 preferences could not be loaded.'));
+ });
+ return () => controller.abort();
+ }, [refreshKey]);
+
+ const save = async () => {
+ if (!draft) return;
+ setSaving(true);
+ try {
+ const response = await m365Request<{ preferences?: unknown }>('/api/m365/preferences', {
+ method: 'PATCH',
+ body: draft,
+ });
+ const preferences = readM365Preferences(response.preferences);
+ setStored(preferences);
+ setDraft(preferences);
+ toast.success('Microsoft 365 preferences saved. Previously published history is unchanged.');
+ } catch (error) {
+ toast.error(errorText(error, 'Microsoft 365 preferences could not be saved.'));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ void save()}
+ disabled={!draft || saving || samePreferences(stored, draft)}
+ className={PRIMARY_BUTTON}
+ >
+ {saving ? 'Saving…' : 'Save'}
+
+ )}
+ >
+ {loadError ? (
+ {loadError}
+ ) : !draft ? (
+ Loading Microsoft 365 preferences…
+ ) : (
+
+
+ {M365_SOURCES.map((source) => (
+
+
+ {M365_SOURCE_LABELS[source]}
+ setDraft({
+ ...draft,
+ sources: { ...draft.sources, [source]: event.target.value as SharingChoice },
+ })}
+ className={SELECT_CLASS}
+ >
+ {(Object.keys(SHARING_LABELS) as SharingChoice[]).map((choice) => (
+ {SHARING_LABELS[choice]}
+ ))}
+
+
+ {(source === 'onedrive' || source === 'spo') && (
+
+ Extended analysis
+ setDraft({
+ ...draft,
+ extended_analysis: {
+ ...draft.extended_analysis,
+ [source]: event.target.value as AnalysisChoice,
+ },
+ })}
+ className={SELECT_CLASS}
+ >
+ {(Object.keys(ANALYSIS_LABELS) as AnalysisChoice[]).map((choice) => (
+ {ANALYSIS_LABELS[choice]}
+ ))}
+
+
+ )}
+
+ onRevoke({
+ title: `Revoke ${M365_SOURCE_LABELS[source]} sharing approvals?`,
+ description: `Existing ${M365_SOURCE_LABELS[source]} sharing approvals end, and you are asked again before anything else is published.`,
+ confirmLabel: 'Revoke',
+ task: () => m365Request(`/api/m365/sources/${encodeURIComponent(source)}/revoke`, {
+ method: 'POST',
+ body: {},
+ }),
+ done: `${M365_SOURCE_LABELS[source]} sharing approvals revoked.`,
+ })}
+ >
+ Revoke approvals
+
+
+
+ ))}
+
+
+ Browser timezone
+ {timezone || 'Unavailable; confirm it when approving sharing.'}
+
+
+ You confirm your timezone with each sharing approval; it is not stored here. "Today" ends at the
+ next local midnight in that timezone. Saving a preferred duration does not itself approve
+ publication: you still acknowledge each share, an action can require a shorter duration, and
+ changing a source's preference ends its earlier approvals. Private chats need no sharing
+ acknowledgement.
+
+
+ )}
+
+ );
+}
+
+// ── Chat connection ─────────────────────────────────────────────────────────
+
+interface ChatConnection {
+ status?: string;
+ sources?: unknown;
+}
+
+function ChatConnectionCard({ refreshKey }: { refreshKey: number }) {
+ const [connection, setConnection] = useState<{ status: string; sources: M365Source[] } | null>(null);
+ const [selected, setSelected] = useState([]);
+ const [loadError, setLoadError] = useState(null);
+ const [connecting, setConnecting] = useState(false);
+ const [reload, setReload] = useState(0);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ m365Request<{ connection?: ChatConnection }>('/api/m365/chat/connection', { signal: controller.signal })
+ .then((response) => {
+ const status = response.connection?.status;
+ const rawSources = response.connection?.sources;
+ const sources = m365Sources(rawSources);
+ if (
+ typeof status !== 'string'
+ || !(status in CHAT_STATUS_TEXT)
+ || !Array.isArray(rawSources)
+ || sources.length !== rawSources.length
+ ) {
+ throw new Error('The chat sign-in status could not be verified. Refresh before trying again.');
+ }
+ setConnection({ status, sources });
+ setSelected(sources);
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!controller.signal.aborted) setLoadError(errorText(error, 'The chat sign-in status could not be loaded.'));
+ });
+ return () => controller.abort();
+ }, [refreshKey, reload]);
+
+ const connect = async () => {
+ if (!selected.length) {
+ toast.error('Select at least one source to reconnect for chat.');
+ return;
+ }
+ setConnecting(true);
+ try {
+ await connectMicrosoft365(selected);
+ toast.success('Microsoft 365 sign-in completed. Retry your original question in the conversation.');
+ setReload((value) => value + 1);
+ } catch (error) {
+ toast.error(errorText(error, 'Microsoft 365 sign-in could not start.'));
+ } finally {
+ setConnecting(false);
+ }
+ };
+
+ return (
+
+
+ {loadError ? (
+ {loadError}
+ ) : !connection ? (
+ Loading chat sign-in status…
+ ) : (
+
+ {CHAT_STATUS_TEXT[connection.status]}. Sources saved for this session:{' '}
+ {connection.sources.map((source) => M365_SOURCE_LABELS[source]).join(', ') || 'none'}. This is
+ the saved sign-in state, not a live test; access is checked when a source runs.
+
+ )}
+
+ void connect()}
+ disabled={!connection || connecting}
+ className={PRIMARY_BUTTON}
+ >
+ {connecting ? : }
+ Reconnect Microsoft 365 for chat
+
+
+
+ );
+}
+
+// ── Workflow connection and authorizations ──────────────────────────────────
+
+interface WorkflowConnection {
+ id?: string;
+ status?: string;
+ account_username?: string;
+ tenant_id?: string;
+ cloud?: string;
+ sources?: unknown;
+ authorized_scopes?: unknown;
+}
+
+function WorkflowConnectionCard({
+ refreshKey,
+ onRevoke,
+}: {
+ refreshKey: number;
+ onRevoke: (pending: PendingRevocation) => void;
+}) {
+ const [connection, setConnection] = useState(undefined);
+ const [selected, setSelected] = useState([]);
+ const [loadError, setLoadError] = useState(null);
+ const [connecting, setConnecting] = useState(false);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ m365Request<{ connection?: WorkflowConnection | null }>('/api/m365/connections', { signal: controller.signal })
+ .then((response) => {
+ if (!Object.prototype.hasOwnProperty.call(response, 'connection')) {
+ throw new Error('The workflow connection status could not be verified.');
+ }
+ setConnection(response.connection ?? null);
+ setSelected(m365Sources(response.connection?.sources));
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!controller.signal.aborted) setLoadError(errorText(error, 'The workflow connection could not be loaded.'));
+ });
+ return () => controller.abort();
+ }, [refreshKey]);
+
+ const connect = async () => {
+ if (!selected.length) {
+ toast.error('Select at least one source to connect for workflows.');
+ return;
+ }
+ setConnecting(true);
+ try {
+ const result = await m365Request<{ authorization_url?: unknown }>('/api/m365/connections/connect', {
+ method: 'POST',
+ body: { sources: selected },
+ });
+ // The sign-in returns to the classic Profile page, which confirms the saved connection.
+ window.location.assign(normalizeAuthorizationUrl(result.authorization_url));
+ } catch (error) {
+ toast.error(errorText(error, 'The workflow connection could not start.'));
+ setConnecting(false);
+ }
+ };
+
+ const status = connection?.status || 'disconnected';
+ const scopes = Array.isArray(connection?.authorized_scopes)
+ ? connection.authorized_scopes.filter((scope): scope is string => typeof scope === 'string')
+ : [];
+ const details: [string, string][] = [
+ ['Account', connection?.account_username || 'Not connected'],
+ ['Tenant', connection?.tenant_id || 'Not connected'],
+ ['Cloud', connection?.cloud || 'Deployment configuration'],
+ ['Authorized sources', m365Sources(connection?.sources).map((source) => M365_SOURCE_LABELS[source]).join(', ') || 'None'],
+ ['Delegated permissions', scopes.join(', ') || 'None'],
+ ];
+
+ return (
+
+
+ {loadError ? (
+
{loadError}
+ ) : connection === undefined ? (
+
Loading workflow connection…
+ ) : (
+ <>
+
+ Workflow connection: {spaced(status)}.
+
+
+ {details.map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+ >
+ )}
+
+
+ void connect()}
+ disabled={connection === undefined || connecting}
+ className={PRIMARY_BUTTON}
+ >
+ {connecting ? : }
+ {connection?.id ? 'Reconnect for workflows' : 'Connect for workflows'}
+
+ {
+ const connectionId = connection?.id;
+ if (!connectionId) return;
+ onRevoke({
+ title: 'Disconnect the workflow account?',
+ description: 'Workflows can no longer run as this account, and its workflow authorizations stop working. You stay signed in to other applications.',
+ confirmLabel: 'Disconnect',
+ task: () => m365Request('/api/m365/connections/disconnect', {
+ method: 'POST',
+ body: { connection_id: connectionId },
+ }),
+ done: 'Workflow account disconnected.',
+ });
+ }}
+ >
+
+ Disconnect
+
+
+
+ A saved workflow sign-in needs the deployment's Key Vault-backed credential protection. Without
+ it, connecting fails; there is no plaintext or application-identity fallback.
+
+
+
+ );
+}
+
+function WorkflowAuthorizationsCard({
+ refreshKey,
+ onRevoke,
+}: {
+ refreshKey: number;
+ onRevoke: (pending: PendingRevocation) => void;
+}) {
+ const [items, setItems] = useState(null);
+ const [continuation, setContinuation] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [loadingMore, setLoadingMore] = useState(false);
+
+ const loadPage = useCallback(async (token: string | null, signal?: AbortSignal) => {
+ const query = new URLSearchParams({ page_size: '20' });
+ if (token) query.set('continuation_token', token);
+ const response = await m365Request<{ items?: unknown; continuation_token?: string | null }>(
+ `/api/m365/bindings?${query}`,
+ { signal },
+ );
+ if (!Array.isArray(response.items)) {
+ throw new Error('Workflow authorizations could not be read.');
+ }
+ return { items: response.items as M365Binding[], next: response.continuation_token || null };
+ }, []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ loadPage(null, controller.signal)
+ .then((page) => {
+ setItems(page.items);
+ setContinuation(page.next);
+ setLoadError(null);
+ })
+ .catch((error) => {
+ if (!controller.signal.aborted) setLoadError(errorText(error, 'Workflow authorizations could not be loaded.'));
+ });
+ return () => controller.abort();
+ }, [refreshKey, loadPage]);
+
+ const loadMore = async () => {
+ if (!continuation) return;
+ setLoadingMore(true);
+ try {
+ const page = await loadPage(continuation);
+ setItems((current) => [...(current ?? []), ...page.items]);
+ setContinuation(page.next);
+ } catch (error) {
+ toast.error(errorText(error, 'More authorizations could not be loaded.'));
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ return (
+
+ Open Approvals
+
+
+ )}
+ >
+ {loadError ? (
+ {loadError}
+ ) : items === null ? (
+ Loading workflow authorizations…
+ ) : items.length === 0 ? (
+ No workflow authorizations.
+ ) : (
+
+ {items.map((binding) => (
+
+
+
+ Workflow {binding.context?.workflow_id || 'authorization'}
+
+
{describeBinding(binding)}
+
+ {(binding.status === 'pending' || binding.status === 'approved') && (
+ onRevoke({
+ title: 'Revoke this workflow authorization?',
+ description: "This workflow revision can no longer use your Microsoft 365 account.",
+ confirmLabel: 'Revoke',
+ task: () => m365Request(`/api/m365/bindings/${encodeURIComponent(binding.id)}/revoke`, {
+ method: 'POST',
+ body: {},
+ }),
+ done: 'Workflow authorization revoked.',
+ })}
+ >
+ Revoke
+
+ )}
+
+ ))}
+
+ )}
+ {continuation && (
+ void loadMore()}
+ disabled={loadingMore}
+ className={clsx(SECONDARY_BUTTON, 'mt-3')}
+ >
+ {loadingMore ? : null}
+ Load more authorizations
+
+ )}
+
+ );
+}
+
+// ── Section ─────────────────────────────────────────────────────────────────
+
+/**
+ * Every Microsoft 365 card, sharing one confirmation dialog and one refresh, so a
+ * revocation in one card refreshes the state shown in the others.
+ */
+export function M365Cards() {
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [pending, setPending] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [revokeError, setRevokeError] = useState(null);
+
+ const confirm = async () => {
+ if (!pending || busy) return;
+ setBusy(true);
+ setRevokeError(null);
+ try {
+ await pending.task();
+ toast.success(pending.done);
+ setPending(null);
+ setRefreshKey((value) => value + 1);
+ } catch (error) {
+ setRevokeError(errorText(error, 'The change could not be made. No permission has been changed.'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const openRevocation = (next: PendingRevocation) => {
+ setRevokeError(null);
+ setPending(next);
+ };
+
+ return (
+ <>
+
+ setRefreshKey((value) => value + 1)}
+ className={SECONDARY_BUTTON}
+ >
+
+ Refresh Microsoft 365 status
+
+
+
+
+
+
+
+ {pending && (
+ void confirm()}
+ onClose={() => {
+ if (!busy) setPending(null);
+ }}
+ >
+ {pending.description}
+
+ Answers and evidence already published to conversations are not removed.
+
+ {revokeError && (
+
+ {revokeError}
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/settings/PreferencesTab.tsx b/application/v2_ui/src/components/settings/PreferencesTab.tsx
index 8947262c1..8f1b94745 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, Voice and audio, Notifications and alerts,
+// Help and guidance, Memory and data, Connected accounts, 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,
+ Workflow,
+} from 'lucide-react';
import { useUserSettingsStore } from '../../stores/userSettingsStore';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import {
@@ -25,7 +38,12 @@ import {
type FontSizePreference,
} from '../../lib/userSettings';
import { Toggle, Skeleton } from '../ui/primitives';
-import { SettingsSection } from './TabScaffold';
+import { SettingsCard, SettingsGroup } from './SettingsCard';
+import { CompletionAudioCard, MicrophoneCard, SpokenRepliesCard } from './VoiceAudioCards';
+import { RetentionCard } from './RetentionCard';
+import { FactMemoryBench } from './FactMemoryBench';
+import { M365Cards } from './M365Cards';
+import { LatestFeaturesCard, TutorialsCard } from './GuidanceCards';
import { VISUAL_STYLE_SETTING_KEYS } from '../../lib/blockVisualStyle';
import {
DEFAULT_VISUAL_STYLE,
@@ -44,17 +62,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,
@@ -270,139 +277,199 @@ export function PreferencesTab() {
}
return (
-
+
{saveError && (
{saveError}
)}
-
- update({ fontSizePreference: next })}
- />
-
+
+ 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."
+ />
+
+
+
+ {enabled('enable_conversation_contents_drawer') && (
+
+ {enabled('enable_conversation_contents_drawer') && (
+
+
+ update({ conversationContentsDrawerEnabled: next })
+ }
+ label="Show the 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') && }
+
+ )}
-
- 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."
- />
-
+ {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 && (
+
+ )}
+
+ )}
-
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
- update({ [VISUAL_STYLE_SETTING_KEYS.mermaid]: next })
- }
- />
-
+
+ {enabled('enable_retention_policy_personal') &&
}
+
-
-
- update({ [VISUAL_STYLE_SETTING_KEYS.simplechart]: next })
- }
- />
-
+
+
- {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') && (
-
-
- Voice
- update({ ttsVoice: event.target.value })}
- className="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"
- >
- {TTS_VOICES.map((voice) => (
-
- {voice.label}
-
- ))}
-
-
-
- )}
-
- {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/RetentionCard.tsx b/application/v2_ui/src/components/settings/RetentionCard.tsx
new file mode 100644
index 000000000..4bce9fc9b
--- /dev/null
+++ b/application/v2_ui/src/components/settings/RetentionCard.tsx
@@ -0,0 +1,182 @@
+// RetentionCard.tsx
+// How long the signed-in user's own conversations and documents are kept.
+//
+// Mirrors the classic profile page's Retention Policy section and saves through the same
+// route (POST /api/retention-policy/user), so the stored `retention_policy` is the one the
+// nightly retention run reads. Shown only when an administrator turned on personal retention.
+//
+// Unlike the other preferences this one is saved with a button: choosing a shorter period
+// means items older than it are deleted at the next run, so it should be a deliberate step.
+
+import { useEffect, useMemo, useState } from 'react';
+import { Hourglass } from 'lucide-react';
+import { api, ApiError } from '../../lib/apiClient';
+import type { UserSettings } from '../../lib/userSettings';
+import { useUserSettingsStore } from '../../stores/userSettingsStore';
+import { toast } from '../../stores/toastStore';
+import { SettingsCard } from './SettingsCard';
+
+/** The classic profile page's choices, in its order. */
+export const PERSONAL_RETENTION_DAYS = [1, 2, 3, 4, 5, 6, 7, 10, 14, 21, 30, 60, 90, 180, 365, 730] as const;
+
+const DAY_LABELS: Record
= {
+ 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,
+ ) => (
+
+ {label}
+ onChange(event.target.value)} className={SELECT_CLASS}>
+
+ {defaultLabel ? `Organization default (${defaultLabel})` : 'Organization default'}
+
+ {choices(value).map((choice) => (
+
+ {choice.label}
+
+ ))}
+
+ {help}
+
+ );
+
+ 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/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.
+
+
+ );
+ }
-
-
setStatus(event.target.value)}
- className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
- >
- Any status
- {STATUSES.map((value) => (
-
- {value}
-
- ))}
-
-
setAction(event.target.value)}
- className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
- >
- Any action
- {ACTIONS.map((value) => (
-
- {value}
-
- ))}
-
-
- Export CSV
-
-
+ return (
+
+
+ {stats ? (
+
+
+
+
+
+
+ ) : loading ? (
+
+ ) : (
+ The summary could not be loaded.
+ )}
+
- {error ? (
-
- {error}
-
- ) : loading ? (
-
-
-
+
+ Export CSV
+
+ }
+ bodyClassName="space-y-3"
+ >
+
+ setStatus(event.target.value)}
+ className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
+ >
+ Any status
+ {STATUSES.map((value) => (
+
+ {value}
+
+ ))}
+
+ setAction(event.target.value)}
+ className="rounded-lg border border-edge bg-surface-solid px-2.5 py-1.5 text-sm text-text-1"
+ >
+ Any action
+ {ACTIONS.map((value) => (
+
+ {value}
+
+ ))}
+
- ) : 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
-
-
-
void load(page - 1)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Previous
-
-
= lastPage || loading}
- onClick={() => void load(page + 1)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Next
-
+ {totalCount > PAGE_SIZE && (
+
+
+ Page {page} of {lastPage} · {totalCount} records
+
+
+ void load(page - 1)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Previous
+
+ = lastPage || loading}
+ onClick={() => void load(page + 1)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Next
+
+
-
- )}
+ )}
+
);
}
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."
+ />
+
+
+
+
+
+ Sound
+
+
update({ chatCompletionAudioSound: event.target.value })}
+ className={SELECT_CLASS}
+ >
+ {COMPLETION_AUDIO_SOUNDS.map((sound) => (
+
+ {sound.label}
+
+ ))}
+
+
void preview()} className={`${BUTTON_CLASS} mt-1.5`}>
+
+ Preview
+
+
+
+
+
+
+ Volume {preferences.volume} of {COMPLETION_AUDIO_VOLUME_MAX}
+
+ update({ chatCompletionAudioVolume: Number(event.target.value) })}
+ className="mt-3 w-full max-w-xs accent-[var(--accent)]"
+ />
+
+
+ {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 (
+
+
+
+ Voice
+ update({ ttsVoice: event.target.value })}
+ className={SELECT_CLASS}
+ >
+
+ {defaultVoice ? `Default (${defaultVoice})` : 'Default voice'}
+
+ {storedMissing && {voice} }
+ {groupVoices(voices).map(([language, items]) => (
+
+ {items.map((item) => (
+
+ {voiceLabel(item)}
+
+ ))}
+
+ ))}
+
+ {voicesWarning && {voicesWarning} }
+
+
+
+
+ Speed {speed.toFixed(1)}x
+
+ update({ ttsSpeed: normalizeTtsSpeed(event.target.value) })}
+ className="mt-3 w-full max-w-xs accent-[var(--accent)]"
+ />
+
+ Slower
+ Faster
+
+
+
+
+
+
+ {previewState === 'loading' ? (
+
+ ) : previewState === 'playing' ? (
+
+ ) : (
+
+ )}
+ {previewState === 'playing' ? 'Stop sample' : 'Play a sample'}
+
+
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') && (
+ void request()} className={BUTTON_CLASS}>
+
+ Allow microphone
+
+ )}
+
+ {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/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 (
-
-
-
- onSearchChange(event.target.value)}
- placeholder={`Search ${kind.pluralNoun}`}
- className="w-full rounded-xl border border-edge bg-surface-solid py-2 pr-3 pl-9 text-sm text-text-1 outline-none focus:border-accent"
- />
-
-
- {actionError && (
-
- {actionError}
-
- )}
- {needsReconciliation ? (
-
void reconcile()}
- className="rounded-lg border border-edge px-3 py-2 text-sm text-text-1 hover:bg-surface-2 disabled:opacity-50">
- Refresh workspace selection
-
- ) : null}
-
- {error ? (
-
- {error}
-
- ) : loading ? (
-
-
-
-
-
- ) : items.length === 0 ? (
-
-
- {effectiveSearch
- ? `No ${kind.pluralNoun} match “${effectiveSearch}”.`
- : `You are not a member of any ${kind.pluralNoun} yet.`}
+
+
+
+ onSearchChange(event.target.value)}
+ placeholder={`Search ${kind.pluralNoun}`}
+ aria-label={`Search ${kind.pluralNoun}`}
+ className="w-full rounded-xl border border-edge bg-surface-solid py-2 pr-3 pl-9 text-sm text-text-1 outline-none focus:border-accent"
+ />
+
+
+ {actionError && (
+
+ {actionError}
-
- ) : (
-
- {items.map((workspace) => (
-
-
-
-
-
- {workspace.name || 'Untitled'}
-
-
- {workspace.status && workspace.status !== 'active' && (
-
- {workspace.status}
+ )}
+ {needsReconciliation ? (
+ void reconcile()}
+ className="rounded-lg border border-edge px-3 py-2 text-sm text-text-1 hover:bg-surface-2 disabled:opacity-50">
+ Refresh workspace selection
+
+ ) : 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
+
+ ) : (
+ void activate(workspace)}
+ disabled={activating !== null || needsReconciliation}
+ className={clsx(
+ 'shrink-0 rounded-lg border border-edge px-2.5 py-1.5 text-xs font-medium text-text-1',
+ 'hover:bg-surface-2 disabled:opacity-60',
+ )}
+ >
+ {activating === workspace.id ? (
+
+ ) : (
+ 'Set active'
+ )}
+
)}
-
-
- {workspace.isActive ? (
-
- Active
-
- ) : (
-
void activate(workspace)}
- disabled={activating !== null || needsReconciliation}
- className={clsx(
- 'shrink-0 rounded-lg border border-edge px-2.5 py-1.5 text-xs font-medium text-text-1',
- 'hover:bg-surface-2 disabled:opacity-60',
- )}
- >
- {activating === workspace.id ? (
-
- ) : (
- 'Set active'
- )}
-
- )}
-
-
- ))}
-
- )}
-
- {totalCount > PAGE_SIZE && (
-
-
- Page {page} of {lastPage} · {totalCount} {kind.pluralNoun}
-
-
-
void load(page - 1, effectiveSearch)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Previous
-
-
= lastPage || loading}
- onClick={() => void load(page + 1, effectiveSearch)}
- className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
- >
- Next
-
+
+
+ ))}
+
+ )}
+
+ {totalCount > PAGE_SIZE && (
+
+
+ Page {page} of {lastPage} · {totalCount} {kind.pluralNoun}
+
+
+ void load(page - 1, effectiveSearch)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Previous
+
+ = lastPage || loading}
+ onClick={() => void load(page + 1, effectiveSearch)}
+ className="rounded-lg border border-edge px-2.5 py-1 text-text-1 hover:bg-surface-2 disabled:opacity-50"
+ >
+ Next
+
+
-
- )}
-
+ )}
+
);
}
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/components/tour/GuidedTour.tsx b/application/v2_ui/src/components/tour/GuidedTour.tsx
new file mode 100644
index 000000000..d57203eca
--- /dev/null
+++ b/application/v2_ui/src/components/tour/GuidedTour.tsx
@@ -0,0 +1,324 @@
+// GuidedTour.tsx
+// Walks the user through the controls on a page, one highlighted element at a time.
+//
+// The highlight is a transparent box positioned over the target, with an enormous box
+// shadow that dims everything else. The page underneath stays as it is: no element is
+// moved, wrapped or restyled, so a tour cannot break the layout it describes.
+//
+// Steps whose target is missing or not visible are dropped when the tour starts, so a
+// control turned off by an administrator is skipped rather than pointed at.
+//
+// Keyboard: Escape ends the tour, the arrow keys move between steps, and Tab stays inside
+// the step card. Focus returns to whatever opened the tour when it ends.
+
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type KeyboardEvent,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { clsx } from 'clsx';
+import { ChevronLeft, ChevronRight, X } from 'lucide-react';
+import type { TourDefinition, TourStep } from '../../lib/tours';
+
+interface Box {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+const PADDING = 6;
+const CARD_WIDTH = 320;
+const GAP = 12;
+
+function findTarget(step: TourStep): HTMLElement | null {
+ const element = Array.from(document.querySelectorAll
('[data-tour]'))
+ .find((candidate) => candidate.dataset.tour === step.target) ?? null;
+ if (!element) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ if (rect.width === 0 && rect.height === 0) {
+ return null;
+ }
+ return element;
+}
+
+function measure(element: HTMLElement): Box {
+ const rect = element.getBoundingClientRect();
+ return {
+ top: rect.top - PADDING,
+ left: rect.left - PADDING,
+ width: rect.width + PADDING * 2,
+ height: rect.height + PADDING * 2,
+ };
+}
+
+/** Below the target when there is room, otherwise above, clamped to the viewport. */
+function placeCard(box: Box, cardHeight: number): { top: number; left: number } {
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const width = Math.min(CARD_WIDTH, viewportWidth - GAP * 2);
+
+ let top = box.top + box.height + GAP;
+ if (top + cardHeight > viewportHeight - GAP) {
+ top = box.top - cardHeight - GAP;
+ }
+ // A target taller than the viewport leaves no room either side; overlap it instead.
+ if (top < GAP) {
+ top = Math.max(GAP, viewportHeight - cardHeight - GAP);
+ }
+
+ let left = box.left;
+ left = Math.min(left, viewportWidth - width - GAP);
+ left = Math.max(GAP, left);
+ return { top, left };
+}
+
+export function GuidedTour({
+ tour,
+ onClose,
+}: {
+ tour: TourDefinition;
+ onClose: () => void;
+}) {
+ // Resolved once, when the tour starts, so the step count does not shift under the user.
+ const steps = useMemo(() => tour.steps.filter((step) => findTarget(step) !== null), [tour]);
+ const [index, setIndex] = useState(0);
+ const [box, setBox] = useState(null);
+ const [cardPosition, setCardPosition] = useState<{ top: number; left: number } | null>(null);
+ const cardRef = useRef(null);
+
+ const step = steps[index];
+ const reduceMotion = useMemo(
+ () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
+ [],
+ );
+
+ useEffect(() => {
+ const previous = document.activeElement;
+ return () => {
+ if (previous instanceof HTMLElement && previous.isConnected) {
+ previous.focus();
+ }
+ };
+ }, []);
+
+ const reposition = useCallback(() => {
+ if (!step) {
+ return;
+ }
+ const element = findTarget(step);
+ if (!element) {
+ setBox(null);
+ return;
+ }
+ const nextBox = measure(element);
+ setBox(nextBox);
+ const cardHeight = cardRef.current?.offsetHeight ?? 160;
+ setCardPosition(placeCard(nextBox, cardHeight));
+ }, [step]);
+
+ useLayoutEffect(() => {
+ if (!step) {
+ return;
+ }
+ findTarget(step)?.scrollIntoView({
+ block: 'nearest',
+ inline: 'nearest',
+ behavior: reduceMotion ? 'auto' : 'smooth',
+ });
+ reposition();
+ // Smooth scrolling settles after this frame; measure again once it has.
+ const settle = window.setTimeout(reposition, reduceMotion ? 0 : 350);
+ return () => window.clearTimeout(settle);
+ }, [step, reposition, reduceMotion]);
+
+ useEffect(() => {
+ window.addEventListener('resize', reposition);
+ // Capture, so scrolling inside any pane moves the highlight with its target.
+ window.addEventListener('scroll', reposition, true);
+ return () => {
+ window.removeEventListener('resize', reposition);
+ window.removeEventListener('scroll', reposition, true);
+ };
+ }, [reposition]);
+
+ useEffect(() => {
+ cardRef.current?.focus();
+ }, [index]);
+
+ const last = index >= steps.length - 1;
+ const next = useCallback(() => {
+ if (last) {
+ onClose();
+ } else {
+ setIndex((value) => value + 1);
+ }
+ }, [last, onClose]);
+ const previous = useCallback(() => setIndex((value) => Math.max(0, value - 1)), []);
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ onClose();
+ return;
+ }
+ if (event.key === 'ArrowRight') {
+ event.preventDefault();
+ next();
+ return;
+ }
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault();
+ previous();
+ return;
+ }
+ if (event.key === 'Tab') {
+ const focusable = cardRef.current?.querySelectorAll('button:not([disabled])');
+ if (!focusable || focusable.length === 0) {
+ return;
+ }
+ const first = focusable[0];
+ const final = focusable[focusable.length - 1];
+ const active = document.activeElement;
+ if (event.shiftKey && (active === first || active === cardRef.current)) {
+ event.preventDefault();
+ final.focus();
+ } else if (!event.shiftKey && active === final) {
+ event.preventDefault();
+ first.focus();
+ }
+ }
+ };
+
+ if (!step) {
+ return createPortal(
+
+
+
+ {tour.title}
+
+
+ None of the controls this tour describes are on screen right now.
+
+
+
+ Close
+
+
+
+
,
+ document.body,
+ );
+ }
+
+ return createPortal(
+
+ {/* Swallows clicks on the dimmed page, so nothing behind it fires mid-tour. */}
+
+ {box && (
+
+ )}
+
+
+
+
+ {tour.title} · {index + 1} of {steps.length}
+
+
+ {step.title}
+
+
+
+
+
+
+
+ {step.body}
+
+
+
+ Skip tour
+
+
+
+
+ Back
+
+
+ {last ? 'Done' : 'Next'}
+ {!last && }
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/application/v2_ui/src/components/tour/TourLauncher.tsx b/application/v2_ui/src/components/tour/TourLauncher.tsx
new file mode 100644
index 000000000..5fc5112ef
--- /dev/null
+++ b/application/v2_ui/src/components/tour/TourLauncher.tsx
@@ -0,0 +1,59 @@
+// TourLauncher.tsx
+// The button that starts a page's guided tour, and the `?tour=` arrival hook.
+//
+// Drawn only while the user wants it: the classic `showTutorialButtons` switch turns every
+// tour off, and `tutorialVisibility` turns off one. Preferences can start a tour directly
+// with `requestTour()` before navigating to its page; that works even when the button is
+// hidden, because asking for a tour by name is an explicit choice. The request travels in
+// session storage rather than the URL, so the chat page's own URL handling cannot drop it.
+
+import { useEffect, useState } from 'react';
+import { clsx } from 'clsx';
+import { CircleHelp } from 'lucide-react';
+import { useUserSettingsStore } from '../../stores/userSettingsStore';
+import { consumeTourRequest, findTour, isTourEnabled } from '../../lib/tours';
+import { GuidedTour } from './GuidedTour';
+
+export function TourLauncher({ tourId, className }: { tourId: string; className?: string }) {
+ const tour = findTour(tourId);
+ const [open, setOpen] = useState(false);
+ const settingsLoading = useUserSettingsStore((state) => state.loading);
+ const enabled = useUserSettingsStore((state) =>
+ isTourEnabled(tourId, state.settings.showTutorialButtons, state.settings.tutorialVisibility),
+ );
+
+ useEffect(() => {
+ // Gives the page a moment to draw the controls the tour points at. The request is
+ // consumed inside the timer so a mount that is torn down early leaves it in place.
+ const timer = window.setTimeout(() => {
+ if (consumeTourRequest(tourId)) {
+ setOpen(true);
+ }
+ }, 400);
+ return () => window.clearTimeout(timer);
+ }, [tourId]);
+
+ if (!tour) {
+ return null;
+ }
+
+ return (
+ <>
+ {enabled && !settingsLoading && (
+ setOpen(true)}
+ title={`Take the ${tour.title.toLowerCase()}`}
+ aria-label={`Take the ${tour.title.toLowerCase()}`}
+ className={clsx(
+ 'rounded-lg p-2 text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1',
+ className,
+ )}
+ >
+
+
+ )}
+ {open && setOpen(false)} />}
+ >
+ );
+}
diff --git a/application/v2_ui/src/components/workspace/WorkspaceShell.tsx b/application/v2_ui/src/components/workspace/WorkspaceShell.tsx
index 9f5b0da49..b1e09de46 100644
--- a/application/v2_ui/src/components/workspace/WorkspaceShell.tsx
+++ b/application/v2_ui/src/components/workspace/WorkspaceShell.tsx
@@ -9,6 +9,7 @@ import {
groupWorkspaceSections, navigableSections,
type ResolvedWorkspaceSection, type WorkspaceSectionDescriptor,
} from '../../lib/workspaceSections';
+import { normalizeWorkspaceUrl } from '../../lib/workspaceContext';
export interface WorkspaceNavigationSection extends WorkspaceSectionDescriptor {
label: string;
@@ -37,7 +38,7 @@ export function WorkspaceShell({
{header}
-
@@ -45,6 +46,7 @@ export function WorkspaceShell({
onClick={() => updateUserSettings({ v2WorkspaceRailCollapsed: !railCollapsed })}
aria-label={railCollapsed ? 'Expand workspace sections' : 'Collapse workspace sections'}
aria-expanded={!railCollapsed}
+ data-tour="workspace-rail-toggle"
title={railCollapsed ? 'Expand workspace sections' : 'Collapse workspace sections'}
className={clsx(
'flex items-center gap-2 rounded-lg py-1.5 text-xs text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1',
@@ -54,7 +56,8 @@ export function WorkspaceShell({
<>Collapse >
)}
-
+
Overview
@@ -66,7 +69,10 @@ export function WorkspaceShell({
{entries.map(({ section }) => {
const Icon = section.icon;
return (
-
{section.label}
@@ -76,7 +82,7 @@ export function WorkspaceShell({
))}
-
+
{fullBleed ? children :
{children}
}
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/foundryAuth.ts b/application/v2_ui/src/lib/foundryAuth.ts
index 8be12f088..b2d3f151d 100644
--- a/application/v2_ui/src/lib/foundryAuth.ts
+++ b/application/v2_ui/src/lib/foundryAuth.ts
@@ -3,30 +3,37 @@
import type { ChatStreamEvent } from './types';
import { apiUrl } from './apiClient';
+/** Keep a handoff URL on the authenticated API origin before exposing it to a link. */
+export function normalizeFoundryAuthUrl(value: unknown): string | null {
+ if (typeof value !== 'string' || !value.trim()) {
+ return null;
+ }
+ try {
+ const apiBase = new URL(apiUrl('/'), window.location.origin);
+ const rawUrl = value.trim();
+ const path = rawUrl.startsWith('/') && !rawUrl.startsWith('//') ? apiUrl(rawUrl) : rawUrl;
+ const url = new URL(path, apiBase);
+ if (
+ (url.protocol === 'https:' || url.protocol === 'http:') &&
+ url.origin === apiBase.origin &&
+ !url.username && !url.password
+ ) {
+ return url.href;
+ }
+ } catch {
+ // Malformed or revoked handoffs must not become executable browser links.
+ }
+ return null;
+}
+
/** Consent starts on our authenticated route, never at an event-supplied external site. */
export function foundryAuthUrl(event?: ChatStreamEvent): string | null {
if (event?.auth_required !== true) {
return null;
}
for (const value of [event.auth_url, event.consent_url]) {
- if (typeof value !== 'string' || !value.trim()) {
- continue;
- }
- try {
- const apiBase = new URL(apiUrl('/'), window.location.origin);
- const rawUrl = value.trim();
- const path = rawUrl.startsWith('/') && !rawUrl.startsWith('//') ? apiUrl(rawUrl) : rawUrl;
- const url = new URL(path, apiBase);
- if (
- (url.protocol === 'https:' || url.protocol === 'http:') &&
- url.origin === apiBase.origin &&
- !url.username && !url.password
- ) {
- return url.href;
- }
- } catch {
- // Malformed or revoked handoffs must not become executable browser links.
- }
+ const normalized = normalizeFoundryAuthUrl(value);
+ if (normalized) return normalized;
}
return null;
}
diff --git a/application/v2_ui/src/lib/latestFeaturesNav.ts b/application/v2_ui/src/lib/latestFeaturesNav.ts
new file mode 100644
index 000000000..d6ed9177f
--- /dev/null
+++ b/application/v2_ui/src/lib/latestFeaturesNav.ts
@@ -0,0 +1,72 @@
+// latestFeaturesNav.ts
+// Whether the Support menu's Latest Features shortcut shows for this user, and why.
+//
+// Mirrors should_hide_latest_features_nav() in functions_latest_features_nav.py and the
+// status text in static/js/latest-features-nav.js. The server says whether the deployment
+// offers the shortcut at all; the user's own choice is a saved version string, compared
+// with the running version here so hiding and showing take effect without a reload. A
+// hide saved for an older version lapses on upgrade, which is the point: a new release
+// has new features to show.
+//
+// Kept free of store and React imports so it can be tested directly.
+
+export interface LatestFeaturesNavInput {
+ available: boolean;
+ hidden_by_development: boolean;
+}
+
+export type LatestFeaturesStatus = 'unavailable' | 'development' | 'hidden' | 'visible';
+
+export interface LatestFeaturesNavState {
+ status: LatestFeaturesStatus;
+ /** Whether the rail should draw the shortcut. */
+ showInRail: boolean;
+ /** A hide saved for a different version, which therefore no longer applies. */
+ staleHiddenVersion: string | null;
+}
+
+export function normalizeHiddenVersion(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null;
+ }
+ const trimmed = value.trim();
+ return trimmed ? trimmed : null;
+}
+
+export function resolveLatestFeaturesNav(
+ nav: LatestFeaturesNavInput | null | undefined,
+ hiddenVersion: unknown,
+ currentVersion: string,
+): LatestFeaturesNavState {
+ const saved = normalizeHiddenVersion(hiddenVersion);
+ const version = String(currentVersion || '').trim();
+
+ if (!nav?.available) {
+ return { status: 'unavailable', showInRail: false, staleHiddenVersion: null };
+ }
+ if (nav.hidden_by_development) {
+ return { status: 'development', showInRail: false, staleHiddenVersion: null };
+ }
+ if (saved && saved === version) {
+ return { status: 'hidden', showInRail: false, staleHiddenVersion: null };
+ }
+ return { status: 'visible', showInRail: true, staleHiddenVersion: saved };
+}
+
+export function describeLatestFeaturesNav(
+ state: LatestFeaturesNavState,
+ currentVersion: string,
+): string {
+ switch (state.status) {
+ case 'unavailable':
+ return 'Your administrator has not turned on the Latest Features page.';
+ case 'development':
+ return 'Hidden for everyone while the application runs in development mode.';
+ case 'hidden':
+ return `Hidden for version ${currentVersion}. It comes back when the application is updated.`;
+ case 'visible':
+ return state.staleHiddenVersion
+ ? `Visible: you hid it for version ${state.staleHiddenVersion}, and this is version ${currentVersion}.`
+ : 'Visible in the navigation rail.';
+ }
+}
diff --git a/application/v2_ui/src/lib/m365Connect.ts b/application/v2_ui/src/lib/m365Connect.ts
index 556f523c1..9e9bc065f 100644
--- a/application/v2_ui/src/lib/m365Connect.ts
+++ b/application/v2_ui/src/lib/m365Connect.ts
@@ -45,7 +45,8 @@ export function m365Sources(values: unknown): M365Source[] {
)))).sort();
}
-function authorizationUrl(value: unknown): string {
+/** A Microsoft sign-in URL the browser may navigate to: HTTPS, with a host and no credentials. */
+export function normalizeAuthorizationUrl(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) throw new Error(INVALID_URL_MESSAGE);
let target: URL;
try {
@@ -139,7 +140,7 @@ export function connectMicrosoft365(sources: M365Source[]): Promise {
headers: { 'X-M365-CSRF-Token': csrfToken },
});
if (!finished) {
- popup.location.replace(authorizationUrl(started.authorization_url));
+ popup.location.replace(normalizeAuthorizationUrl(started.authorization_url));
popup.focus();
}
} catch (error) {
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/tours.ts b/application/v2_ui/src/lib/tours.ts
new file mode 100644
index 000000000..331b7bd08
--- /dev/null
+++ b/application/v2_ui/src/lib/tours.ts
@@ -0,0 +1,186 @@
+// tours.ts
+// The guided tours V2 ships, and the rules deciding which of them a user is offered.
+//
+// The classic interface has a chat tutorial and a workspace tutorial behind one switch,
+// `showTutorialButtons`. V2 keeps that switch, shared with the classic interface, and adds
+// `tutorialVisibility` so a user can keep one tour and drop the other.
+//
+// A step names its target by `data-tour` attribute rather than by class or id, so restyling
+// a control cannot quietly break a tour. A step whose target is missing or not visible is
+// skipped when the tour runs: the composer's attach button, for instance, is not drawn when
+// uploads are off, and a tour that pointed at nothing would be worse than one step shorter.
+//
+// The ids must match TUTORIAL_IDS in route_backend_users.py, which rejects any other key.
+//
+// Kept free of store and React imports so it can be tested directly. The request helpers
+// touch session storage only when called.
+
+export interface TourStep {
+ /** The `data-tour` value of the element this step points at. */
+ target: string;
+ title: string;
+ body: string;
+}
+
+export interface TourDefinition {
+ id: string;
+ title: string;
+ description: string;
+ /** The V2 route the tour runs on. */
+ path: string;
+ steps: TourStep[];
+}
+
+export const TOURS: TourDefinition[] = [
+ {
+ id: 'chat',
+ title: 'Chat tour',
+ description: 'The conversation list, the composer and the controls around a reply.',
+ path: '/chat',
+ steps: [
+ {
+ target: 'new-chat',
+ title: 'Start a conversation',
+ body: 'Opens a fresh conversation. Earlier ones stay in the list below.',
+ },
+ {
+ target: 'conversation-list',
+ title: 'Your conversations',
+ body: 'Search, pin, select and export conversations here. Hover a conversation for its actions.',
+ },
+ {
+ target: 'chat-header',
+ title: 'Conversation tools',
+ body: 'Share the conversation, widen the reading area, and open the details, contents and documents panels.',
+ },
+ {
+ target: 'message-list',
+ title: 'The conversation',
+ body: 'Replies appear here. Hover a reply to see its sources and details, or one of your own messages to edit it.',
+ },
+ {
+ target: 'composer-input',
+ title: 'Write a message',
+ body: 'Type your question. Start with / to insert one of your saved prompts.',
+ },
+ {
+ target: 'composer-tools',
+ title: 'Choose what the answer draws on',
+ body: 'Pick a model or agent, search your workspaces, and turn on web search or image generation where they are offered.',
+ },
+ {
+ target: 'composer-attach',
+ title: 'Attach a file',
+ body: 'Add a file to this conversation so the answer can use it.',
+ },
+ {
+ target: 'composer-send',
+ title: 'Send',
+ body: 'Sends the message. While a reply is streaming, this becomes a stop button.',
+ },
+ {
+ target: 'user-menu',
+ title: 'Your settings',
+ body: 'Open User Settings to change the text size, sounds, memory and these tours.',
+ },
+ ],
+ },
+ {
+ id: 'workspace',
+ title: 'Workspace tour',
+ description: 'The sections of a workspace: documents, prompts, agents and connections.',
+ path: '/workspace',
+ steps: [
+ {
+ target: 'workspace-sections',
+ title: 'Workspace sections',
+ body: 'Everything this workspace holds is grouped here: knowledge, automation and connections.',
+ },
+ {
+ target: 'workspace-overview',
+ title: 'Overview',
+ body: 'The starting point for this workspace, with a summary of what it holds.',
+ },
+ {
+ target: 'workspace-content',
+ title: 'Work with the section',
+ body: 'Upload documents, write prompts or configure agents here, depending on the section you picked.',
+ },
+ {
+ target: 'workspace-rail-toggle',
+ title: 'More room',
+ body: 'Collapse the sections to icons when you need the space. The choice is remembered.',
+ },
+ ],
+ },
+];
+
+export function findTour(id: string): TourDefinition | undefined {
+ return TOURS.find((tour) => tour.id === id);
+}
+
+/**
+ * Whether a tour is offered. The classic master switch wins; a tour missing from the
+ * per-tour map is shown.
+ */
+export function isTourEnabled(
+ tourId: string,
+ showTutorialButtons: unknown,
+ tutorialVisibility: unknown,
+): boolean {
+ if (showTutorialButtons === false) {
+ return false;
+ }
+ if (tutorialVisibility && typeof tutorialVisibility === 'object') {
+ const value = (tutorialVisibility as Record)[tourId];
+ if (value === false) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** The per-tour map with one tour changed, keeping only shipped ids. */
+export function withTourVisibility(
+ current: unknown,
+ tourId: string,
+ visible: boolean,
+): Record {
+ const next: Record = {};
+ const known = new Set(TOURS.map((tour) => tour.id));
+ if (current && typeof current === 'object') {
+ for (const [key, value] of Object.entries(current as Record)) {
+ if (known.has(key) && typeof value === 'boolean') {
+ next[key] = value;
+ }
+ }
+ }
+ if (known.has(tourId)) {
+ next[tourId] = visible;
+ }
+ return next;
+}
+
+const TOUR_REQUEST_KEY = 'simplechat.v2.pendingTour';
+
+/** Asks the next page that hosts this tour to start it on arrival. */
+export function requestTour(tourId: string): void {
+ try {
+ window.sessionStorage.setItem(TOUR_REQUEST_KEY, tourId);
+ } catch {
+ // Storage can be unavailable; the user can still start the tour from its page.
+ }
+}
+
+/** Whether a tour was requested for this page, clearing the request either way it matches. */
+export function consumeTourRequest(tourId: string): boolean {
+ try {
+ if (window.sessionStorage.getItem(TOUR_REQUEST_KEY) !== tourId) {
+ return false;
+ }
+ window.sessionStorage.removeItem(TOUR_REQUEST_KEY);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts
index 76d4016aa..fb07d3132 100644
--- a/application/v2_ui/src/lib/types.ts
+++ b/application/v2_ui/src/lib/types.ts
@@ -1059,6 +1059,15 @@ export interface PublicWorkspaceLabels {
max_length: number;
}
+/** Whether this deployment offers the Latest Features shortcut, before the user's own choice. */
+export interface LatestFeaturesNav {
+ available: boolean;
+ /** Development builds hide the shortcut for everyone, as the classic interface does. */
+ hidden_by_development: boolean;
+ url: string;
+ menu_name: string;
+}
+
export interface BootstrapFeatures extends Record {
enable_workflow_alert_sounds?: boolean;
}
@@ -1110,6 +1119,8 @@ export interface BootstrapPayload {
navigation: {
custom_pages: NavGroup;
external_links: NavGroup;
+ /** The Support menu's Latest Features shortcut. Absent from older servers. */
+ latest_features?: LatestFeaturesNav;
};
features: BootstrapFeatures;
control_center: ControlCenterCapabilities;
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/userSettings.ts b/application/v2_ui/src/lib/userSettings.ts
index 9f7047dce..513b1477e 100644
--- a/application/v2_ui/src/lib/userSettings.ts
+++ b/application/v2_ui/src/lib/userSettings.ts
@@ -34,6 +34,11 @@ export interface UserSettings {
fontSizePreference?: FontSizePreference;
sidebarToggleStyle?: SidebarToggleStyle;
showTutorialButtons?: boolean;
+ /**
+ * Per-tour visibility, `{ tourId: boolean }`. A tour missing from the map is shown,
+ * as long as `showTutorialButtons` is not off. The route accepts only shipped tour ids.
+ */
+ tutorialVisibility?: Record;
desktopNotificationsEnabled?: boolean;
conversationContentsDrawerEnabled?: boolean;
/**
@@ -85,6 +90,12 @@ export interface UserSettings {
/** Whether the V2 Control Center section rail is collapsed to icons. */
v2ControlCenterRailCollapsed?: 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.
*
@@ -141,7 +152,7 @@ export interface UserSettings {
ttsSpeed?: number;
ttsAutoplay?: boolean;
- /** Read-only here; the route sets it when the user hides the shortcut. */
+ /** The version the user hid the Latest Features shortcut for; the route accepts only the current one. */
latestFeaturesHiddenVersion?: string | null;
/**
@@ -206,6 +217,9 @@ export const WRITABLE_USER_SETTING_KEYS = [
'fontSizePreference',
'sidebarToggleStyle',
'showTutorialButtons',
+ 'tutorialVisibility',
+ // Written by the Latest Features hide control: the current version, or null to show it again.
+ 'latestFeaturesHiddenVersion',
'desktopNotificationsEnabled',
'conversationContentsDrawerEnabled',
'showConversationWorkspaceTags',
@@ -223,6 +237,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',
// Separate from the shell and Admin Settings rails so each keeps its own layout.
'v2ControlCenterRailCollapsed',
// Workspace documents explorer: how the list is presented, and the saved filter
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/application/v2_ui/src/lib/workspaceContext.ts b/application/v2_ui/src/lib/workspaceContext.ts
index 99e5bc88c..c80108ce7 100644
--- a/application/v2_ui/src/lib/workspaceContext.ts
+++ b/application/v2_ui/src/lib/workspaceContext.ts
@@ -319,6 +319,44 @@ export function workspaceBasePath(scope: WorkspaceRef): string {
return `/${scope.kind === 'group' ? 'groups' : 'public'}/${id}`;
}
+/** Validate a workspace navigation path before it reaches a router link. */
+export function normalizeWorkspaceUrl(value: string, origin: string): string {
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')
+ || value.includes('\\') || /[?#]/.test(value)) {
+ throw new Error('Invalid workspace navigation path.');
+ }
+
+ let pageOrigin: URL;
+ try {
+ pageOrigin = new URL(origin);
+ } catch {
+ throw new Error('Invalid workspace navigation origin.');
+ }
+ if ((pageOrigin.protocol !== 'http:' && pageOrigin.protocol !== 'https:')
+ || pageOrigin.origin !== origin) {
+ throw new Error('Invalid workspace navigation origin.');
+ }
+
+ for (const segment of value.slice(1).split('/')) {
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(segment);
+ } catch {
+ throw new Error('Invalid workspace navigation path.');
+ }
+ if (decoded === '.' || decoded === '..' || /[/\\?#\u0000-\u001f\u007f]/.test(decoded)) {
+ throw new Error('Invalid workspace navigation path.');
+ }
+ }
+
+ const target = new URL(value, pageOrigin);
+ if (target.origin !== pageOrigin.origin || target.username || target.password
+ || target.search || target.hash || !/^\/(?:workspace|groups|public)(?:\/|$)/.test(target.pathname)) {
+ throw new Error('Invalid workspace navigation path.');
+ }
+ return target.pathname;
+}
+
const CONTENT_SECTION_GROUPS = ['knowledge', 'automation', 'connections'];
function isSectionAccess(value: unknown, groups: readonly string[] = CONTENT_SECTION_GROUPS): value is WorkspaceSectionAccess {
diff --git a/application/v2_ui/src/pages/ChatPage.tsx b/application/v2_ui/src/pages/ChatPage.tsx
index 4c17a76fe..715c912e2 100644
--- a/application/v2_ui/src/pages/ChatPage.tsx
+++ b/application/v2_ui/src/pages/ChatPage.tsx
@@ -41,6 +41,7 @@ import { ConversationBadges } from '../components/chat/ConversationBadges';
import { ParticipantsPanel } from '../components/chat/ParticipantsPanel';
import { InviteBanner } from '../components/chat/InviteBanner';
import { FileApprovals } from '../components/chat/FileApprovals';
+import { TourLauncher } from '../components/tour/TourLauncher';
import { panelTargetForConversation, canShareConversation } from '../lib/sharing';
import {
readWorkspaceAgentLaunch, workspaceAgentForLaunch, workspaceAgentLaunchUnavailableMessage,
@@ -253,7 +254,10 @@ function ChatHeader({ onOpenDetails }: { onOpenDetails: () => void }) {
setDrawerMode(drawerMode === mode ? null : mode);
return (
-
+
{active?.title || 'New chat'}
@@ -393,6 +397,9 @@ function ChatHeader({ onOpenDetails }: { onOpenDetails: () => void }) {
)}
)}
+
+ {/* Pushed right on its own when there is no conversation and so no tool row. */}
+
);
}
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() {
}
/>
-
-
+
- {tabs.map((tab) => {
- const Icon = tab.icon;
- const isActive = tab.id === active.id;
- return (
- setSearchParams({ tab: tab.id })}
- className={clsx(
- 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition-colors',
- isActive
- ? 'bg-accent-soft font-medium text-accent'
- : 'text-text-2 hover:bg-surface-2 hover:text-text-1',
- )}
- >
-
- {tab.label}
-
- );
- })}
-
+
updateUserSettings({ v2UserSettingsRailCollapsed: !railCollapsed })}
+ aria-label={railCollapsed ? 'Expand user settings sections' : 'Collapse user settings sections'}
+ aria-expanded={!railCollapsed}
+ aria-controls="user-settings-tab-list"
+ title={railCollapsed ? 'Expand user settings sections' : 'Collapse user settings sections'}
+ className={clsx(
+ 'mb-2 flex w-full items-center gap-2 rounded-lg py-1.5 text-xs text-text-3 transition-colors hover:bg-surface-2 hover:text-text-1',
+ railCollapsed ? 'justify-center px-2' : 'px-3',
+ )}
+ >
+ {railCollapsed ? : (
+ <>Collapse >
+ )}
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ const isActive = tab.id === active.id;
+ return (
+ selectTab(tab.id)}
+ className={clsx(
+ 'flex w-full items-center gap-2.5 rounded-lg py-2.5 text-left text-sm transition-colors',
+ railCollapsed ? 'justify-center px-2' : 'px-3',
+ isActive
+ ? 'bg-accent-soft font-semibold text-accent'
+ : 'text-text-2 hover:bg-surface-2 hover:text-text-1',
+ )}
+ >
+
+ {/* Collapsed, the label stays as the button's accessible name. */}
+ {tab.label}
+
+ );
+ })}
+
+
-
-
-
+
+
+ Settings section
+ selectTab(event.target.value)}
+ className="w-full max-w-md rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1"
+ >
+ {tabs.map((tab) => (
+ {tab.label}
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {entries.length > 1 ? (
+
+ ) : null}
+
);
-}
+}
\ No newline at end of file
diff --git a/application/v2_ui/src/pages/workspace/WorkspacePage.tsx b/application/v2_ui/src/pages/workspace/WorkspacePage.tsx
index d7c050ee8..b93b6a374 100644
--- a/application/v2_ui/src/pages/workspace/WorkspacePage.tsx
+++ b/application/v2_ui/src/pages/workspace/WorkspacePage.tsx
@@ -11,6 +11,7 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { LayoutGrid, Lock } from 'lucide-react';
import { PageHeader } from '../../components/layout/PageHeader';
+import { TourLauncher } from '../../components/tour/TourLauncher';
import { EmptyState } from '../../components/ui/primitives';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { WorkspaceShell } from '../../components/workspace/WorkspaceShell';
@@ -101,6 +102,7 @@ export function WorkspacePage() {
header={
}
/>}>
{renderBody()}
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..764be9efc
--- /dev/null
+++ b/docs/explanation/features/V2_USER_SETTINGS_REDESIGN.md
@@ -0,0 +1,203 @@
+# V2 User Settings Redesign
+
+**Implemented in version: 0.261.277** (layout); **0.261.278** (voice, audio and retention);
+**0.261.279** (fact memory and Microsoft 365); **0.261.280** (guided tours and Latest Features)
+
+## 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. 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. Fact memory workbench and Microsoft 365 sharing and workflows (0.261.279).
+4. Guided tours and the Latest Features shortcut (0.261.280).
+
+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), Voice and audio (Completion sounds, Spoken replies, Microphone), Notifications and alerts (Desktop notifications, Workflow alerts on this device), Help and guidance (Latest Features, Guided tours), Memory and data (Fact memory, Retention), Connected accounts (Microsoft 365 sharing, Chat connection, Workflow connection, Workflow authorizations), 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.
+
+### 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.
+
+### Fact memory
+
+`components/settings/FactMemoryBench.tsx` replaces the classic Fact Memory card and its
+manager dialog with one workbench, using the same routes:
+
+| Action | Route |
+| --- | --- |
+| List, with the admin state and counts | `GET /api/profile/fact-memory` |
+| Add | `POST /api/profile/fact-memory` with `value` and `memory_type` |
+| Edit wording or type | `PUT /api/profile/fact-memory/
` |
+| Delete, after a confirmation dialog | `DELETE /api/profile/fact-memory/` |
+
+- A memory is an **instruction** (applied to every reply) or a **fact** (recalled when
+ relevant).
+- The list is searchable, filterable by type, and ordered by last change.
+- The card is always shown, as on the classic page. The header badge reflects
+ `enable_fact_memory_plugin`; while it is off, memories can be managed but are not used.
+
+### Microsoft 365
+
+`components/settings/M365Cards.tsx` mirrors the classic "Microsoft 365 sharing and
+workflows" section (`static/js/profile/profile-m365.js`). Like the classic page it is not
+gated by a capability flag.
+
+| Card | Routes | Behaviour |
+| --- | --- | --- |
+| Microsoft 365 sharing | `GET`/`PATCH /api/m365/preferences`, `POST /api/m365/sources//revoke` | A sharing duration for Calendar, Email, OneDrive and SPO, and an extended-analysis choice for OneDrive and SPO. An unknown value from the server is refused rather than guessed. Saved with **Save**. Shows the browser timezone, which is not stored. |
+| Chat connection | `GET /api/m365/chat/connection`, `POST /api/m365/chat/connection/connect` | Status and saved sources. Reconnect uses the existing `connectMicrosoft365` popup flow, then re-reads the status. |
+| Workflow connection | `GET /api/m365/connections`, `POST /api/m365/connections/connect`, `POST /api/m365/connections/disconnect` | Account, tenant, cloud, sources and delegated permissions. Connect navigates to the sign-in URL only after `authorizationUrl()` validates it as HTTPS with no credentials. |
+| Workflow authorizations | `GET /api/m365/bindings`, `POST /api/m365/bindings//revoke` | Paged list using the classic Approvals wording. Pending and approved entries can be revoked. Links to Approvals. |
+
+- Every write sends `X-M365-CSRF-Token`. The token comes from the GET responses and is
+ refreshed and retried once when the server returns `m365_csrf_invalid`, as the classic
+ page does.
+- Revocations and disconnect go through one confirmation dialog. It states that history
+ already published to conversations is not removed. After a change, all four cards refresh.
+
+### Guided tours and Latest Features
+
+**Guided tours.** V2 has its own tour engine, which replaces the classic floating tutorial
+launchers on the Chat and Personal Workspace pages.
+
+- `lib/tours.ts` defines each tour: its page, and the steps that point at `data-tour`
+ anchors. The shipped tours are `chat` and `workspace`.
+- `components/tour/GuidedTour.tsx` draws the tour:
+ - A spotlight over the current control, with a card beside it.
+ - **Back**, **Next**, **Skip** and **Done** buttons. Esc ends the tour and the arrow
+ keys move between steps.
+ - Focus is trapped in the card, then returned when the tour ends. Reduced motion is
+ respected.
+ - Steps whose control is missing or hidden are skipped.
+- `components/tour/TourLauncher.tsx` adds a help button to the Chat header and the
+ Workspace page header.
+- **Start now** in Preferences opens the tour's page and starts it. It does this through a
+ one-time request in session storage (`simplechat.v2.pendingTour`).
+
+Which tours are offered is decided by two settings:
+
+- `showTutorialButtons` is the master switch, shared with the classic tutorial buttons. When
+ it is false, every tour is off.
+- `tutorialVisibility` holds a per-tour choice, `{ tourId: bool }`. A tour with no entry is
+ shown. The route accepts only shipped tour ids (`TUTORIAL_IDS` in
+ `route_backend_users.py`) with boolean values.
+
+**Latest Features.** The bootstrap payload now has `navigation.latest_features`, built by
+`_build_latest_features_nav` in `route_backend_v2.py`. It contains `available`,
+`hidden_by_development`, `url` and `menu_name`.
+
+The shortcut is available when all of these hold:
+
+- The user has the Admin or User role.
+- The support menu is on.
+- `enable_support_latest_features` is on.
+- At least one Latest Features item is visible.
+
+As on the classic interface, the shortcut is hidden for everyone in development mode.
+`components/layout/LatestFeaturesLink.tsx` adds it to the navigation rail with a hide
+button, and the Preferences card shows its status with **Hide for this version** or **Show
+again**.
+
+Both surfaces write `latestFeaturesHiddenVersion`, shared with the classic page. A hide
+applies only to the version it was saved for, so the shortcut comes back after an upgrade.
+`lib/latestFeaturesNav.ts` holds the logic both surfaces use.
+
+### 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.
+- `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'`.
+- `functional_tests/test_v2_user_settings_memory_m365.py`: preference grouping, the fact
+ memory routes and confirmed delete, CSRF validation on every M365 write the cards make,
+ the CSRF retry, the classic choice labels, confirmed revocations, and validated sign-in
+ redirects.
+- `functional_tests/test_v2_user_settings_tutorials_latest_features.py`: tour ids
+ accepted by the route, every tour step's anchor present, launchers and the Help and
+ guidance group, the bootstrap navigation entry, and a Node run of the shared tour and
+ Latest Features logic.
+- 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
+
+The index appears only when a tab has more than one card, so the Groups and Public
+workspaces tabs do not show it.
+
+The Microsoft 365 workflow sign-in returns to the classic Profile page
+(`/profile?m365_connection=connected`), because the callback route redirects there.
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/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/explanation/fixes/V2_XSS_SINK_CHECK_FIX.md b/docs/explanation/fixes/V2_XSS_SINK_CHECK_FIX.md
new file mode 100644
index 000000000..d0ab37a3a
--- /dev/null
+++ b/docs/explanation/fixes/V2_XSS_SINK_CHECK_FIX.md
@@ -0,0 +1,22 @@
+# V2 XSS Sink Check Fix
+
+**Fixed in version: 0.261.281**
+
+## Issue and root cause
+
+The `xss-sink-check` job failed on the V2 settings redesign PR. Several newly added UI links passed values from API state directly to URL sinks, including the Latest Features route, workspace navigation, Foundry sign-in, and Microsoft 365 authorization. The guided tour also interpolated a tour target into a DOM selector. Separately, the checker treated a `javascript:` example in a Python comment as executable content.
+
+## Technical details
+
+`Latest Features` links now use the fixed same-origin route. Workspace destinations are normalized against the current origin and reject unsupported paths, path traversal, and embedded separators before reaching React Router. Foundry URLs are normalized against the authenticated API origin; Microsoft 365 authorization continues to require HTTPS and now uses a normalization-named helper at each navigation sink. The tour locates `data-tour` elements by comparing their dataset values rather than interpolating a selector. The Python comment now describes a script-scheme URL without using the checker token.
+
+The regression tests cover workspace URL rejection, the URL helper call sites, and the guided-tour target lookup. No checker rule was disabled or suppressed.
+
+## Validation
+
+- `python scripts\check_xss_sinks.py --base-sha b0896b4c52cd120621a9a5a5f67360963127ce10 --head-sha HEAD ` — passed for 37 files.
+- `python -m pytest -q functional_tests\test_v2_user_settings_tutorials_latest_features.py functional_tests\test_v2_user_settings_memory_m365.py` — 15 passed.
+- `node functional_tests\test_v2_group_workspace_context_logic.mjs` — 27 checks passed.
+- `npm run typecheck` in `application\v2_ui` — passed.
+- `npm run build` in `application\v2_ui` — passed; Vite reported the existing large-chunk advisory.
+- `git diff --check` — passed.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 1608cd72f..236cf5570 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -4,6 +4,14 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
### **(v0.261.280)**
+#### New Features
+
+* **Guided Tours and Latest Features in V2**
+ * Chat and Personal Workspace have step-by-step guided tours, started from a help button in the page header or with **Start now** in User Settings. Tours skip controls that are not on screen, support the keyboard, and respect reduced motion.
+ * A new **Help and guidance** group in Preferences turns tours off one by one, or all at once with **Show tour buttons**. That switch is shared with the classic tutorial buttons.
+ * A **Latest Features** shortcut appears in the V2 navigation rail when the Support menu offers it. Hiding it lasts until the next release, and the setting is shared with the classic interface.
+ * (Ref: `lib/tours.ts`, `GuidedTour.tsx`, `TourLauncher.tsx`, `LatestFeaturesLink.tsx`, `GuidanceCards.tsx`, `_build_latest_features_nav`, `tutorialVisibility`, [V2 User Settings Redesign](features/V2_USER_SETTINGS_REDESIGN.md))
+
#### Bug Fixes
* **Dashboard Date-Range Error Safety**
@@ -20,6 +28,11 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
#### New Features
+* **Fact Memory Workbench and Microsoft 365 Cards in V2 User Settings**
+ * Fact memory is now one workbench: add, search, filter, edit, and delete (with confirmation) instructions and facts.
+ * Microsoft 365 sharing, chat connection, workflow connection, and workflow authorizations match the classic page, including CSRF-protected writes and confirmed revocations.
+ * (Ref: `FactMemoryBench.tsx`, `M365Cards.tsx`, `PreferencesTab.tsx`)
+
* **V2 Control Center Dashboard**
* Adds period-based summary metrics, correct group and public-workspace status counts, login/activity/token charts, token filters, CSV export, and chat-with-trends.
* Aggregates use activity-log and workspace data with a 90-second cache. Metrics without retained historical snapshots are identified as current state instead of presenting misleading period deltas.
@@ -35,20 +48,41 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
#### New Features
+* **Voice, Audio and Retention in V2 User Settings**
+ * Completion sounds with preview and mute, spoken reply voice, speed, and auto-play, microphone permission status, and personal retention for conversations and documents.
+ * (Ref: `PreferencesTab.tsx`, `lib/speechPlayback.ts`, `/api/retention-policy/user`)
+
* **V2 Control Center Foundation**
* Adds the permission-aware V2 Control Center pane, reusable management primitives, and a manually invoked activity-log data-health check and backfill.
* The classic page no longer scans for migration status on every visit; the retained backfill skips existing activity events and uses stable IDs for reruns.
* (Ref: `ControlCenterPage.tsx`, `functions_authentication.py`, `route_backend_v2.py`, [V2 Control Center](features/V2_CONTROL_CENTER.md), [Activity Log Migration Prompt Fix](fixes/ACTIVITY_LOG_MIGRATION_PROMPT_FIX.md))
+#### Bug Fixes
+
+* **Personal Retention Default Value**
+ * Choosing the organization default for personal retention is now accepted.
+ * (Ref: [Personal Retention Default Value Fix](fixes/PERSONAL_RETENTION_DEFAULT_VALUE_FIX.md))
+
### **(v0.261.277)**
#### User Interface Enhancements
+* **V2 User Settings Follows the Admin Settings Design**
+ * A collapsible section rail remembered per user, full-width settings cards, and an **On this page** index on every tab. Preferences are grouped instead of listed, and the Violations tab is always listed.
+ * (Ref: `SettingsPage.tsx`, `SettingsCard.tsx`, `v2UserSettingsRailCollapsed`)
+
* **Agent Editor No Longer Shows GUIDs**
* The agent editor for personal, group and global agents no longer shows internal identifiers. The header shows the agent's own icon, such as the Microsoft 365 logo, and its description instead of a stable ID.
* Model & connection › Current selection lists the deployment, model, connection name and provider (for example "Azure OpenAI") instead of endpoint and model IDs.
* Action choices, knowledge workspaces and documents no longer show IDs. Workspaces show their type, and documents show their workspace, file name and tags.
* (Ref: `AgentEditorPage.tsx`, `AgentModelFields.tsx`, `AgentActionPicker.tsx`, `AgentKnowledgeFields.tsx`, [Agent Editor Internal ID Exposure Fix](fixes/AGENT_EDITOR_INTERNAL_ID_EXPOSURE_FIX.md))
+
+#### Bug Fixes
+
+* **Feedback Tab Loads in V2**
+ * The Feedback tab failed with "too many values to unpack". The user's own feedback now loads.
+ * (Ref: [Feedback My Routes Filter Unpack Fix](fixes/FEEDBACK_MY_FILTER_UNPACK_FIX.md))
+
### **(v0.261.276)**
#### New Features
diff --git a/docs/guides/update-profile-preferences.md b/docs/guides/update-profile-preferences.md
index cc4f7deea..dbe78922b 100644
--- a/docs/guides/update-profile-preferences.md
+++ b/docs/guides/update-profile-preferences.md
@@ -47,6 +47,65 @@ 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**, **Voice and audio**, **Notifications and alerts**, **Help and
+guidance**, **Memory and data**, **Connected accounts**, 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 **Help and guidance**:
+
+- **Latest Features** controls the shortcut to recent changes in the navigation rail. Select
+ **Hide for this version** to remove it; it returns by itself after the next upgrade, so
+ you still hear about new features. You can also hide it with the button beside the
+ shortcut, or bring it back with **Show again**. The setting is shared with the classic
+ interface.
+- **Guided tours** walk you through the controls on a page, one step at a time. Each page
+ with a tour has a help button in its header, and **Start now** opens the page and starts
+ the tour. Turn individual tours off, or use **Show tour buttons** to turn them all off.
+ That switch is shared with the classic interface's tutorial buttons.
+
+Under **Memory and data**, **Fact memory** holds what the assistant should know about you.
+Add a memory as an **Instruction**, which shapes every reply, or a **Fact**, which is
+recalled only when it is relevant to what you ask. The list beside the editor lets you
+search and filter your memories; select one to change its wording or type, or to delete
+it. If your administrator has turned fact memory off, the card says so: you can still tidy
+your memories, but they are not used until it is turned back on.
+
+**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.
+
+Under **Connected accounts**, the Microsoft 365 cards cover the same ground as the classic
+page, described in the next section: **Microsoft 365 sharing**, **Chat connection**,
+**Workflow connection**, and **Workflow authorizations**. Every revocation and disconnect asks
+you to confirm first. Chat reconnect opens Microsoft sign-in in a pop-up and keeps you on the
+page; connecting for workflows signs in through the classic Profile page and returns there.
+
+**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_bootstrap_branding_and_navigation.py b/functional_tests/test_v2_bootstrap_branding_and_navigation.py
index 4e7bad161..31d4dda2a 100644
--- a/functional_tests/test_v2_bootstrap_branding_and_navigation.py
+++ b/functional_tests/test_v2_bootstrap_branding_and_navigation.py
@@ -42,13 +42,19 @@
# The builders under test. There is no module-level constant to lift: the landing copy
# is passed through exactly as stored, deliberately.
-LIFTED_FUNCTIONS = ("_build_branding", "_coerce_logo_scale", "_menu_name", "_build_navigation")
+LIFTED_FUNCTIONS = (
+ "_build_branding",
+ "_coerce_logo_scale",
+ "_menu_name",
+ "_build_navigation",
+ "_build_latest_features_nav",
+)
branding_urls = import_app_module("functions_branding_urls")
fields_module = import_app_module("admin_settings_fields")
-def load_builders(custom_pages_nav=None, nav_raises=False):
+def load_builders(custom_pages_nav=None, nav_raises=False, latest_visible=True, development=False):
"""Execute the bootstrap builders in isolation from the Flask application.
Only the names the lifted functions actually reference are provided. Anything
@@ -86,6 +92,8 @@ def fake_get_custom_pages_nav(_settings):
"LOGO_SCALE_DEFAULT_PERCENT": fields_module.LOGO_SCALE_DEFAULT_PERCENT,
"LOGO_SCALE_MIN_PERCENT": fields_module.LOGO_SCALE_MIN_PERCENT,
"LOGO_SCALE_MAX_PERCENT": fields_module.LOGO_SCALE_MAX_PERCENT,
+ "has_visible_support_latest_features": lambda _settings: latest_visible,
+ "IS_DEVELOPMENT": development,
}
module = ast.Module(body=wanted, type_ignores=[])
@@ -377,6 +385,33 @@ def test_custom_pages_group_survives_a_failing_lookup():
return True
+def test_latest_features_entry_follows_the_classic_gate():
+ """The rail shortcut appears only where the classic Support menu would show it."""
+ print("\nTesting the Latest Features navigation entry...")
+
+ assert_app_version_at_least("0.261.280")
+ on = {"enable_support_menu": True, "enable_support_latest_features": True}
+
+ entry = load_builders()["_build_navigation"](on, ["User"])["latest_features"]
+ assert entry["available"] is True and entry["hidden_by_development"] is False
+ assert entry["url"] == "/support/latest-features"
+ assert entry["menu_name"] == "Support"
+
+ build = load_builders()["_build_latest_features_nav"]
+ assert build(on, [])["available"] is False, "A caller with no app role must not see it"
+ assert build({**on, "enable_support_menu": False}, ["User"])["available"] is False
+ assert build({**on, "enable_support_latest_features": False}, ["Admin"])["available"] is False
+ assert load_builders(latest_visible=False)["_build_latest_features_nav"](on, ["User"])[
+ "available"
+ ] is False, "With every feature hidden there is nothing to link to"
+ assert load_builders(development=True)["_build_latest_features_nav"](on, ["User"])[
+ "hidden_by_development"
+ ] is True
+
+ print(" The Latest Features entry follows the classic gate.")
+ return True
+
+
def test_bootstrap_payload_carries_the_navigation_block():
"""The rail reads navigation from bootstrap; nothing else supplies it."""
print("\nTesting the bootstrap payload wiring...")
@@ -401,6 +436,7 @@ def test_bootstrap_payload_carries_the_navigation_block():
test_unsafe_external_link_urls_are_dropped,
test_external_links_are_gated_by_role_and_validated,
test_custom_pages_group_survives_a_failing_lookup,
+ test_latest_features_entry_follows_the_classic_gate,
test_bootstrap_payload_carries_the_navigation_block,
]
diff --git a/functional_tests/test_v2_group_workspace_context_logic.mjs b/functional_tests/test_v2_group_workspace_context_logic.mjs
index 685e3f9f9..a5113ef68 100644
--- a/functional_tests/test_v2_group_workspace_context_logic.mjs
+++ b/functional_tests/test_v2_group_workspace_context_logic.mjs
@@ -1,8 +1,9 @@
// test_v2_group_workspace_context_logic.mjs
-// Version: 0.261.166
+// Version: 0.261.281
// Implemented in: 0.261.126
// Shared shell navigation and revalidation: 0.261.127
// Members section validation (M7B): 0.261.155
+// Workspace navigation URL validation: 0.261.281
// Executes the real context API and stores with controlled HTTP ordering.
import assert from 'node:assert/strict';
@@ -10,7 +11,7 @@ import './test_support/tsResolve.mjs';
const {
GROUP_WORKSPACE_SECTION_IDS, isGroupWorkspaceContext, workspaceScopeKey,
- workspaceBasePath, requireWorkspaceId,
+ workspaceBasePath, normalizeWorkspaceUrl, requireWorkspaceId,
} = await import('../application/v2_ui/src/lib/workspaceContext.ts');
const { useBootstrapStore } = await import('../application/v2_ui/src/stores/bootstrapStore.ts');
const { resolveWorkspaceSections } = await import('../application/v2_ui/src/lib/workspaceSections.ts');
@@ -117,6 +118,17 @@ try {
assert.equal(workspaceBasePath({ kind: 'public', id: 'a b' }), '/public/a%20b');
assert.equal(workspaceBasePath({ kind: 'group', id: "a!'()*" }), '/groups/a%21%27%28%29%2A');
assert.equal(workspaceBasePath({ kind: 'personal', id: 'viewer' }), '/workspace');
+ assert.equal(
+ normalizeWorkspaceUrl('/groups/a%20b/documents', 'https://simplechat.test'),
+ '/groups/a%20b/documents',
+ );
+ for (const path of [
+ 'javascript:alert(1)', '//evil.test/groups/a', '/groups/%2e%2e/chat',
+ '/groups/a%2fb/documents', '/groups/a?next=/chat',
+ ]) {
+ assert.throws(() => normalizeWorkspaceUrl(path, 'https://simplechat.test'));
+ }
+ assert.throws(() => normalizeWorkspaceUrl('/groups/a', 'https://evil.test/path'));
for (const id of ['', '.', '..', ' leading', 'trailing ', 'a/b', 'a\\b', 'a?b', 'a#b', 'a\nb']) {
assert.throws(() => requireWorkspaceId(id));
}
diff --git a/functional_tests/test_v2_settings_and_workspace_tags.py b/functional_tests/test_v2_settings_and_workspace_tags.py
index 850c5cbb7..92f8df340 100644
--- a/functional_tests/test_v2_settings_and_workspace_tags.py
+++ b/functional_tests/test_v2_settings_and_workspace_tags.py
@@ -44,7 +44,14 @@ def _allowed_keys():
users = _read(APP_DIR / "route_backend_users.py")
block = re.search(r"allowed_keys = \{(.*?)\}", users, re.DOTALL)
assert block, "Could not find allowed_keys in route_backend_users.py"
- return set(re.findall(r"['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]", block.group(1)))
+ keys = set(re.findall(r"['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]", block.group(1)))
+ # Some keys are listed through a shared constant rather than a literal.
+ if "LATEST_FEATURES_HIDDEN_VERSION_SETTING" in block.group(1):
+ nav = _read(APP_DIR / "functions_latest_features_nav.py")
+ constant = re.search(r'LATEST_FEATURES_HIDDEN_VERSION_SETTING = "([^"]+)"', nav)
+ assert constant, "Could not resolve LATEST_FEATURES_HIDDEN_VERSION_SETTING"
+ keys.add(constant.group(1))
+ return keys
def _writable_keys():
@@ -183,10 +190,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"
@@ -219,17 +232,24 @@ 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"
)
# Settings V2 does not act on are deliberately not offered.
- for absent in ("sidebarToggleStyle", "showTutorialButtons"):
- assert absent not in preferences, (
- f"{absent!r} drives a classic-interface surface with no V2 equivalent; offering "
- "it here would change the other interface with no visible effect in this one"
- )
+ assert "sidebarToggleStyle" not in preferences, (
+ "'sidebarToggleStyle' drives a classic-interface surface with no V2 equivalent; offering "
+ "it here would change the other interface with no visible effect in this one"
+ )
+
+ # The tour switch is offered now that V2 has guided tours that honour it.
+ guidance = _read(V2_SRC / "components" / "settings" / "GuidanceCards.tsx")
+ assert "showTutorialButtons" in guidance and "TutorialsCard" in preferences, (
+ "The tour master switch must be offered alongside the V2 tours it controls"
+ )
print("Preference honouring test passed!")
return True
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..af80d2fe9
--- /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", " " in source
+ assert " " in source
+ # Retention moved into Memory and data and is still gated by its admin flag.
+ assert "enabled('enable_retention_policy_personal') && ", 1)[0]
+ assert "enabled('enable_fact_memory_plugin')" not in memory_group
+
+
+def test_fact_memory_bench_uses_the_profile_routes():
+ source = _read(FACT_MEMORY_TSX)
+ assert "api.get('/api/profile/fact-memory'" in source
+ assert "api.post<{ fact?: FactMemoryItem }>('/api/profile/fact-memory'" in source
+ assert "api.put<{ fact?: FactMemoryItem }>(" in source
+ assert "api.delete(`/api/profile/fact-memory/${encodeURIComponent(selected.id)}`)" in source
+ assert "memory_type: newType" in source
+ assert "memory_type: draftType" in source
+ # Classic wording for the two kinds of memory, and the admin badge.
+ assert "Instruction: always apply to future responses" in source
+ assert "Fact: recall only when relevant" in source
+ assert "Enabled by admin" in source and "Disabled by admin" in source
+
+
+def test_fact_memory_bench_has_search_filter_and_confirmed_delete():
+ source = _read(FACT_MEMORY_TSX)
+ assert 'type="search"' in source
+ assert 'All types ' in source
+ assert " setConfirmDelete(true)}" in source
+ assert "onConfirm={() => void remove()}" in source
+
+
+def test_fact_memory_routes_match_the_bench():
+ source = _read(PROFILE_ROUTE)
+ for route, method in (
+ ("'/api/profile/fact-memory'", "GET"),
+ ("'/api/profile/fact-memory'", "POST"),
+ ("'/api/profile/fact-memory/'", "PUT"),
+ ("'/api/profile/fact-memory/'", "DELETE"),
+ ):
+ assert f"@bp.route({route}, methods=['{method}'])" in source
+ assert "'enabled': bool(settings.get('enable_fact_memory_plugin', False))" in source
+
+
+def test_every_m365_write_the_cards_call_validates_csrf():
+ routes = _route_functions(M365_ROUTE)
+ writes = [
+ ("/api/m365/preferences", "PATCH"),
+ ("/api/m365/sources//revoke", "POST"),
+ ("/api/m365/chat/connection/connect", "POST"),
+ ("/api/m365/connections/connect", "POST"),
+ ("/api/m365/connections/disconnect", "POST"),
+ ("/api/m365/bindings//revoke", "POST"),
+ ]
+ for key in writes:
+ assert key in routes, f"Missing M365 route {key}"
+ assert routes[key], f"{key} no longer validates the M365 CSRF token"
+ for key in (
+ ("/api/m365/preferences", "GET"),
+ ("/api/m365/chat/connection", "GET"),
+ ("/api/m365/connections", "GET"),
+ ("/api/m365/bindings", "GET"),
+ ):
+ assert key in routes, f"Missing M365 route {key}"
+
+
+def test_m365_cards_send_and_refresh_the_csrf_token():
+ source = _read(M365_CARDS_TSX)
+ assert "'X-M365-CSRF-Token': csrfToken" in source
+ assert "error.status === 403 && code === 'm365_csrf_invalid'" in source
+ assert "m365Request(path, options, true)" in source
+ for path in (
+ "'/api/m365/preferences'",
+ "'/api/m365/chat/connection'",
+ "'/api/m365/connections'",
+ "'/api/m365/connections/connect'",
+ "'/api/m365/connections/disconnect'",
+ "`/api/m365/sources/${encodeURIComponent(source)}/revoke`",
+ "`/api/m365/bindings/${encodeURIComponent(binding.id)}/revoke`",
+ "`/api/m365/bindings?${query}`",
+ ):
+ assert path in source, f"M365 cards no longer call {path}"
+
+
+def test_m365_cards_mirror_classic_choices():
+ source = _read(M365_CARDS_TSX)
+ for label in (
+ "Ask before sharing",
+ "Prefer this request only",
+ "Prefer today",
+ "Prefer always, until revoked",
+ "Ask before deeper analysis",
+ "Always allow deeper analysis within service limits",
+ "Use a faster answer with coverage limitations",
+ ):
+ assert label in source, f"Missing classic M365 choice: {label}"
+ assert "const ANALYSIS_SOURCES: readonly AnalysisSource[] = ['onedrive', 'spo'];" in source
+ assert "Intl.DateTimeFormat().resolvedOptions().timeZone" in source
+
+
+def test_m365_revocations_are_confirmed_and_redirects_validated():
+ source = _read(M365_CARDS_TSX)
+ # Every write except saving preferences and starting a connection is a revocation that
+ # goes through the shared confirmation dialog.
+ for path in ("/revoke`", "'/api/m365/connections/disconnect'"):
+ for match in re.finditer(re.escape(path), source):
+ preceding = source[max(0, match.start() - 900):match.start()]
+ assert "onRevoke({" in preceding, f"{path} is not behind the confirmation dialog"
+ assert "