Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions application/single_app/route_backend_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions application/single_app/route_backend_retention_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,8 @@
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'

Check warning on line 415 in application/single_app/route_backend_retention_policy.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
document_retention_days (str|int): Number of days, 'none' or 'default'
"""
try:
user_id = get_current_user_id()
Expand All @@ -426,6 +426,8 @@
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'

Check warning on line 430 in application/single_app/route_backend_retention_policy.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
else:
try:
days = int(conv_retention)
Expand All @@ -451,6 +453,8 @@
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)
Expand Down
23 changes: 22 additions & 1 deletion application/single_app/route_backend_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -578,7 +583,7 @@ def user_settings():
'chatCompletionAudioEnabled', 'chatCompletionAudioMuted',
'chatCompletionAudioSound', 'chatCompletionAudioVolume',
# Tutorial visibility settings
'showTutorialButtons',
'showTutorialButtons', 'tutorialVisibility',
# Desktop conversation notification settings
'desktopNotificationsEnabled',
'recentCollaborators',
Expand Down Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions application/single_app/route_backend_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -400,7 +401,7 @@
# 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 "
Expand All @@ -427,6 +428,41 @@
"force_menu": bool(raw_settings.get("external_links_force_menu")),
"items": external_links,
},
"latest_features": _build_latest_features_nav(raw_settings, user_roles),

Check warning on line 431 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
}


def _build_latest_features_nav(raw_settings, user_roles):

Check warning on line 435 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
"""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

Check warning on line 438 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
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

Check warning on line 441 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
unhiding take effect without a reload.
"""
may_see_support = any(role in ("Admin", "User") for role in user_roles or [])

Check warning on line 444 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
available = False
if (
may_see_support
and raw_settings.get("enable_support_menu")
and raw_settings.get("enable_support_latest_features", True)
):
try:

Check warning on line 451 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
available = bool(has_visible_support_latest_features(raw_settings))
except Exception as exc:

Check warning on line 453 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
log_event(

Check warning on line 454 in application/single_app/route_backend_v2.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
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"),
}


Expand Down
7 changes: 5 additions & 2 deletions application/v2_ui/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1555,7 +1555,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
</div>
)}

<div className="glass glass-edge relative rounded-2xl p-2">
<div className="glass glass-edge relative rounded-2xl p-2" data-tour="composer-input">
{savedAnalysis && (
<div role="status" aria-live="polite" className="mb-2 flex items-start gap-2 rounded-xl bg-surface-2 px-3 py-2">
<span className="min-w-0 flex-1 text-xs text-text-2">
Expand Down Expand Up @@ -1713,7 +1713,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
</div>
)}

<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1" data-tour="composer-tools">
{orchestrationAvailable && (
<ToolToggle
active={orchestrating}
Expand Down Expand Up @@ -1975,6 +1975,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
<button
type="button"
onClick={() => fileInputRef.current?.click()}
data-tour="composer-attach"
disabled={
!canPost ||
!gating.showFileUpload ||
Expand All @@ -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"
>
<Square size={15} className="fill-current" />
Expand All @@ -2008,6 +2010,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
<button
type="button"
onClick={() => submit()}
data-tour="composer-send"
disabled={(!text.trim() && !attachedPrompt && !hasMentionChips) || !canPost || uploadsBlocked || workflowResultOpening || (orchestrating && orchestrationBlocked)}
aria-label={
shared && !streaming
Expand Down
1 change: 1 addition & 0 deletions application/v2_ui/src/components/chat/ConversationRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export function ConversationRail({

return (
<div
data-tour="conversation-list"
className={clsx(
'relative mt-4 border-t border-edge',
// While a search is narrowing the list, the section keeps at least the rail's
Expand Down
63 changes: 16 additions & 47 deletions application/v2_ui/src/components/chat/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
} from 'lucide-react';
import { useChatStore } from '../../stores/chatStore';
import { useCollaborationStore } from '../../stores/collaborationStore';
import { useUserSettingsStore } from '../../stores/userSettingsStore';
import { useBootstrapStore } from '../../stores/bootstrapStore';
import { toast } from '../../stores/toastStore';
import { ApiError } from '../../lib/apiClient';
Expand All @@ -55,7 +54,7 @@ import {
type MessageExportFormat,
} from '../../lib/endpoints';
import { buildMessageVisualAssets } from '../../lib/exportVisuals';
import { synthesizeSpeech } from '../../lib/voice';
import { speak, stopSpeech, useSpeechState } from '../../lib/speechPlayback';
import { suggestPromptName } from '../../lib/promptSlash';
import { createPrompt } from '../../lib/workspaceApi';
import {
Expand Down Expand Up @@ -97,60 +96,30 @@ export function IconButton({
);
}

/** Play a message aloud via the speech endpoint. */
/**
* Play a message aloud via the speech endpoint.
*
* Playback lives in the shared reader (lib/speechPlayback), so only one message is read at a
* time and a message auto-play started shows here as playing and can be stopped here.
*/
function SpeakButton({ message }: { message: ChatMessage }) {
const [state, setState] = useState<'idle' | 'loading' | 'playing'>('idle');
const audioRef = useRef<HTMLAudioElement | null>(null);
const urlRef = useRef<string | null>(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 (
<IconButton
label={state === 'playing' ? 'Stop reading' : 'Read aloud'}
onClick={() => void toggle()}
onClick={toggle}
active={state === 'playing'}
disabled={state === 'loading'}
>
Expand Down
7 changes: 5 additions & 2 deletions application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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"
>
<div className={clsx('mx-auto w-full space-y-4', chatWidthClass(chatWidth))}>
{messagesLoading && (
Expand Down Expand Up @@ -1674,9 +1677,9 @@ export function MessageList() {
<TriangleAlert size={16} className="mt-0.5 shrink-0" />
<div className="min-w-0 space-y-2 break-words">
<p>{streamError}</p>
{streamAuthUrl ? (
{safeStreamAuthUrl ? (
<>
<a href={streamAuthUrl} target="_blank" rel="noopener noreferrer"
<a href={safeStreamAuthUrl} target="_blank" rel="noopener noreferrer"
className="inline-block text-accent underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
Sign in or grant Foundry access
</a>
Expand Down
Loading
Loading