+
(
icon={copied ? : }
/>
- {canShowEditAction && (
-
- }
- />
-
+ {canShowActionMenu && (
+ <>
+
+ {
+ event.stopPropagation();
+ setActionMenuOpen(open => !open);
+ }}
+ aria-label={t('message.editAndRollback')}
+ aria-haspopup="menu"
+ aria-expanded={actionMenuOpen}
+ aria-controls={actionMenuOpen ? actionMenuId : undefined}
+ icon={rollbackPending ? (
+
+ ) : }
+ />
+
+
+
+ >
)}
{isFailed ? (
@@ -776,20 +831,6 @@ export const UserMessageItem = React.memo(
icon={}
/>
- ) : canShowRollbackAction && !steeringStatus && !hostQueueStatus ? (
-
-
- ) : (
-
- )}
- />
-
) : null}
)}
diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts
index 0604c10664..a2c29075e4 100644
--- a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts
+++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts
@@ -53,7 +53,7 @@ describe('UserMessageItem metadata visibility', () => {
expect(stylesheet).not.toContain('min-height: 5.5rem;');
});
- it('reveals the copy, edit, and rollback actions as one hover or focus cluster', () => {
+ it('reveals copy and the edit/rollback menu on hover, focus, or an open menu', () => {
const stylesheet = readFileSync(
fileURLToPath(new URL('./UserMessageItem.scss', import.meta.url)),
'utf8',
@@ -66,14 +66,14 @@ describe('UserMessageItem metadata visibility', () => {
expect(actions).not.toContain('visibility: hidden;');
expect(shell).not.toContain('.user-message-item__actions');
expect(stylesheet).toContain('.user-message-item-shell:hover .user-message-item__actions,');
+ expect(stylesheet).toContain('.user-message-item__actions[data-menu-open],');
expect(stylesheet).toContain('.user-message-item-shell:focus-within .user-message-item__actions {\n opacity: 1;\n pointer-events: auto;');
expect(stylesheet).toContain([
'.user-message-item__copy-btn,',
- '.user-message-item__edit-btn,',
- '.user-message-item__rollback-btn {',
+ '.user-message-item__edit-rollback-btn {',
].join('\n'));
- expect(stylesheet).not.toContain('.user-message-item__edit-btn {\n opacity: 1;');
+ expect(stylesheet).not.toContain('.user-message-item__edit-rollback-btn {\n opacity: 1;');
});
it('reveals the timestamp beside the actions without shifting the action cluster', () => {
diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts
index 1b97db6a4e..1897a407f0 100644
--- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts
+++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts
@@ -71,15 +71,6 @@ describe('isPeerLocalOnlyCommand', () => {
expect(isPeerLocalOnlyCommand('i18n_set_config')).toBe(true);
});
- it('keeps announcement scheduler and state on the controller device', () => {
- expect(isPeerLocalOnlyCommand('get_pending_announcements')).toBe(true);
- expect(isPeerLocalOnlyCommand('get_announcement_tips')).toBe(true);
- expect(isPeerLocalOnlyCommand('mark_announcement_seen')).toBe(true);
- expect(isPeerLocalOnlyCommand('dismiss_announcement')).toBe(true);
- expect(isPeerLocalOnlyCommand('never_show_announcement')).toBe(true);
- expect(isPeerLocalOnlyCommand('trigger_announcement')).toBe(true);
- });
-
it('keeps companion-pet import and preview on the controller device', () => {
expect(isPeerLocalOnlyCommand('list_agent_companion_pets')).toBe(true);
expect(isPeerLocalOnlyCommand('import_agent_companion_pet_package')).toBe(true);
@@ -218,14 +209,6 @@ describe('peerInvokePriorityFor', () => {
expect(isPeerRetryableReadCommand('respond_permission')).toBe(false);
});
- it('does not retry side-effecting announcement get_* commands', () => {
- // These run the scheduler (mutate app_open_count + persist) and must never
- // be auto-retried by the peer read path, where retries would multiply the
- // side effect.
- expect(isPeerRetryableReadCommand('get_pending_announcements')).toBe(false);
- expect(isPeerRetryableReadCommand('get_announcement_tips')).toBe(false);
- });
-
it('retries Browser Control / Computer Use status reads but not their mutations', () => {
// The pure reads start with `browser_`/`computer_`, not `get_`/`read_`/
// `list_`, so they are listed explicitly; the side-effecting commands in
diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
index 3f52c60f55..bac652c70e 100644
--- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
+++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts
@@ -148,22 +148,7 @@ export function isPeerLocalOnlyCommand(command: string): boolean {
return LOCAL_ONLY_COMMANDS.has(command);
}
-/**
- * `get_*` commands that run the announcement scheduler (mutate app_open_count +
- * persist state). They are NOT side-effect-free reads and must never be
- * auto-retried by the peer read path, where a retry would multiply the side
- * effect. They are also LOCAL_ONLY, but this guard keeps the contract explicit
- * if ownership ever moves back to the peer. See scheduler.rs run().
- */
-const SIDE_EFFECTING_GET_COMMANDS = new Set([
- 'get_pending_announcements',
- 'get_announcement_tips',
-]);
-
export function isPeerRetryableReadCommand(command: string): boolean {
- if (SIDE_EFFECTING_GET_COMMANDS.has(command)) {
- return false;
- }
return RETRYABLE_READ_COMMANDS.has(command) ||
command.startsWith('read_') ||
command.startsWith('list_') ||
diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts
index ec33769230..f7016e8b57 100644
--- a/src/web-ui/src/infrastructure/api/generated/productControl.ts
+++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts
@@ -1,5 +1,5 @@
// Generated by scripts/generate-interactive-capabilities.mjs; do not edit.
-export const PRODUCT_CONTROL_GRAPH_DIGEST = "e05dbc222e1267523832f82d6392701ab011c9d8d5ad1582dbe407904dc80d3d" as const;
+export const PRODUCT_CONTROL_GRAPH_DIGEST = "09837a3f9f09ecf1d1b2e3a60059824d6e8afa979c0b022cef05daa63221c04b" as const;
export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics";
@@ -26,7 +26,7 @@ export interface ProductControlOptionIdsByCapability {
"feature.pages": never;
"feature.voice-input": never;
"feature.desktop-pet": never;
- "setting.application.general": "launch-at-login" | "prevent-sleep" | "auto-update" | "close-button-behavior" | "completion-notification" | "permission-notification" | "startup-tips";
+ "setting.application.general": "launch-at-login" | "prevent-sleep" | "auto-update" | "close-button-behavior" | "completion-notification" | "permission-notification";
"setting.application.appearance": "theme" | "language";
"setting.application.pet": "enabled";
"setting.application.input": "auto-show-selection-toolbar" | "voice-enabled" | "voice-language" | "max-recording-seconds";
diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
index 457f29b74a..a7d3df7514 100644
--- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
+++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts
@@ -1,6 +1,6 @@
// Generated by scripts/generate-interactive-capabilities.mjs; do not edit.
// Source: openbitfun_product_domains::remote_surface (Product Operation Registry).
-export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:7a6e4c114b970186" as const;
+export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:fd9e646a3f645e19" as const;
/**
* Registered Tauri commands the Peer Device controller keeps on the controller
@@ -52,7 +52,6 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet
= new Set([
"debug_devtools_available",
"debug_open_devtools",
"delete_agent_companion_pet_package",
- "dismiss_announcement",
"dispatch_answer",
"dispatch_append",
"dispatch_cancel",
@@ -75,11 +74,9 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"frontend_update_candidate_failed",
"frontend_update_candidate_ready",
"generate_insights",
- "get_announcement_tips",
"get_frontend_update_status",
"get_latest_insights",
"get_local_models_dev_catalogs",
- "get_pending_announcements",
"get_pending_update",
"get_prevent_sleep_enabled",
"has_insights_data",
@@ -99,12 +96,10 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"list_agent_companion_pets",
"load_insights_report",
"local_file_download",
- "mark_announcement_seen",
"mark_openbitfun_control_surface_ready",
"mark_openbitfun_control_surface_unready",
"market_image_load",
"minimize_to_tray",
- "never_show_announcement",
"peer_control_attach",
"peer_control_detach",
"peer_controller_set_active",
@@ -158,7 +153,6 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([
"speech_verify_model",
"startup_window_control",
"toggle_main_window_fullscreen",
- "trigger_announcement",
]);
/** Every capability id a peer host may advertise in `peer_mode_ping`. */
diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts
index 6fe13b9347..3f58fc227d 100644
--- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts
+++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts
@@ -51,7 +51,6 @@ import { fontPreferenceAppearanceDescriptor } from '@/infrastructure/font-prefer
import { languageSelectorAppearanceDescriptor } from '@/infrastructure/i18n/appearance';
import { peerDeviceAppearanceDescriptor } from '@/infrastructure/peer-device/appearance';
import { updateAppearanceDescriptor } from '@/infrastructure/update/appearance';
-import { announcementAppearanceDescriptor } from '@/shared/announcement-system/appearance';
import { contextMenuAppearanceDescriptor } from '@/shared/context-menu-system/appearance';
import { contextListAppearanceDescriptor } from '@/shared/context-system/appearance';
import { notificationAppearanceDescriptor } from '@/shared/notification-system/appearance';
@@ -312,7 +311,6 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry {
.registerComponent(sessionNavigationAppearanceDescriptor)
.registerComponent(harnessProfileStepAppearanceDescriptor)
.registerComponent(subagentAvatarAppearanceDescriptor)
- .registerComponent(announcementAppearanceDescriptor)
.registerComponent(contextMenuAppearanceDescriptor)
.registerComponent(contextListAppearanceDescriptor)
.registerComponent(notificationAppearanceDescriptor)
diff --git a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts
index d195fd4b15..3d17ef4f85 100644
--- a/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts
+++ b/src/web-ui/src/infrastructure/appearance/runtime/AppearanceOverlayHost.test.ts
@@ -51,9 +51,7 @@ describe('appearance overlay host', () => {
expect(systemTokens.layer.overlayHost.$value).toBeGreaterThan(systemTokens.layer.overlay.$value);
expect(readSource('src/shared/notification-system/components/NotificationContainer.tsx')).toContain('');
expect(readSource('src/shared/notification-system/components/NotificationContainer.tsx')).toContain(' (bottom-left)
- * - (standard centre-screen presentation)
- * - (release-letter presentation)
- *
- * ─── Debug Mode ───────────────────────────────────────────────────────────────
- * In development (`import.meta.env.DEV`), press **Ctrl+Shift+Alt+D** to
- * inject all cards listed in `DEBUG_CARD_IDS` into the queue, bypassing
- * backend filter logic. This lets you preview the full UI flow without
- * clearing persisted state.
- */
-const AnnouncementProvider: React.FC<{ ready: boolean }> = ({ ready }) => {
- const { loadQueue, markInitialised, initialised, forceShowCards } = useAnnouncementStore();
-
- // ── Normal load path ─────────────────────────────────────────────────────
- useEffect(() => {
- if (!ready || initialised) return;
- let cancelled = false;
-
- const load = async () => {
- try {
- if (ENV_PREVIEW_CARD_ID) {
- const previewCard = await announcementService.triggerCard(ENV_PREVIEW_CARD_ID);
- if (cancelled) return;
- if (!previewCard) {
- log.warn('Announcement preview card was not found', { id: ENV_PREVIEW_CARD_ID });
- return;
- }
- log.debug('Force-showing announcement preview card', { id: previewCard.id });
- forceShowCards([previewCard]);
- return;
- }
-
- const cards = await announcementService.getPendingAnnouncements();
- if (cancelled) {
- return;
- }
- const tipsEnabled = await configAPI.getConfig('app.notifications.enable_startup_tips') !== false;
- if (cancelled) {
- return;
- }
- const visibleCards = tipsEnabled ? cards : cards.filter((card) => card.card_type !== 'tip');
- if (visibleCards.length > 0) {
- log.debug('Announcement cards loaded', { count: visibleCards.length });
- loadQueue(visibleCards);
- }
- } catch (e) {
- log.error('Failed to load announcement cards', e);
- } finally {
- if (!cancelled) {
- markInitialised();
- }
- }
- };
-
- const cancelStartupSchedule = scheduleAfterStartupPaint(() => {
- void load();
- }, {
- frameCount: 2,
- onError: error => {
- log.error('Failed to schedule announcement load after startup', error);
- },
- });
-
- return () => {
- cancelled = true;
- cancelStartupSchedule();
- };
- }, [forceShowCards, initialised, loadQueue, markInitialised, ready]);
-
- // ── Debug trigger ─────────────────────────────────────────────────────────
- const handleDebugTrigger = useCallback(async () => {
- log.debug('[DEBUG] Triggering announcement preview', { ids: DEBUG_CARD_IDS });
- try {
- const cards = await announcementService.debugTriggerCards(DEBUG_CARD_IDS);
- if (cards.length === 0) {
- log.warn('[DEBUG] No cards resolved for debug trigger. Check DEBUG_CARD_IDS.');
- return;
- }
- log.debug('[DEBUG] Force-showing cards', { count: cards.length, ids: cards.map((c) => c.id) });
- forceShowCards(cards);
- } catch (e) {
- log.error('[DEBUG] Failed to trigger debug cards', e);
- }
- }, [forceShowCards]);
-
- useEffect(() => {
- if (!import.meta.env.DEV || !ready) return;
-
- const onKeyDown = (e: KeyboardEvent) => {
- // Ctrl+Shift+Alt+D (all platforms)
- if (e.ctrlKey && e.shiftKey && e.altKey && e.key === 'D') {
- e.preventDefault();
- handleDebugTrigger();
- }
- };
-
- window.addEventListener('keydown', onKeyDown);
- return () => window.removeEventListener('keydown', onKeyDown);
- }, [handleDebugTrigger, ready]);
-
- if (!ready) return null;
-
- return (
- <>
-
-
-
- >
- );
-};
-
-export default AnnouncementProvider;
diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx
deleted file mode 100644
index 662a60c6ac..0000000000
--- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { Button, Icon, IconButton, OverflowText, useHasModalOverlay } from '@openbitfun/ui';
-import type { AnnouncementCard } from '../types';
-import { useAnnouncementStore } from '../store/announcementStore';
-import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n';
-
-interface Props {
- card: AnnouncementCard;
-}
-
-const ANNOUNCEMENT_TOAST_EXIT_MS = 220;
-
-/**
- * Bottom-left toast: compact fixed-width card.
- * Layout (top → bottom): title row (+ close) → description → action buttons.
- */
-const AnnouncementToastItem: React.FC = ({ card }) => {
- const { t } = useAnnouncementI18n();
- const { openModalFor, dismissToast } = useAnnouncementStore();
- const [exiting, setExiting] = useState(false);
- const modalOpen = useHasModalOverlay();
- const autoDismissTimer = useRef | null>(null);
- const exitTimer = useRef | null>(null);
-
- const { toast, card_type, modal } = card;
- const hasModal = card_type !== 'tip' && modal !== null;
- const autoDismissMs = toast.auto_dismiss_ms;
- const remainingMs = useRef(autoDismissMs ?? 0);
-
- const resolve = (key: string) => (key.startsWith('announcements.') ? t(key) : key);
-
- function triggerExit(callback: () => void) {
- if (exiting) return;
- if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current);
- setExiting(true);
- exitTimer.current = setTimeout(callback, ANNOUNCEMENT_TOAST_EXIT_MS);
- }
-
- function handleDismiss() {
- triggerExit(() => dismissToast(card));
- }
-
- function handleAction() {
- if (hasModal) {
- triggerExit(() => openModalFor(card));
- } else {
- handleDismiss();
- }
- }
-
- useEffect(() => {
- if (exiting || modalOpen || remainingMs.current <= 0) return;
- const startedAt = Date.now();
- autoDismissTimer.current = setTimeout(handleDismiss, remainingMs.current);
- return () => {
- if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current);
- remainingMs.current = Math.max(0, remainingMs.current - (Date.now() - startedAt));
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [card.id, exiting, modalOpen]);
-
- useEffect(() => () => {
- if (exitTimer.current) clearTimeout(exitTimer.current);
- }, []);
-
- const actionLabel =
- resolve(toast.action_label) ||
- (hasModal ? t('announcements.common.learn_more') : t('announcements.common.got_it'));
-
- return (
-
- {/* Row 1: title + close with optional countdown ring */}
-
-
{resolve(toast.title)}
- {toast.dismissible && (
-
- {autoDismissMs != null && autoDismissMs > 0 && (
-
- )}
- }
- onClick={handleDismiss}
- aria-label={t('announcements.common.close')}
- />
-
- )}
-
-
- {/* Row 2: description */}
-
{resolve(toast.description)}
-
- {/* Row 3: action buttons */}
-
-
-
-
-
- );
-};
-
-export default AnnouncementToastItem;
diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx
deleted file mode 100644
index 478f1a2bce..0000000000
--- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastStack.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import { Portal } from '@openbitfun/ui';
-import { useAnnouncementStore } from '../store/announcementStore';
-import AnnouncementToastItem from './AnnouncementToastItem';
-import '../styles/AnnouncementToast.scss';
-
-/**
- * Fixed bottom-left announcement stack.
- *
- * Shows the active toast on top. When more cards are queued, up to two ghost
- * layers peek out beneath it to hint that more notifications are waiting —
- * no text badge is shown.
- */
-const AnnouncementToastStack: React.FC = () => {
- const { activeToast, toastVisible, queue } = useAnnouncementStore();
-
- if (!activeToast || !toastVisible) return null;
-
- const ghostCount = Math.min(queue.length, 2);
-
- return (
-
-
-
- {/* Ghost layers: rendered before active card = lower in DOM = behind */}
- {ghostCount >= 2 && (
-
- )}
- {ghostCount >= 1 && (
-
- )}
-
-
-
-
- );
-};
-
-export default AnnouncementToastStack;
diff --git a/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx b/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx
deleted file mode 100644
index 47194325c3..0000000000
--- a/src/web-ui/src/shared/announcement-system/components/FeatureModal.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
-import { Button, Icon, IconButton, Portal, ScrollArea } from '@openbitfun/ui';
-import { useAnnouncementStore } from '../store/announcementStore';
-import FeatureModalPage from './FeatureModalPage';
-import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n';
-import '../styles/FeatureModal.scss';
-import type { ModalConfig } from '../types';
-
-const FEATURE_MODAL_EXIT_MS = 180;
-const FEATURE_MODAL_PAGE_MS = 180;
-
-interface FeatureModalPagesProps {
- pages: NonNullable;
- currentPage: number;
-}
-
-const FeatureModalPages: React.FC = ({ pages, currentPage }) => {
- const [displayedPage, setDisplayedPage] = useState(currentPage);
- const [leavingPage, setLeavingPage] = useState(null);
- const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
- const settleTimerRef = useRef(null);
-
- useLayoutEffect(() => {
- if (currentPage === displayedPage) return;
- if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
- setDirection(currentPage > displayedPage ? 'forward' : 'backward');
- setLeavingPage(displayedPage);
- setDisplayedPage(currentPage);
- settleTimerRef.current = window.setTimeout(() => {
- settleTimerRef.current = null;
- setLeavingPage(null);
- }, FEATURE_MODAL_PAGE_MS);
- }, [currentPage, displayedPage]);
-
- useEffect(() => () => {
- if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
- }, []);
-
- const renderPage = (pageIndex: number, state: 'active' | 'leaving') => {
- const page = pages[pageIndex];
- if (!page) return null;
- return (
-
-
-
- );
- };
-
- return (
- <>
- {leavingPage !== null && leavingPage !== displayedPage
- ? renderPage(leavingPage, 'leaving')
- : null}
- {renderPage(displayedPage, 'active')}
- >
- );
-};
-
-/**
- * Centre-screen feature demo modal.
- *
- * - Multi-page with dot-indicator navigation.
- * - Each page can carry a media asset (Lottie, video, image, GIF).
- * - Backdrop click closes the modal when `closable` is true.
- * - Supports a "Don't show again" completion action.
- */
-const FeatureModal: React.FC = () => {
- const { t } = useAnnouncementI18n();
- const {
- openModal,
- modalVisible,
- currentPage,
- setPage,
- closeModal,
- markModalPresented,
- } = useAnnouncementStore();
-
- const [exiting, setExiting] = React.useState(false);
- const backdropRef = useRef(null);
- const closeTimerRef = useRef(null);
-
- // When modalVisible becomes false trigger the exit animation.
- useEffect(() => {
- if (!modalVisible) setExiting(false);
- }, [modalVisible]);
-
- useEffect(() => () => {
- if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current);
- }, []);
-
- useEffect(() => {
- if (
- openModal
- && modalVisible
- && openModal.modal?.presentation !== 'release_letter'
- && backdropRef.current
- ) {
- markModalPresented(openModal);
- }
- }, [markModalPresented, modalVisible, openModal]);
-
- if (
- !openModal
- || !modalVisible
- || openModal.modal?.presentation === 'release_letter'
- ) return null;
-
- const modal: ModalConfig = openModal.modal!;
- const pages = modal.pages ?? [];
- const isFirst = currentPage === 0;
- const isLast = currentPage === pages.length - 1;
-
- function triggerClose(neverShow = false) {
- if (exiting) return;
- setExiting(true);
- closeTimerRef.current = window.setTimeout(() => {
- closeTimerRef.current = null;
- closeModal(neverShow);
- }, FEATURE_MODAL_EXIT_MS);
- }
-
- function handleBackdropClick(e: React.MouseEvent) {
- if (e.target === backdropRef.current && modal.closable) {
- triggerClose();
- }
- }
-
- const sizeClass = `feature-modal--${modal.size ?? 'lg'}`;
-
- return (
- triggerClose()} dismissOnEscape={Boolean(modal.closable)}>
-
-
- {/* Close button */}
- {modal.closable && (
-
}
- size="md"
- data-openbitfun-component="announcement"
- data-openbitfun-part="modalClose"
- onClick={() => triggerClose()}
- aria-label={t('announcements.common.close')}
- />
- )}
-
- {/* Page viewport */}
-
-
-
-
- {/* Footer navigation */}
-
- {/* Dot indicators */}
-
- {pages.map((_, i) => (
-
-
- {/* Navigation buttons */}
-
- {modal.completion_action === 'never_show_again' && isLast && (
-
- )}
- {!isFirst && (
-
- )}
- {!isLast ? (
-
- ) : (
-
- )}
-
-
-
-
-
- );
-};
-
-export default FeatureModal;
diff --git a/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx b/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx
deleted file mode 100644
index 88fbce0070..0000000000
--- a/src/web-ui/src/shared/announcement-system/components/FeatureModalPage.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from 'react';
-import type { ModalPage } from '../types';
-import MediaRenderer from './MediaRenderer';
-import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n';
-
-interface Props {
- page: ModalPage;
- active: boolean;
-}
-
-/**
- * Renders a single page inside the FeatureModal.
- *
- * Handles i18n key resolution and Markdown body rendering (simple HTML via
- * dangerouslySetInnerHTML – body content is authored internally so this is safe).
- */
-const FeatureModalPage: React.FC = ({ page, active }) => {
- const { t } = useAnnouncementI18n();
-
- const resolve = (key: string) => (key.startsWith('announcements.') ? t(key) : key);
-
- /**
- * Lightweight Markdown → HTML for announcement body content.
- *
- * Supported syntax:
- * - **bold**
- * - `inline code`
- * - Pipe tables (|---|)
- * - Blank-line paragraph breaks
- * - Single line-break →
within a paragraph
- */
- function renderBody(raw: string): string {
- const resolved = resolve(raw);
-
- // Split into blocks on double newline.
- const blocks = resolved.split(/\n{2,}/);
-
- const htmlBlocks = blocks.map((block) => {
- const trimmed = block.trim();
-
- // ── Table detection: block must have at least 3 lines where one is a separator row ──
- const lines = trimmed.split('\n');
- const isSeparatorRow = (l: string) => /^\|[\s|:-]+\|$/.test(l.trim());
- if (lines.length >= 2 && lines.some(isSeparatorRow)) {
- const rows = lines.filter((l) => !isSeparatorRow(l));
- const toTd = (l: string, tag: 'th' | 'td') =>
- l
- .split('|')
- .filter((_, i, a) => i > 0 && i < a.length - 1) // drop leading/trailing empty
- .map((cell) => `<${tag}>${inlineMarkdown(cell.trim())}${tag}>`)
- .join('');
-
- const [header, ...body] = rows;
- return [
- '',
- `${toTd(header, 'th')}
`,
- `${body.map((r) => `${toTd(r, 'td')}
`).join('')}`,
- '
',
- ].join('');
- }
-
- // ── Regular paragraph ──
- const inner = lines
- .map((l) => inlineMarkdown(l))
- .join('
');
- return `${inner}
`;
- });
-
- return htmlBlocks.join('\n');
- }
-
- /** Apply inline Markdown (bold, code) to a single line. */
- function inlineMarkdown(line: string): string {
- return line
- .replace(/\*\*(.+?)\*\*/g, '$1')
- .replace(/`([^`]+)`/g, '$1');
- }
-
- const layoutClass = `feature-modal-page--${page.layout}`;
-
- return (
-
- {page.media && (
-
-
-
- )}
-
-
-
{resolve(page.title)}
-
-
-
-
- );
-};
-
-export default FeatureModalPage;
diff --git a/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx b/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx
deleted file mode 100644
index 62096b19b8..0000000000
--- a/src/web-ui/src/shared/announcement-system/components/MediaRenderer.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import type { MediaConfig } from '../types';
-
-interface Props {
- media: MediaConfig;
- /** Whether this page is currently visible (affects autoplay). */
- active: boolean;
-}
-
-interface LottieProps {
- src: string;
- active: boolean;
-}
-
-const LottieRenderer: React.FC = ({ src, active }) => {
- const [LottieComponent, setLottieComponent] =
- React.useState | null>(null);
- const [loadError, setLoadError] = React.useState(false);
-
- useEffect(() => {
- let cancelled = false;
- const pkg = '@lottiefiles/dotlottie-react';
- import(/* @vite-ignore */ pkg)
- .then((mod: any) => {
- if (!cancelled) setLottieComponent(() => mod.DotLottieReact ?? mod.default);
- })
- .catch(() => {
- if (!cancelled) setLoadError(true);
- });
- return () => { cancelled = true; };
- }, []);
-
- if (loadError) {
- return (
-
-
- Lottie library not available
-
-
- );
- }
-
- if (!LottieComponent) {
- return (
-
- );
- }
-
- return (
-
-
-
- );
-};
-
-/**
- * Renders a media asset inside a modal page.
- *
- * Supported types:
- * - `image` / `gif` →
- * - `video` →