diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 1d2e4b184c..b81d2e3f3d 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -310,11 +310,19 @@ device changes, per-device remote preferences and legacy local migration, stale review launches, overview/tool-card gates, restored panels, and the controller-owned URL opener. They do not establish live macOS-to-Linux Peer Device or native browser behavior. -For Session Usage side-panel loading, report summaries, navigation, and retained session ownership: +For the composer context overview, category highlighting/previews, Session Usage side-panel loading, report summaries, navigation, and retained session ownership: ```bash -pnpm --dir src/web-ui run test:run src/flow_chat/services/usageReportService.test.ts src/flow_chat/services/openSessionContextPreview.test.ts src/flow_chat/components/usage/SessionUsageComponents.test.tsx src/flow_chat/components/usage/ToolDefinitionsPreview.test.tsx src/flow_chat/components/usage/usageReportUtils.test.ts src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx +pnpm --dir src/web-ui run test:run src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx src/flow_chat/services/usageReportService.test.ts src/flow_chat/services/openSessionContextPreview.test.ts src/flow_chat/components/usage/SessionUsageComponents.test.tsx src/flow_chat/components/usage/ToolDefinitionsPreview.test.tsx src/flow_chat/components/usage/usageReportUtils.test.ts src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx ``` These local DOM/store fixtures include simulated SSH report data, Peer surface switches, and the dispatch report source; they do not establish live remote behavior or visual fidelity. + +For canvas split tab placement, layout transitions, and lower-pane tab actions: + +```bash +pnpm --dir src/web-ui run test:run src/app/components/panels/content-canvas/editor-area/EditorArea.test.tsx src/app/components/panels/content-canvas/CanvasToolbar.test.tsx src/app/components/panels/content-canvas/tab-bar/TabBar.test.tsx +``` + +These local DOM/store fixtures do not establish native rendering or live remote behavior. diff --git a/src/web-ui/README.md b/src/web-ui/README.md index aae4ef2335..367edb0871 100644 --- a/src/web-ui/README.md +++ b/src/web-ui/README.md @@ -77,6 +77,21 @@ still wait for the current turn; Detached Dispatch sessions keep appending messages to the target task. In Peer Device Mode, the preference belongs to the active host, as do the other conversation settings. +## MiniApp navigation shortcuts + +In **MiniApps → Installed**, open an app's **More actions** menu and choose +**Add to left navigation**. The same action is available on installed-app detail +pages. Shortcuts appear after **Task Board**, in the order added, and open the +app in a tab. Each shortcut shows only the app icon and name. Its remove button +appears on hover or keyboard focus within the same navigation row. Use that +button or the app's navigation action to remove it without uninstalling the app. + +Shortcuts survive restarts and are saved separately for each selected device on +this controller. Switching devices uses that device's installed catalog; failed +catalog reads retain the saved shortcuts. Apps requiring workspace filesystem +access remain disabled in unsupported remote workspaces. Launching an app that +is already open in a native window uses the existing transfer confirmation. + ## Floating conversations The bottom-right window opens the persistent OpenBitFun control conversation in diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx index c973c797b6..08ab474d1b 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx @@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({ createProject: vi.fn(), pickDirectory: vi.fn(), options: {} as Record, + activeTabId: 'personal-overview', })); const t = (key: string) => key; vi.mock('react-i18next', () => ({ useTranslation: () => ({ t }) })); @@ -34,7 +35,7 @@ vi.mock('@/features/ssh-remote/useWorkspaceSourceOptions', () => ({ })); vi.mock('@/app/hooks/useSceneManager', () => ({ useSceneManager: () => ({ openScene: vi.fn() }) })); vi.mock('@/app/stores/sceneStore', () => ({ - useSceneStore: (select: (value: { activeTabId: string }) => unknown) => select({ activeTabId: 'personal-overview' }), + useSceneStore: (select: (value: { activeTabId: string }) => unknown) => select(state), })); vi.mock('@/app/scenes/miniapps/hooks/useMiniAppCatalogSync', () => ({ useMiniAppCatalogSync: () => {} })); vi.mock('@/app/scenes/profile/nurseryStore', () => ({ useNurseryStore: { getState: vi.fn() } })); @@ -54,6 +55,7 @@ vi.mock('./components/StickySectionHeader', () => ({ default: ({ children }: Rea vi.mock('./components/WorkspaceSessionGroupingToggle', () => ({ default: () => null })); vi.mock('./components/WorkspaceSessionFilterMenu', () => ({ default: () => null })); vi.mock('./sections/workspaces/WorkspaceListSection', () => ({ default: () => null })); +vi.mock('./components/MiniAppShortcuts', () => ({ default: () => })); vi.mock('@/features/ssh-remote', () => ({ useSSHRemoteContext: () => state.remote, SSHConnectionDialog: ({ open, initialTargetType, fixedSource, confirmLabel, onClose, onConnected }: { open: boolean; initialTargetType: string; fixedSource: boolean; confirmLabel: string; onClose: () => void; onConnected: (config: unknown, id: string) => void }) => ( @@ -69,6 +71,7 @@ vi.mock('@/features/ssh-remote', () => ({ import MainNav from './MainNav'; import { useWorkspaceSessionViewStore } from './workspaceSessionView'; +import { useMiniAppNavigationStore } from '@/app/scenes/miniapps/miniAppNavigationStore'; describe('project creation entry points', () => { let root: Root; @@ -106,6 +109,8 @@ describe('project creation entry points', () => { beforeEach(async () => { vi.resetAllMocks(); resetDeviceSurfaceForTest(); + useMiniAppNavigationStore.setState({ pinnedAppIdsBySurface: {} }); + state.activeTabId = 'personal-overview'; state.createProject.mockResolvedValue(true); state.options = {}; for (const source of ['ssh', 'wsl', 'localDocker', 'remoteDocker']) { @@ -133,6 +138,16 @@ describe('project creation entry points', () => { resetDeviceSurfaceForTest(); }); + it('places MiniApp shortcuts immediately after Task Board and gives the selected app its own highlight', async () => { + state.activeTabId = 'miniapp:notes'; + await act(async () => { useMiniAppNavigationStore.getState().setPinned('local', 'notes', true); }); + await render(); + const topActions = host.querySelector('[data-openbitfun-part="topActions"]')!; + expect([...topActions.querySelectorAll('button')].map(button => button.getAttribute('data-testid'))) + .toEqual(['nav-personal-overview', 'nav-todos-btn', 'miniapp-shortcut']); + expect(host.querySelector('[data-testid="nav-personal-overview"]')!.getAttribute('data-openbitfun-state')).toBe(''); + }); + it.each([true, false])('reveals project groups only after successful creation (success=%s)', async succeeded => { useWorkspaceSessionViewStore.getState().setGrouping('all'); state.createProject.mockResolvedValueOnce(succeeded); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 49c346196b..c003366b81 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -28,6 +28,7 @@ import SectionHeader from './components/SectionHeader'; import StickySectionHeader from './components/StickySectionHeader'; import WorkspaceSessionGroupingToggle from './components/WorkspaceSessionGroupingToggle'; import WorkspaceSessionFilterMenu from './components/WorkspaceSessionFilterMenu'; +import { useMiniAppNavigation } from '@/app/scenes/miniapps/miniAppNavigationStore'; import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; import { useSceneStore } from '../../stores/sceneStore'; import { useMiniAppCatalogSync } from '../../scenes/miniapps/hooks/useMiniAppCatalogSync'; @@ -61,6 +62,7 @@ const log = createLogger('MainNav'); const NewProjectDialog = lazyWithRecovery(() => import('../NewProjectDialog').then(module => ({ default: module.NewProjectDialog })) ); +const MiniAppShortcuts = lazyWithRecovery(() => import('./components/MiniAppShortcuts')); const MainNav: React.FC = () => { const sshRemote = useSSHRemoteContext(); @@ -73,9 +75,10 @@ const MainNav: React.FC = () => { const { openScene } = useSceneManager(); const activeTabId = useSceneStore(s => s.activeTabId); + const { pinnedAppIds } = useMiniAppNavigation(); const isOverviewActive = typeof activeTabId === 'string' && ( ['personal-overview', 'assistant', 'profile', 'agents', 'skills', 'miniapps'].includes(activeTabId) - || activeTabId.startsWith('miniapp:') + || (activeTabId.startsWith('miniapp:') && !pinnedAppIds.includes(activeTabId.slice('miniapp:'.length))) ); const { t } = useI18n('common'); const searchShortcutLabel = useSyncExternalStore( @@ -302,6 +305,7 @@ const MainNav: React.FC = () => { {taskBoardLabel} + {pinnedAppIds.length > 0 && } {/* Unified sessions */} diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 273026fad0..2509557248 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -2994,6 +2994,38 @@ $_section-header-height: 22px; } +// The shortcut owns one continuous hover/selection surface, including its action. +.openbitfun-nav-panel__miniapp-shortcut { + gap: 0; + padding-inline-end: var(--openbitfun-space-1); + border-radius: var(--openbitfun-radius-base); + + &:not(.is-active):not([data-disabled='true']):hover { + @include shell-surfaces.navigation-hover-feedback; + } + + &.is-active { + @include shell-surfaces.navigation-selection-feedback; + } + + > .openbitfun-nav-panel__top-action-btn, + > .openbitfun-nav-panel__top-action-btn:is(:hover, :active, .is-active) { + background: transparent; + padding-inline-end: var(--openbitfun-space-1); + } + + .openbitfun-nav-panel__miniapp-shortcut-remove { + opacity: 0; + pointer-events: none; + } + + &:hover .openbitfun-nav-panel__miniapp-shortcut-remove, + &:focus-within .openbitfun-nav-panel__miniapp-shortcut-remove { + opacity: 1; + pointer-events: auto; + } +} + .openbitfun-nav-panel__main-nav-header { padding: 0; } diff --git a/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.test.tsx b/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.test.tsx new file mode 100644 index 0000000000..509ae7f0c5 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.test.tsx @@ -0,0 +1,141 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useMiniAppStore } from '@/app/scenes/miniapps/miniAppStore'; +import { useMiniAppNavigationStore } from '@/app/scenes/miniapps/miniAppNavigationStore'; +import { useMiniAppPresentationStore } from '@/app/scenes/miniapps/miniAppPresentationStore'; +import MiniAppShortcuts from './MiniAppShortcuts'; + +const mocks = vi.hoisted(() => ({ + workspace: null as WorkspaceInfo | null, + local: true, snapshot: vi.fn(), open: vi.fn(), openScene: vi.fn(), error: vi.fn(), + activeTabId: 'miniapp:notes', +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useCurrentWorkspace: () => ({ workspace: mocks.workspace, workspacePath: mocks.workspace?.rootPath }), +})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ + t: (key: string, params?: { name: string }) => params ? `${key}:${params.name}` : key, + currentLanguage: 'en-US', +}) })); +vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ error: mocks.error }) })); +vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: Object.assign( + (select: (state: { activeTabId: string }) => unknown) => select(mocks), + { getState: () => ({ openScene: mocks.openScene }) }, +) })); +vi.mock('@/infrastructure/api/service-api/MiniAppPresentationAPI', () => ({ + hasLocalMiniAppPresentation: () => mocks.local, + miniAppPresentationAPI: { snapshot: mocks.snapshot, open: mocks.open }, +})); + +const app = (id: string, name: string): MiniAppMeta => ({ + id, name, description: '', icon: 'box', permissions: {}, category: 'utilities', tags: [], + version: 1, created_at: 1, updated_at: 1, +}); +const notes = app('notes', 'Notes'); +const timer = app('timer', 'Timer'); +const snapshot = (window = false) => ({ protocolVersion: 1, hostEpoch: 'host', revision: 1, + instances: window ? [{ instanceId: 'notes#1', appId: 'notes', workspacePath: '/original', + primaryView: { kind: 'window', viewId: 'window' }, pendingView: null, contentCapabilities: [] }] : [], +}); +let root: Root; +let host: HTMLDivElement; +const render = () => act(async () => { root.render(); }); +const row = (id: string) => host.querySelector(`[data-miniapp-id="${id}"]`)!; +const pin = (surface: string, id: string) => useMiniAppNavigationStore.getState().setPinned(surface, id, true); + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + resetDeviceSurfaceForTest(); + vi.clearAllMocks(); + mocks.local = true; + mocks.activeTabId = 'miniapp:notes'; + mocks.workspace = { id: 'project', rootPath: '/current', workspaceKind: WorkspaceKind.Normal } as WorkspaceInfo; + mocks.snapshot.mockReset().mockResolvedValue(snapshot()); + mocks.open.mockReset().mockImplementation(async (appId, kind, workspacePath) => ({ + instance: { appId, workspacePath }, view: { kind, viewId: 'tab' }, + })); + useMiniAppNavigationStore.setState({ pinnedAppIdsBySurface: {} }); + useMiniAppStore.getState().setApps([notes, timer]); + useMiniAppPresentationStore.setState({ snapshot: null, tabs: {} }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + host.remove(); + resetDeviceSurfaceForTest(); +}); + +describe('MiniApp navigation shortcuts', () => { + it('uses saved order and current localized metadata, highlights the app, and launches through presentation', async () => { + pin('local', 'timer'); pin('local', 'notes'); + useMiniAppStore.getState().setApps([{ ...notes, i18n: { locales: { 'en-US': { name: 'My Notes' } } } }, timer]); + await render(); + expect([...host.querySelectorAll('[data-miniapp-id]')].map(item => item.textContent)).toEqual(['Timer', 'My Notes']); + expect(row('notes').getAttribute('data-openbitfun-state')).toBe('active'); + await act(async () => { row('notes').click(); }); + expect(mocks.open).toHaveBeenCalledExactlyOnceWith('notes', 'tab', '/current', undefined); + expect(mocks.openScene).toHaveBeenCalledExactlyOnceWith('miniapp:notes'); + }); + + it('removes a shortcut without launching or deleting the app', async () => { + pin('local', 'notes'); + await render(); + await act(async () => { host.querySelector('[aria-label="navigation.removeApp:Notes"]')!.click(); }); + expect(row('notes')).toBeNull(); + expect(useMiniAppStore.getState().apps).toContain(notes); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it('retains preferences when the catalog is empty and updates after a device switch', async () => { + pin('local', 'notes'); pin('peer', 'notes'); + await render(); + expect(row('notes')).not.toBeNull(); + await act(async () => { activateSurface('peer'); }); + expect(host.querySelector('[data-miniapp-id]')).toBeNull(); + await act(async () => { useMiniAppStore.getState().setApps([]); }); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface.peer).toEqual(['notes']); + await act(async () => { useMiniAppStore.getState().setApps([app('notes', 'Peer Notes')]); }); + expect(row('notes').textContent).toBe('Peer Notes'); + mocks.local = false; + await render(); + await act(async () => { row('notes').click(); }); + expect(mocks.openScene).toHaveBeenCalledExactlyOnceWith('miniapp:notes'); + expect(mocks.snapshot).not.toHaveBeenCalled(); + expect(mocks.open).not.toHaveBeenCalled(); + }); + + it('keeps unsupported remote-workspace apps removable without launching them', async () => { + mocks.workspace!.workspaceKind = WorkspaceKind.Remote; + useMiniAppStore.getState().setApps([{ ...notes, permissions: { fs: { read: ['{workspace}'] } } }]); + pin('local', 'notes'); + await render(); + expect(row('notes').disabled).toBe(true); + await act(async () => { row('notes').click(); }); + expect(mocks.open).not.toHaveBeenCalled(); + expect(mocks.openScene).not.toHaveBeenCalled(); + const remove = host.querySelector('[aria-label="navigation.removeApp:Notes"]')!; + expect(remove.disabled).toBe(false); + await act(async () => { remove.click(); }); + expect(row('notes')).toBeNull(); + }); + + it('asks before moving an existing window into a tab and preserves its workspace', async () => { + mocks.snapshot.mockResolvedValue(snapshot(true)); + pin('local', 'notes'); + await render(); + await act(async () => { row('notes').click(); }); + expect(mocks.open).not.toHaveBeenCalled(); + const dialog = document.querySelector('[role="alertdialog"]')!; + expect(dialog.textContent).toContain('presentation.transferWarning'); + const confirm = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'presentation.openTab')!; + await act(async () => { confirm.click(); }); + expect(mocks.open).toHaveBeenCalledExactlyOnceWith('notes', 'tab', '/original', undefined); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.tsx b/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.tsx new file mode 100644 index 0000000000..80cebd3c59 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/MiniAppShortcuts.tsx @@ -0,0 +1,70 @@ +import { ConfirmDialog, Icon, IconButton, NavigationPanelItem, Tooltip } from '@openbitfun/ui'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { useI18n } from '@/infrastructure/i18n'; +import { isRemoteWorkspace } from '@/shared/types'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { useMiniAppStore } from '@/app/scenes/miniapps/miniAppStore'; +import { useMiniAppNavigation } from '@/app/scenes/miniapps/miniAppNavigationStore'; +import { useMiniAppLaunch } from '@/app/scenes/miniapps/hooks/useMiniAppLaunch'; +import { getMiniAppSceneId } from '@/app/scenes/miniapps/miniAppActivity'; +import { renderMiniAppIcon } from '@/app/scenes/miniapps/utils/miniAppIcons'; +import { pickLocalizedString } from '@/app/scenes/miniapps/utils/pickLocalizedString'; + +function MiniAppShortcutItems({ scope, pinnedAppIds, setPinned }: ReturnType) { + const { t, currentLanguage } = useI18n('scenes/miniapp'); + const { workspace } = useCurrentWorkspace(); + const apps = useMiniAppStore(state => state.apps); + const catalogSurfaceId = useMiniAppStore(state => state.catalogSurfaceId); + const activeTabId = useSceneStore(state => state.activeTabId); + const launch = useMiniAppLaunch(); + const shortcuts = catalogSurfaceId === scope.surfaceId + ? pinnedAppIds.flatMap(id => apps.find(app => app.id === id) ?? []) + : []; + + return <> + {shortcuts.map(app => { + const name = pickLocalizedString(app, currentLanguage, 'name'); + const selected = activeTabId === getMiniAppSceneId(app.id); + const workspaceUnsupported = Boolean(workspace && isRemoteWorkspace(workspace) + && [...(app.permissions.fs?.read ?? []), ...(app.permissions.fs?.write ?? [])].includes('{workspace}')); + const removeLabel = t('navigation.removeApp', { name }); + return + void launch.openApp(app.id)} + leading={{renderMiniAppIcon(app.icon || 'box', 'sm')}} + actionContent={ + } onClick={() => setPinned(app.id, false)} /> + } + > + {name} + + ; + })} + { if (!open) launch.cancelTransfer(); }} + title={t('presentation.openTab')} + message={t('presentation.transferWarning')} + confirmText={t('presentation.openTab')} + cancelText={t('confirmDelete.cancel')} + onConfirm={() => void launch.confirmTransfer()} + /> + ; +} + +export default function MiniAppShortcuts() { + const navigation = useMiniAppNavigation(); + return ; +} diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss index 7469bf225e..409842bf2a 100644 --- a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss +++ b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss @@ -4,7 +4,7 @@ --_session-history-source-left: 0px; --_session-history-source-top: 0px; - // Match the session header SearchField panel, including its glass recipe. + // Keep the moving clip opaque so live chat content does not need backdrop sampling. inline-size: max(220px, var(--_session-history-source-width)); padding: 0; border: 0; @@ -31,12 +31,13 @@ } &[data-state='entered'] { - animation: session-tab-history-expand var(--openbitfun-motion-duration-content-swap) var(--openbitfun-motion-easing-standard) both; + // Reveal both axes together; backwards fill releases the clip and shadow at rest. + animation: session-tab-history-expand var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-enter) backwards; } &[data-state='exiting'] { pointer-events: none; - animation: session-tab-history-collapse var(--openbitfun-motion-duration-base) var(--openbitfun-motion-easing-exit) both; + animation: session-tab-history-collapse var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-exit) both; } &__header { @@ -65,24 +66,6 @@ border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); } - @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { - background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent); - -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); - backdrop-filter: var(--openbitfun-effect-blur-medium); - } - - :where([data-contrast='high']) & { - background: var(--openbitfun-color-surface-raised); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } - - @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { - background: var(--openbitfun-color-surface-raised); - -webkit-backdrop-filter: none; - backdrop-filter: none; - } - @media (prefers-reduced-motion: reduce) { &[data-state] { animation: none; clip-path: none; } } @@ -91,24 +74,15 @@ outline-color: ButtonText; background: Canvas; box-shadow: none; - -webkit-backdrop-filter: none; - backdrop-filter: none; } } @keyframes session-tab-history-expand { 0% { clip-path: var(--_session-history-capsule); } - 45% { - clip-path: inset(0 0 calc(100% - var(--_session-history-source-height)) 0 round calc(var(--_session-history-source-height) / 2)); - } - 99% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } - 100% { clip-path: none; } + 100% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } } @keyframes session-tab-history-collapse { 0% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } - 55% { - clip-path: inset(0 0 calc(100% - var(--_session-history-source-height)) 0 round calc(var(--_session-history-source-height) / 2)); - } 100% { clip-path: var(--_session-history-capsule); } } diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx index bf9d34fe2d..43fca3cb05 100644 --- a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx +++ b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx @@ -97,7 +97,7 @@ export function SessionTabHistoryMenu({ tab, anchorRef, open, onClose }: Session open={open} onClose={onClose} overlapAnchor - exitDurationMs={220} + exitDurationMs={140} parts={historyParts} />; } diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index d7a0fce8c2..b564e3b187 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -903,6 +903,11 @@ const FlexiblePanel: React.FC = memo(({ workspaceId={content.data?.workspaceId ?? content.data?.report?.workspace?.workspaceId} workspacePath={content.data?.workspacePath || workspacePath} initialTab={content.data?.initialTab} + onTitleChange={title => { + if (content.title !== title) { + onContentChange?.({ ...content, title, data: { ...content.data, title } }); + } + }} /> ); diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index 54a13a26b7..04c962f594 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -221,7 +221,7 @@ export const PANEL_CONTENT_CONFIGS: Record }, 'session-usage': { type: 'session-usage', - displayName: 'Session Usage', + displayName: 'Context Usage Report', icon: { glyph: Activity }, supportsCopy: false, supportsDownload: false, diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.test.tsx new file mode 100644 index 0000000000..dd9482336d --- /dev/null +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CanvasToolbarHost, CanvasToolbarProvider } from '../CanvasToolbar'; +import { useAgentCanvasStore } from '../stores'; +import type { EditorGroupId, SplitMode } from '../types'; +import { EditorArea } from './EditorArea'; + +vi.mock('react-i18next', async original => ({ + ...await original(), + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock('../../base/FlexiblePanel', () => ({ default: () => null })); + +// Exercise real groups, tab bars and toolbar registration without mounting editors. +describe('split canvas tab placement', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + useAgentCanvasStore.getState().reset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useAgentCanvasStore.getState().reset(); + vi.unstubAllGlobals(); + }); + + function addTab(groupId: EditorGroupId, title: string = groupId) { + useAgentCanvasStore.getState().addTab({ + type: 'text-viewer', title, data: { content: title }, + }, 'active', groupId); + return useAgentCanvasStore.getState()[`${groupId}Group`].activeTabId!; + } + + function render(externalToolbar = true) { + act(() => root.render( +
+
Layout} />
+
)); + } + + const headerGroups = () => [...container.querySelectorAll('[data-canvas-toolbar-group]')] + .map(group => group.dataset.canvasToolbarGroup); + const group = (id: EditorGroupId) => container.querySelector( + `[data-openbitfun-component="canvas-editor-group"][data-openbitfun-group="${id}"]`, + )!; + const tab = (id: string) => container.querySelector( + `[role="tab"][data-openbitfun-value="${id}"]`, + )!; + + it.each([ + ['none', ['primary'], []], + ['horizontal', ['primary', 'secondary'], []], + ['vertical', ['primary'], ['secondary']], + ['grid', ['primary', 'secondary'], ['tertiary']], + ] as [SplitMode, EditorGroupId[], EditorGroupId[]][])( + 'keeps lower tab bars beside their content in %s mode', (mode, topGroups, lowerGroups) => { + useAgentCanvasStore.getState().setSplitMode(mode); + const visibleGroups = [...topGroups, ...lowerGroups]; + visibleGroups.forEach(id => addTab(id)); + render(); + + expect(headerGroups()).toEqual(topGroups); + expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(visibleGroups.length); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(visibleGroups.length); + topGroups.forEach(id => expect(group(id).querySelector('[role="tablist"]')).toBeNull()); + lowerGroups.forEach(id => expect(group(id).querySelectorAll('[role="tab"]')).toHaveLength(1)); + expect([...container.querySelectorAll('button')].filter(button => button.textContent === 'Layout')).toHaveLength(1); + expect(container.querySelector('header')?.textContent).toContain('Layout'); + }, + ); + + it('moves tab bars between the header and lower pane when the layout changes', () => { + useAgentCanvasStore.getState().setSplitMode('horizontal'); + const primaryId = addTab('primary'); + const secondaryId = addTab('secondary'); + render(); + expect(headerGroups()).toEqual(['primary', 'secondary']); + + act(() => useAgentCanvasStore.getState().setSplitMode('vertical')); + expect(headerGroups()).toEqual(['primary']); + expect(group('secondary').contains(tab(secondaryId))).toBe(true); + + act(() => useAgentCanvasStore.getState().setSplitMode('horizontal')); + expect(headerGroups()).toEqual(['primary', 'secondary']); + expect(group('secondary').querySelector('[role="tablist"]')).toBeNull(); + + act(() => useAgentCanvasStore.getState().setSplitMode('none')); + expect(headerGroups()).toEqual(['primary']); + expect(container.querySelector('header')?.contains(tab(primaryId))).toBe(true); + expect(container.querySelector('header')?.contains(tab(secondaryId))).toBe(true); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(2); + }); + + it('selects and closes tabs in the lower pane through the owning group', () => { + useAgentCanvasStore.getState().setSplitMode('vertical'); + const primaryId = addTab('primary'); + const firstId = addTab('secondary', 'first'); + const secondId = addTab('secondary', 'second'); + render(); + + act(() => tab(firstId).click()); + expect(useAgentCanvasStore.getState().activeGroupId).toBe('secondary'); + expect(useAgentCanvasStore.getState().secondaryGroup.activeTabId).toBe(firstId); + expect(tab(firstId).getAttribute('aria-selected')).toBe('true'); + + act(() => group('secondary').querySelector( + `[data-tab-id="${firstId}"] .canvas-tab__action-btn`, + )!.click()); + expect(tab(firstId)).toBeNull(); + expect(group('secondary').contains(tab(secondId))).toBe(true); + expect(useAgentCanvasStore.getState().primaryGroup.activeTabId).toBe(primaryId); + }); + + it('keeps embedded canvas toolbars local', () => { + useAgentCanvasStore.getState().setSplitMode('vertical'); + addTab('primary'); + addTab('secondary'); + render(false); + expect(headerGroups()).toEqual([]); + expect(group('primary').querySelector('[role="tablist"]')).not.toBeNull(); + expect(group('secondary').querySelector('[role="tablist"]')).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index f5bf546383..a957657208 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -134,6 +134,7 @@ export const EditorArea: React.FC = ({ ); // Keep host layout controls in the upper-right corner of every split layout. + // Only top-row groups share the host header; lower groups keep their tab bars. const toolbarGroupId = layout.splitMode === 'horizontal' || layout.splitMode === 'grid' ? 'secondary' : 'primary'; @@ -163,7 +164,7 @@ export const EditorArea: React.FC = ({ onOpenMissionControl={groupId === 'primary' ? onOpenMissionControl : undefined} onCloseAllTabs={handleCloseAllTabs(groupId)} onInteraction={onInteraction} - externalToolbar={externalToolbar} + externalToolbar={externalToolbar && (groupId === 'primary' || groupId === toolbarGroupId)} disablePopOut={disablePopOut} toolbarActions={toolbarActions === undefined || groupId === toolbarGroupId ? toolbarActions : null} terminalResizeSuspended={terminalResizeSuspended} diff --git a/src/web-ui/src/app/scenes/miniapps/AGENTS.md b/src/web-ui/src/app/scenes/miniapps/AGENTS.md new file mode 100644 index 0000000000..ceae49dcea --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/AGENTS.md @@ -0,0 +1,17 @@ +# MiniApps + +Follow `src/web-ui/AGENTS.md` for shared frontend rules. Navigation shortcuts are +controller presentation preferences scoped by the selected device. Catalog +responses must belong to the current surface before updating shortcuts; failed +or empty discovery must not erase preferences. Launch through `useMiniAppLaunch` +so presentation transfer and remote-workspace restrictions remain consistent. + +For navigation shortcuts, persistence, catalog ownership and launch behavior: + +```bash +pnpm --dir src/web-ui run test:run src/app/scenes/miniapps/miniAppNavigationStore.test.ts src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.test.tsx src/app/scenes/miniapps/hooks/miniAppCatalogStartupRefresh.test.ts src/app/scenes/miniapps/hooks/useMiniAppLaunch.test.tsx src/app/scenes/miniapps/components/MiniAppInstalledCard.test.tsx src/app/scenes/miniapps/views/MiniAppLibraryView.navigation.test.tsx src/app/components/NavPanel/components/MiniAppShortcuts.test.tsx src/app/components/NavPanel/MainNav.projectCreation.test.tsx +``` + +These store and DOM fixtures cover local launches and simulated SSH/Peer surface +restrictions and switches. They do not establish live Remote Workspace, Remote +Control, Peer Device or Detached Dispatch behavior. diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx index b9f34f1d39..0ef5b0f1a0 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx @@ -18,6 +18,7 @@ import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; import { getMiniAppShowcaseAsset, renderMiniAppIcon } from '../utils/miniAppIcons'; import { MarketImage } from '@/app/components/GalleryLayout/MarketImage'; import { MiniAppDetailPage } from './MiniAppDetailPage'; +import { MiniAppNavigationAction } from './MiniAppNavigationAction'; import { pickLocalizedString, pickLocalizedTags } from '../utils/pickLocalizedString'; import { projectMiniAppDetailCapabilities, @@ -204,6 +205,7 @@ const MiniAppDetailModal: React.FC = ({ {localizedTags.map(tag => {tag})} } actions={<> + {systemAppActions} {launchActions ?? ; +} diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/miniAppCatalogStartupRefresh.ts b/src/web-ui/src/app/scenes/miniapps/hooks/miniAppCatalogStartupRefresh.ts index 6393166f1c..881982b3be 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/miniAppCatalogStartupRefresh.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/miniAppCatalogStartupRefresh.ts @@ -8,6 +8,7 @@ export interface MiniAppCatalogStartupRefreshDependencies { scheduler?: Pick; refreshApps: () => Promise; refreshRunningWorkers: () => Promise; + inFlightKey?: string; } export function scheduleMiniAppCatalogStartupRefresh( @@ -27,6 +28,6 @@ export function scheduleMiniAppCatalogStartupRefresh( }, { idle: true, priority: 'low', - inFlightKey: 'miniapp:startup-catalog-refresh', + inFlightKey: dependencies.inFlightKey ?? 'miniapp:startup-catalog-refresh', }); } diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.test.tsx b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.test.tsx new file mode 100644 index 0000000000..9103f49a09 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.test.tsx @@ -0,0 +1,66 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useMiniAppStore } from '../miniAppStore'; +import { useMiniAppNavigationStore } from '../miniAppNavigationStore'; +import { useMiniAppCatalogSync } from './useMiniAppCatalogSync'; + +const mocks = vi.hoisted(() => ({ + listMiniApps: vi.fn(), workerListRunning: vi.fn(), + listeners: new Map void>(), +})); +vi.mock('@/infrastructure/api/service-api/MiniAppAPI', () => ({ miniAppAPI: mocks })); +vi.mock('../utils/loadInstalledMarketOrigins', () => ({ loadInstalledMarketOrigins: async () => ({}) })); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { + listen: (event: string, handler: (payload: { id: string }) => void) => { + mocks.listeners.set(event, handler); + return () => mocks.listeners.delete(event); + }, +} })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn() }) })); + +const localApp = { id: 'notes', name: 'Local Notes' } as MiniAppMeta; +const peerApp = { id: 'notes', name: 'Peer Notes' } as MiniAppMeta; +let root: Root; +function Harness() { useMiniAppCatalogSync(); return null; } + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + resetDeviceSurfaceForTest(); + vi.clearAllMocks(); + mocks.listeners.clear(); + mocks.listMiniApps.mockReset().mockResolvedValue([localApp]); + mocks.workerListRunning.mockReset().mockResolvedValue([]); + useMiniAppStore.setState({ apps: [], catalogSurfaceId: null, loading: false, runningWorkerIds: [] }); + useMiniAppNavigationStore.setState({ pinnedAppIdsBySurface: { local: ['notes'], peer: ['notes'] } }); + root = createRoot(document.createElement('div')); +}); +afterEach(() => { + act(() => root.unmount()); + resetDeviceSurfaceForTest(); +}); + +describe('MiniApp shortcut catalog ownership', () => { + it('reloads on device activation and discards a late response from the previous device', async () => { + let finish!: (apps: MiniAppMeta[]) => void; + mocks.listMiniApps.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + await act(async () => { root.render(); }); + mocks.listMiniApps.mockResolvedValue([peerApp]); + await act(async () => { activateSurface('peer'); }); + expect(useMiniAppStore.getState()).toMatchObject({ apps: [peerApp], catalogSurfaceId: 'peer', loading: false }); + await act(async () => { finish([localApp]); }); + expect(useMiniAppStore.getState()).toMatchObject({ apps: [peerApp], catalogSurfaceId: 'peer' }); + }); + + it('preserves pins on failed discovery and removes only an explicitly deleted app on its device', async () => { + mocks.listMiniApps.mockRejectedValueOnce(new Error('Host offline')); + await act(async () => { root.render(); }); + expect(useMiniAppStore.getState().loading).toBe(false); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface).toEqual({ local: ['notes'], peer: ['notes'] }); + await act(async () => { mocks.listeners.get('miniapp-deleted')!({ id: 'notes' }); }); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface).toEqual({ local: [], peer: ['notes'] }); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.ts index 7c7e360360..4c807db529 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppCatalogSync.ts @@ -1,12 +1,14 @@ /** * useMiniAppCatalogSync — keeps Mini App catalog and runtime state in sync. */ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useSyncExternalStore } from 'react'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { BackgroundTaskCancelledError } from '@/shared/utils/backgroundTaskScheduler'; import { createLogger } from '@/shared/utils/logger'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; import { useMiniAppStore } from '../miniAppStore'; +import { useMiniAppNavigationStore } from '../miniAppNavigationStore'; import { loadInstalledMarketOrigins } from '../utils/loadInstalledMarketOrigins'; import { scheduleMiniAppCatalogStartupRefresh } from './miniAppCatalogStartupRefresh'; @@ -19,6 +21,7 @@ export interface UseMiniAppCatalogSyncOptions { export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {}) { const { enabled = true, initialLoad = 'immediate' } = options; + const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope); const setApps = useMiniAppStore((state) => state.setApps); const setLoading = useMiniAppStore((state) => state.setLoading); const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins); @@ -27,29 +30,33 @@ export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {} const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); const refreshApps = useCallback(async () => { + if (!scope.isCurrent()) return; setLoading(true); try { const [apps, origins] = await Promise.all([ miniAppAPI.listMiniApps(), loadInstalledMarketOrigins(), ]); + if (!scope.isCurrent()) return; setApps(apps); setMarketOrigins(origins); } catch (error) { - log.error('Failed to load miniapps', error); + if (scope.isCurrent()) log.error('Failed to load miniapps', error); } finally { - setLoading(false); + if (scope.isCurrent()) setLoading(false); } - }, [setApps, setLoading, setMarketOrigins]); + }, [scope, setApps, setLoading, setMarketOrigins]); const refreshRunningWorkers = useCallback(async () => { + if (!scope.isCurrent()) return; try { const running = await miniAppAPI.workerListRunning(); + if (!scope.isCurrent()) return; setRunningWorkerIds(running); } catch (error) { - log.error('Failed to load running miniapp workers', error); + if (scope.isCurrent()) log.error('Failed to load running miniapp workers', error); } - }, [setRunningWorkerIds]); + }, [scope, setRunningWorkerIds]); useEffect(() => { if (!enabled) { @@ -60,6 +67,7 @@ export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {} ? scheduleMiniAppCatalogStartupRefresh({ refreshApps, refreshRunningWorkers, + inFlightKey: scope.key('miniapp:startup-catalog-refresh', scope.epoch), }) : null; @@ -81,18 +89,20 @@ export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {} void refreshApps(); }); const unlistenDeleted = api.listen<{ id?: string }>('miniapp-deleted', (payload) => { + if (!scope.isCurrent()) return; if (payload?.id) { + useMiniAppNavigationStore.getState().setPinned(scope.surfaceId, payload.id, false); markWorkerStopped(payload.id); } void refreshApps(); }); const unlistenRestarted = api.listen<{ id?: string }>('miniapp-worker-restarted', (payload) => { - if (payload?.id) { + if (scope.isCurrent() && payload?.id) { markWorkerRunning(payload.id); } }); const unlistenStopped = api.listen<{ id?: string }>('miniapp-worker-stopped', (payload) => { - if (payload?.id) { + if (scope.isCurrent() && payload?.id) { markWorkerStopped(payload.id); } }); @@ -105,7 +115,7 @@ export function useMiniAppCatalogSync(options: UseMiniAppCatalogSyncOptions = {} unlistenRestarted(); unlistenStopped(); }; - }, [enabled, initialLoad, markWorkerRunning, markWorkerStopped, refreshApps, refreshRunningWorkers]); + }, [enabled, initialLoad, scope, markWorkerRunning, markWorkerStopped, refreshApps, refreshRunningWorkers]); return { refreshApps, diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.test.ts b/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.test.ts new file mode 100644 index 0000000000..b03d97ff19 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.test.ts @@ -0,0 +1,50 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useMiniAppNavigationStore } from './miniAppNavigationStore'; + +const store = useMiniAppNavigationStore; +const storageKey = 'openbitfun-miniapp-navigation'; + +beforeEach(() => { + localStorage.clear(); + store.setState({ pinnedAppIdsBySurface: {} }); +}); + +describe('MiniApp navigation preferences', () => { + it('keeps insertion order, ignores duplicate additions, and isolates equal app IDs by device', () => { + const { setPinned } = store.getState(); + setPinned('local', 'notes', true); + setPinned('local', 'timer', true); + setPinned('local', 'notes', true); + setPinned('peer', 'notes', true); + setPinned('local', 'notes', false); + expect(store.getState().pinnedAppIdsBySurface).toEqual({ local: ['timer'], peer: ['notes'] }); + setPinned('local', 'notes', true); + expect(store.getState().pinnedAppIdsBySurface.local).toEqual(['timer', 'notes']); + }); + + it('restores the saved preferences after a reload without persisting actions', async () => { + store.getState().setPinned('peer', 'notes', true); + const saved = localStorage.getItem(storageKey)!; + expect(JSON.parse(saved).state).toEqual({ pinnedAppIdsBySurface: { peer: ['notes'] } }); + store.setState({ pinnedAppIdsBySurface: {} }); + localStorage.setItem(storageKey, saved); + await store.persist.rehydrate(); + expect(store.getState().pinnedAppIdsBySurface).toEqual({ peer: ['notes'] }); + store.getState().setPinned('peer', 'timer', true); + expect(store.getState().pinnedAppIdsBySurface.peer).toEqual(['notes', 'timer']); + }); + + it('accepts older empty preferences and tolerates unknown payload fields', async () => { + localStorage.setItem(storageKey, JSON.stringify({ state: {}, version: 0 })); + await store.persist.rehydrate(); + expect(store.getState().pinnedAppIdsBySurface).toEqual({}); + localStorage.setItem(storageKey, JSON.stringify({ state: { + pinnedAppIdsBySurface: { local: ['notes'], peer: ['timer'] }, futureSetting: true, + }, version: 0 })); + await store.persist.rehydrate(); + store.getState().setPinned('local', 'timer', true); + expect(JSON.parse(localStorage.getItem(storageKey)!).state.pinnedAppIdsBySurface) + .toEqual({ local: ['notes', 'timer'], peer: ['timer'] }); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.ts b/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.ts new file mode 100644 index 0000000000..7f1bca00fa --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/miniAppNavigationStore.ts @@ -0,0 +1,59 @@ +import { useSyncExternalStore } from 'react'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; + +interface MiniAppNavigationState { + /** Controller presentation preferences, isolated by the device owning each app. */ + pinnedAppIdsBySurface: Record; + setPinned: (surfaceId: string, appId: string, pinned: boolean) => void; +} + +const EMPTY_IDS: string[] = []; + +function normalizePins(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).flatMap(([surfaceId, ids]) => + Array.isArray(ids) + ? [[surfaceId, [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.trim().length > 0))]]] + : [], + )); +} + +export const useMiniAppNavigationStore = create()(persist( + (set) => ({ + pinnedAppIdsBySurface: {}, + setPinned: (surfaceId, appId, pinned) => set(state => { + if (!appId.trim()) return state; + const ids = state.pinnedAppIdsBySurface[surfaceId] ?? EMPTY_IDS; + if (ids.includes(appId) === pinned) return state; + return { + pinnedAppIdsBySurface: { + ...state.pinnedAppIdsBySurface, + [surfaceId]: pinned ? [...ids, appId] : ids.filter(id => id !== appId), + }, + }; + }), + }), + { + name: 'openbitfun-miniapp-navigation', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ pinnedAppIdsBySurface: state.pinnedAppIdsBySurface }), + merge: (persisted, current) => ({ + ...current, + pinnedAppIdsBySurface: normalizePins((persisted as Partial | null)?.pinnedAppIdsBySurface), + }), + }, +)); + +export function useMiniAppNavigation() { + const scope = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope); + const pinnedAppIds = useMiniAppNavigationStore(state => state.pinnedAppIdsBySurface[scope.surfaceId] ?? EMPTY_IDS); + return { + scope, + pinnedAppIds, + setPinned: (appId: string, pinned: boolean) => { + if (scope.isCurrent()) useMiniAppNavigationStore.getState().setPinned(scope.surfaceId, appId, pinned); + }, + }; +} diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts index e3e61a652c..2f1a95a0f5 100644 --- a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts +++ b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts @@ -165,6 +165,7 @@ export interface MiniAppComposerClaim { interface MiniAppState { apps: MiniAppMeta[]; + catalogSurfaceId: string | null; loading: boolean; /** App IDs whose JS workers are currently running. */ runningWorkerIds: string[]; @@ -202,6 +203,7 @@ interface MiniAppState { export const useMiniAppStore = create((set) => ({ apps: [], + catalogSurfaceId: null, loading: false, runningWorkerIds: [], customizingAppIds: [], @@ -213,6 +215,7 @@ export const useMiniAppStore = create((set) => ({ const validIds = new Set(apps.map((app) => app.id)); return { apps, + catalogSurfaceId: getActiveSurfaceId(), runningWorkerIds: state.runningWorkerIds.filter((id) => validIds.has(id)), customizingAppIds: state.customizingAppIds.filter((id) => validIds.has(id)), composerClaims: Object.fromEntries( diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.navigation.test.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.navigation.test.tsx index c7337e532d..53a87c0fc6 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.navigation.test.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.navigation.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; import type { MarketListingDetail } from '@/infrastructure/api/service-api/MiniAppMarketAPI'; import { useMiniAppStore } from '../miniAppStore'; +import { useMiniAppNavigationStore } from '../miniAppNavigationStore'; import MiniAppLibraryView from './MiniAppLibraryView'; const mocks = vi.hoisted(() => ({ @@ -68,6 +69,7 @@ const openMarketDetail = () => act(async () => { beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); vi.clearAllMocks(); + useMiniAppNavigationStore.setState({ pinnedAppIdsBySurface: {} }); mocks.remote = false; mocks.items = [listing]; mocks.getListing.mockResolvedValue(listing); @@ -141,6 +143,8 @@ describe('MiniApp detail page navigation', () => { expect(launch.dataset.size).toBe('standard'); await act(async () => card.querySelector('.miniapp-installed-card__content')!.click()); expect(page()?.querySelector('h1')?.textContent).toBe('Notes'); + await act(async () => button('navigation.add').click()); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface.local).toEqual(['notes']); expect(mocks.openApp).not.toHaveBeenCalled(); await act(async () => button('detail.back').click()); await act(async () => launch.click()); @@ -154,6 +158,7 @@ describe('MiniApp detail page navigation', () => { expect(mocks.deleteMiniApp).not.toHaveBeenCalled(); await act(async () => button('confirmDelete.cancel', confirm).click()); expect(useMiniAppStore.getState().apps).toHaveLength(1); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface.local).toEqual(['notes']); expect(mocks.deleteMiniApp).not.toHaveBeenCalled(); await remove(); @@ -161,6 +166,7 @@ describe('MiniApp detail page navigation', () => { await act(async () => button('confirmDelete.confirm', confirm).click()); expect(mocks.deleteMiniApp).toHaveBeenCalledWith('notes'); expect(useMiniAppStore.getState().apps).toHaveLength(0); + expect(useMiniAppNavigationStore.getState().pinnedAppIdsBySurface.local).toEqual([]); expect(page()).toBeNull(); }); diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx index 6dd01079b2..e67df38bdc 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppLibraryView.tsx @@ -81,6 +81,8 @@ import { useMiniAppLaunch } from '../hooks/useMiniAppLaunch'; import { MiniAppLaunchActions } from '../components/MiniAppLaunchActions'; import { MiniAppSystemAppActions } from '../components/MiniAppSystemAppActions'; import { useMiniAppSystemInstallation } from '../hooks/useMiniAppSystemInstallation'; +import { MiniAppNavigationAction } from '../components/MiniAppNavigationAction'; +import { useMiniAppNavigationStore } from '../miniAppNavigationStore'; const log = createLogger('MiniAppLibraryView'); const MINIAPP_MARKET_WEBSITE_URL = 'https://market.openbitfun.com/miniapp/'; @@ -387,6 +389,8 @@ const MiniAppLibraryContent: React.FC = ({ setPendingDeleteId(null); try { await miniAppAPI.deleteMiniApp(appId); + if (!surfaceScope.isCurrent()) return; + useMiniAppNavigationStore.getState().setPinned(surfaceScope.surfaceId, appId, false); if (selectedApp?.id === appId) closeDetail(); setApps(useMiniAppStore.getState().apps.filter((app) => app.id !== appId)); markWorkerStopped(appId); @@ -1067,6 +1071,7 @@ const MiniAppLibraryContent: React.FC = ({ ) : null} {installed ? renderLaunchActions(installed.appId, busyListingId === detail.listingId || detailWorkspaceUnsupported, 'menu') : null} + {installed ? : null} {!installed || canUpdate ? ( ) : null} + {showUsage && usagePopoverOpen ? ( + setUsagePopoverOpen(false)} + onViewReport={() => { + setUsagePopoverOpen(false); + usageReport.onOpen(); + }} + /> + ) : null} ); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 1ab28192e8..ab0f9804c7 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -576,7 +576,7 @@ export const UserMessageItem = React.memo( sessionId: currentSession?.sessionId ?? resolvedSessionId, workspacePath: currentSession?.workspacePath, initialTab, - title: t('usage.title'), + title: t('usage.panel.report'), expand: true, }); }); diff --git a/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.scss b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.scss new file mode 100644 index 0000000000..cfeb5d2487 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.scss @@ -0,0 +1,145 @@ +@use '@/shared/styles/surface-recipes' as surfaces; + +.context-usage-popover { + @include surfaces.floating; + + position: fixed; + display: flex; + flex-direction: column; + box-sizing: border-box; + min-inline-size: 0; + max-block-size: calc(100vh - var(--openbitfun-space-8)); + overflow-y: auto; + padding: var(--openbitfun-space-3); + gap: var(--openbitfun-space-2); + border-radius: var(--openbitfun-control-composer-surface-radius); + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-flow-support-font-family); + font-size: var(--openbitfun-type-flow-support-font-size); + font-weight: var(--openbitfun-type-flow-support-font-weight); + line-height: var(--openbitfun-type-flow-support-line-height); + letter-spacing: var(--openbitfun-type-flow-support-letter-spacing); + outline: none; + + &__header { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); + flex-shrink: 0; + } + + &__header-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--openbitfun-space-1); + margin-inline-start: auto; + } + + &__totals { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); + font-variant-numeric: tabular-nums; + } + + &__bar { + display: flex; + flex-shrink: 0; + block-size: var(--openbitfun-space-2); + gap: var(--openbitfun-border-width-default); + border-radius: var(--openbitfun-radius-pill); + overflow: hidden; + } + + &__segment { + flex-basis: 0; + min-inline-size: var(--openbitfun-border-width-default); + background: currentColor; + } + + &__legend { + display: flex; + flex-direction: column; + gap: 0; + margin: 0 calc(-1 * var(--openbitfun-space-1)); + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + min-block-size: calc(var(--openbitfun-control-icon-button-xs-size) + var(--openbitfun-space-1)); + gap: var(--openbitfun-space-3); + padding: calc(var(--openbitfun-space-1) / 2) var(--openbitfun-space-1); + border-radius: var(--openbitfun-radius-md); + + &:is(:hover, :focus-within, [data-usage-active]) { + background: var(--openbitfun-color-action-neutral-surface); + } + + &:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); + } + + dt { + display: flex; + align-items: center; + min-inline-size: 0; + gap: var(--openbitfun-space-2); + } + + dd { + flex-shrink: 0; + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-variant-numeric: tabular-nums; + } + } + + &__preview { + opacity: 0; + pointer-events: none; + } + + &__row:is(:hover, :focus-within, [data-usage-active]) &__preview { + opacity: 1; + pointer-events: auto; + } + + &__row[data-usage-muted] { opacity: 0.6; } + &__segment[data-usage-muted] { opacity: 0.3; } + + @media (hover: none) { + &__preview { opacity: 1; pointer-events: auto; } + } + + &__swatch { + flex-shrink: 0; + inline-size: var(--openbitfun-space-3); + block-size: var(--openbitfun-space-3); + border-radius: var(--openbitfun-radius-sm); + background: currentColor; + } + + &__series--systemPrompt { color: var(--openbitfun-component-session-usage-input); } + &__series--toolDefinitions { color: var(--openbitfun-component-session-usage-output); } + &__series--rules { color: var(--openbitfun-color-status-success-emphasis); } + &__series--skills { color: var(--openbitfun-color-status-warning-emphasis); } + &__series--mcpTools { color: color-mix(in srgb, var(--openbitfun-component-session-usage-output) 50%, var(--openbitfun-color-status-danger-emphasis)); } + &__series--subagentDefinitions { color: color-mix(in srgb, var(--openbitfun-component-session-usage-input) 50%, var(--openbitfun-color-status-success-emphasis)); } + &__series--summarizedConversation { color: var(--openbitfun-color-status-danger-emphasis); } + &__series--conversation { color: color-mix(in srgb, var(--openbitfun-color-status-warning-emphasis) 60%, var(--openbitfun-color-status-danger-emphasis)); } + &__series--unused { color: var(--openbitfun-color-border-default); } + + &__status { + margin: 0; + color: var(--openbitfun-color-content-muted); + } +} diff --git a/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx new file mode 100644 index 0000000000..b1e38ad847 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/ContextUsagePopover.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import { Card, Icon, IconButton, OverflowText, Tooltip, createOverlayPortal, useDismissibleLayer } from '@openbitfun/ui'; +import { ChartPie, ExternalLink, Minus } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import type { UsageContextBreakdown } from '@/infrastructure/api/service-api/SessionAPI'; +import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; +import { formatTokenCount, formatUsagePercent } from './usageReportUtils'; +import { useUsageSeriesHighlight } from './useUsageSeriesHighlight'; +import { useContextPreview } from './useContextPreview'; +import './ContextUsagePopover.scss'; + +const CATEGORIES = [ + 'systemPrompt', 'toolDefinitions', 'rules', 'skills', 'mcpTools', + 'subagentDefinitions', 'summarizedConversation', 'conversation', +] as const satisfies readonly (keyof UsageContextBreakdown)[]; + +const isCount = (value: number | undefined): value is number => ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +); + +/** Opening the overview is read-only; report and category actions are explicit. */ +export function ContextUsagePopover({ + id, anchorRef, triggerRef, currentTokens, maxTokens, loadContext, previewSession, onClose, onViewReport, +}: { + id: string; + anchorRef: RefObject; + triggerRef: RefObject; + currentTokens: number; + maxTokens: number; + loadContext?: () => Promise; + previewSession?: { sessionId: string; workspaceId?: string }; + onClose: () => void; + onViewReport: () => void; +}) { + const { t } = useI18n('flow-chat'); + const popoverRef = useRef(null); + const { previewLoading, previewContext } = useContextPreview({ + ...previewSession, reportSessionId: previewSession?.sessionId, + }); + const [context, setContext] = useState(); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(loadContext ? 'loading' : 'ready'); + // Snapshot the loader on open: live token updates must not re-fetch the report. + const loaderRef = useRef(loadContext); + useEffect(() => { + let current = true; + if (loaderRef.current) { + void loaderRef.current().then(value => { + if (current) { setContext(value); setStatus('ready'); } + }, () => { if (current) setStatus('error'); }); + } + return () => { current = false; }; + }, []); + + const layout = useAnchoredPopoverPosition({ + open: true, anchorRef, popoverRef, preferredPlacement: 'top', + matchAnchorWidth: true, gap: 8, layoutRevision: status, + }); + const close = () => { onClose(); triggerRef.current?.focus(); }; + useDismissibleLayer({ + enabled: true, layerRef: popoverRef, branchRefs: [triggerRef], + onDismiss: reason => { + if (reason === 'escape-key') close(); + else onClose(); + }, + }); + useEffect(() => { popoverRef.current?.focus(); }, []); + + const hasBreakdown = !!context && CATEGORIES.every(key => isCount(context[key])); + const total = hasBreakdown ? CATEGORIES.reduce((sum, key) => sum + context[key], 0) : undefined; + const hasTotal = isCount(total); + // Older hosts may supply category counts without a capacity. Do not fabricate a denominator. + const capacity = isCount(context?.contextWindow) && context.contextWindow > 0 ? context.contextWindow + : context ? undefined : maxTokens > 0 ? maxTokens : undefined; + const used = hasTotal ? total : context ? undefined : currentTokens; + const percentage = used !== undefined && capacity !== undefined ? used / capacity * 100 : undefined; + const hasDistribution = hasTotal && capacity !== undefined && total <= capacity; + const rows = CATEGORIES.filter(key => key !== 'summarizedConversation' || (context?.[key] ?? 0) > 0); + const chartRows = hasDistribution ? [ + ...rows.map(key => ({ key, value: context![key] })), + { key: 'unused', value: capacity - total }, + ] : []; + const series = chartRows.map(row => ({ ...row, share: row.value / capacity! })); + const { highlight } = useUsageSeriesHighlight(series); + + return createOverlayPortal( + +
+ + {t('usage.context.title')} + +
+ + } + aria-label={t('usage.context.viewReport')} onClick={onViewReport} + data-testid="context-usage-view-report" /> + + + } + aria-label={t('usage.context.collapse')} onClick={close} /> + +
+
+
+ {percentage !== undefined + ? t('usage.context.full', { value: formatUsagePercent(percentage, t) }) : t('usage.unavailable')} + {t('usage.context.tokenCapacity', { + used: used !== undefined ? t('usage.context.approximateTokens', { value: formatTokenCount(used, t) }) : t('usage.unavailable'), + size: formatTokenCount(capacity, t), + })} +
+ {hasDistribution &&
+ {series.filter(row => row.value > 0).map(row => ( + + ))} +
} + {hasTotal ? <> +
+ {rows.map(key =>
row.key === key) ?? { key })}> +
+
{formatTokenCount(context![key], t)}
+
)} +
+ {!hasDistribution &&

+ + {t(capacity === undefined ? 'usage.context.capacityUnavailable' : 'usage.context.exceedsCapacity')} + +

} + :

+ + {t(status === 'loading' ? 'usage.context.loading' : status === 'error' ? 'usage.context.loadFailed' : 'usage.context.unavailable')} + +

} +
, + getAppearanceOverlayHost(), undefined, { ownerRef: triggerRef }, + ); +} diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index 1e89d52070..94e5ea34f1 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -46,6 +46,13 @@ vi.mock('react-i18next', async (importOriginal) => ({ 'usage.tokens.partialCacheHelp': 'The chart shows recorded cache hits; remaining input includes unreported usage. The hit percentage is a lower bound over all input tokens.', 'usage.tokens.cacheUnavailable': '(cache unavailable)', 'usage.context.unavailable': 'No context breakdown recorded', + 'usage.context.label': 'Context', + 'usage.context.utilization': 'Context usage', + 'usage.context.unused': 'Unused', + 'usage.context.capacityUnavailable': 'Context capacity was not reported. Only recorded usage by category is shown.', + 'usage.context.exceedsCapacity': 'Estimated context usage exceeds the reported capacity. Only recorded usage by category is shown.', + 'usage.context.usageOfCapacity': '{{used}} / {{size}}', + 'usage.context.approximateTokens': '~{{value}}', 'usage.unavailable': 'Unavailable', 'usage.redacted': 'Redacted', 'usage.percent': '{{value}}%', @@ -163,6 +170,10 @@ vi.mock('react-i18next', async (importOriginal) => ({ 'usage.runtime.button': 'Usage', 'usage.runtime.tooltip': 'Generate a usage report in this chat', 'usage.panel.tabsLabel': 'Usage report sections', + 'usage.panel.report': 'Context Usage Report', + 'usage.panel.workspace': 'Project', + 'usage.panel.totalTokens': 'Total tokens', + 'usage.panel.totalUsage': 'Total usage', 'usage.panel.accounting': 'Accounting', 'usage.panel.turnScope': 'Scope', 'usage.panel.cacheCoverage': 'Cache reporting', @@ -511,6 +522,17 @@ describe('Session usage report UI components', () => { }); }; + it.each([ + { isLoading: true }, + { error: 'Host offline' }, + { markdown: '# Legacy report' }, + { report: usageReport() }, + ])('publishes the localized title for existing tabs in every report state: %j', props => { + const onTitleChange = vi.fn(); + render(); + expect(onTitleChange).toHaveBeenCalledExactlyOnceWith('Context Usage Report'); + }); + it.each([undefined, null])('distinguishes a new session from missing telemetry with a %s file count', changedFiles => { const report = usageReport({ scope: { kind: 'entire_session', turnCount: 0, includesSubagents: false }, @@ -982,11 +1004,18 @@ describe('Session usage report UI components', () => { })} />); expect(container.querySelector('.session-usage-panel__title')?.textContent).toBe('Product video'); expect(container.querySelector('[role="dialog"]')).toBeNull(); - expect(container.querySelector('.session-usage-panel__donut-label strong')?.textContent).toBe('200'); - expect(container.querySelectorAll('.session-usage-panel__donut-segment')).toHaveLength(8); + expect(container.querySelector('.session-usage-panel__donut-label strong')?.textContent).toBe('20%'); + expect(container.querySelectorAll('.session-usage-panel__donut-segment')).toHaveLength(9); const contextShares = Array.from(container.querySelectorAll('.session-usage-panel__donut-segment')) .map(segment => Number(segment.getAttribute('stroke-dasharray')?.split(' ')[0])); expect(contextShares.reduce((sum, share) => sum + share, 0)).toBeCloseTo(100); + expect(contextShares.slice(0, -1).reduce((sum, share) => sum + share, 0)).toBeCloseTo(20); + const unused = container.querySelector('.session-usage-panel__donut-segment[data-usage-category="unused"]'); + expect(unused?.classList.contains('session-usage-panel__series--unused')).toBe(true); + expect(unused?.getAttribute('stroke-dasharray')).toBe('80 20'); + expect(unused?.getAttribute('stroke-dashoffset')).toBe('-20'); + expect(container.querySelector('.session-usage-panel__context .session-usage-panel__legend-row[data-usage-category="unused"]')?.textContent) + .toBe('Unused80080%'); const tokenSection = container.querySelector('.session-usage-panel__tokens'); const tokenShares = Array.from(tokenSection!.querySelectorAll('.session-usage-panel__bar-segment')) .map(segment => Number(segment.style.flexGrow)); @@ -1010,6 +1039,59 @@ describe('Session usage report UI components', () => { expect(container.querySelector('.session-usage-panel__summary')).toBe(tokenSection); }); + it.each([0, 1000])('plots %i used tokens against the full capacity, including empty and full usage', used => { + render(); + + const segments = Array.from(container.querySelectorAll('.session-usage-panel__donut-segment')); + expect(segments).toHaveLength(1); + expect(segments[0].getAttribute('data-usage-category')).toBe(used === 0 ? 'unused' : 'conversation'); + expect(segments[0].getAttribute('stroke-dasharray')).toBe('100 0'); + expect(container.querySelector('.session-usage-panel__donut-label strong')?.textContent).toBe(used === 0 ? '0%' : '100%'); + expect(container.querySelector('.session-usage-panel__donut-label span')?.textContent).toBe('Context usage'); + const unusedLegend = container.querySelector('.session-usage-panel__legend-row[data-usage-category="unused"]')!; + expect(unusedLegend.querySelector('strong')?.textContent).toBe(used === 0 ? '1K' : '0'); + expect(unusedLegend.querySelector('.session-usage-panel__legend-share')?.textContent).toBe(used === 0 ? '100%' : '0%'); + }); + + it.each([undefined, 0, -1, Number.NaN, Infinity, 1000.5])('preserves recorded category details without inventing free capacity when capacity is %s', contextWindow => { + render(); + + const contextSection = container.querySelector('.session-usage-panel__context')!; + expect(contextSection.querySelector('.session-usage-panel__donut')).toBeNull(); + expect(contextSection.textContent).toContain('Context capacity was not reported'); + expect(contextSection.querySelectorAll('.session-usage-panel__legend-row')).toHaveLength(8); + expect(contextSection.querySelector('[data-usage-category="conversation"] strong')?.textContent).toBe('80'); + expect(contextSection.querySelector('[data-usage-category="systemPrompt"] button')).not.toBeNull(); + expect(contextSection.querySelector('[data-usage-category="unused"]')).toBeNull(); + expect(contextSection.querySelector('.session-usage-panel__legend-share')).toBeNull(); + }); + + it('explains estimates over capacity without normalizing them into a misleading capacity chart', () => { + render(); + + const contextSection = container.querySelector('.session-usage-panel__context')!; + expect(contextSection.querySelector('.session-usage-panel__donut')).toBeNull(); + expect(contextSection.textContent).toContain('Estimated context usage exceeds the reported capacity'); + expect(contextSection.querySelector('[data-usage-category="conversation"] strong')?.textContent).toBe('80'); + expect(contextSection.querySelector('[data-usage-category="unused"]')).toBeNull(); + expect(contextSection.querySelector('.session-usage-panel__legend-share')).toBeNull(); + }); + it('does not draw invented distribution segments for missing or inconsistent token counts', () => { for (const tokens of [ { totalTokens: 100, outputTokens: 20 }, @@ -1030,7 +1112,7 @@ describe('Session usage report UI components', () => { promptPreviewMocks.open.mockReturnValue(new Promise(resolve => { finish = resolve; })); render(); expect(promptPreviewMocks.open).not.toHaveBeenCalled(); const button = container.querySelector(`.session-usage-panel__legend-row[data-usage-category="${category}"] button`)!; @@ -1052,12 +1134,14 @@ describe('Session usage report UI components', () => { it.each([ ['context', 'donut-segment', 'systemPrompt'], ['context', 'legend-row', 'conversation'], + ['context', 'donut-segment', 'unused'], + ['context', 'legend-row', 'unused'], ['tokens', 'bar-group', 'input'], ['tokens', 'legend-row', 'output'], ])('links %s %s hover to its legend and restores peers on leave', (section, part, category) => { render(); @@ -1065,7 +1149,7 @@ describe('Session usage report UI components', () => { const target = chart.querySelector(`.session-usage-panel__${part}[data-usage-category="${category}"]`)!; const centerValue = container.querySelector('.session-usage-panel__donut-label strong')!; const centerLabel = container.querySelector('.session-usage-panel__donut-label span')!; - expect(centerValue.textContent).toBe('200'); + expect(centerValue.textContent).toBe('20%'); act(() => { target.dispatchEvent(new dom.window.MouseEvent('mouseover', { bubbles: true })); @@ -1078,22 +1162,24 @@ describe('Session usage report UI components', () => { } const otherChart = container.querySelector(`.session-usage-panel__${section === 'context' ? 'tokens' : 'context'}`)!; expect(otherChart.querySelector('[data-usage-muted]')).toBeNull(); - expect(centerValue.textContent).toBe(section === 'context' ? category === 'systemPrompt' ? '20%' : '40%' : '200'); - expect(centerLabel.textContent).toBe(section === 'context' ? `usage.context.categories.${category}` : 'usage.context.estimatedTokens'); + expect(centerValue.textContent).toBe(section === 'context' + ? category === 'systemPrompt' ? '4%' : category === 'unused' ? '80%' : '8%' : '20%'); + expect(centerLabel.textContent).toBe(section === 'context' + ? category === 'unused' ? 'Unused' : `usage.context.categories.${category}` : 'Context usage'); act(() => { target.dispatchEvent(new dom.window.MouseEvent('mouseout', { bubbles: true, relatedTarget: dom.window.document.body })); }); expect(container.querySelector('[data-usage-muted]')).toBeNull(); expect(container.querySelector('[data-usage-active]')).toBeNull(); - expect(centerValue.textContent).toBe('200'); - expect(centerLabel.textContent).toBe('usage.context.estimatedTokens'); + expect(centerValue.textContent).toBe('20%'); + expect(centerLabel.textContent).toBe('Context usage'); }); it('restores the focused legend highlight after hover and clears it on blur', () => { render(); @@ -1121,13 +1207,13 @@ describe('Session usage report UI components', () => { const systemSegment = container.querySelector('.session-usage-panel__donut-segment[data-usage-category="systemPrompt"]')!; const centerValue = container.querySelector('.session-usage-panel__donut-label strong')!; act(() => conversationLegend.focus()); - expect(centerValue.textContent).toBe('40%'); + expect(centerValue.textContent).toBe('8%'); act(() => systemSegment.dispatchEvent(new dom.window.MouseEvent('mouseover', { bubbles: true }))); - expect(centerValue.textContent).toBe('20%'); + expect(centerValue.textContent).toBe('4%'); act(() => systemSegment.dispatchEvent(new dom.window.MouseEvent('mouseout', { bubbles: true, relatedTarget: dom.window.document.body }))); - expect(centerValue.textContent).toBe('40%'); + expect(centerValue.textContent).toBe('8%'); act(() => conversationLegend.blur()); - expect(centerValue.textContent).toBe('200'); + expect(centerValue.textContent).toBe('20%'); }); it('plots recorded cache hits with a lower bound for partial telemetry and leaves unknown cache unsplit', () => { @@ -1347,7 +1433,7 @@ describe('Session usage report UI components', () => { expect(container.querySelector('.session-runtime-status-entry')).toBeNull(); }); - it('centers the session title and omits the timestamp and metadata section', () => { + it('shows a report label above the left-aligned session title and omits the metadata section', () => { const report = usageReport({ models: [ { @@ -1380,7 +1466,8 @@ describe('Session usage report UI components', () => { const title = container.querySelector('.session-usage-panel__header h2'); expect(title?.textContent).toBe('Usage report layout'); - expect(container.querySelector('.session-usage-panel__header')?.getAttribute('data-align')).toBe('center'); + expect(container.querySelector('.session-usage-panel__eyebrow')?.textContent).toBe('Context Usage Report'); + expect(container.querySelector('.session-usage-panel__header')?.getAttribute('data-align')).toBe('start'); expect(container.querySelectorAll('time')).toHaveLength(0); expect(container.querySelector('.session-usage-panel__metadata')).toBeNull(); expect(container.querySelector('.session-usage-panel__meta-row')).toBeNull(); @@ -1392,6 +1479,57 @@ describe('Session usage report UI components', () => { expect(cacheCoverageHelp?.textContent).toContain('unavailable'); }); + it('summarizes the latest context separately from cumulative provider usage for a remote workspace', () => { + render(); + + const facts = container.querySelector('.session-usage-panel__summary-facts')!; + expect(Array.from(facts.querySelectorAll('dt')).map(node => node.textContent)) + .toEqual(['Project', 'Total usage', 'Context']); + expect(Array.from(facts.querySelectorAll('dd')).map(node => node.textContent)) + .toEqual(['OpenBitFun', '1.5K', '~133.3K / 256K']); + }); + + it('keeps legacy context values unavailable and distinguishes a recorded empty context', () => { + render(); + const factValues = () => Array.from(container.querySelectorAll('.session-usage-panel__summary-facts dd')) + .map(node => node.textContent); + expect(factValues()).toEqual(['openbitfun', '1.5K', 'Unavailable']); + + const emptyContext = { + systemPrompt: 0, toolDefinitions: 0, rules: 0, skills: 0, + mcpTools: 0, subagentDefinitions: 0, summarizedConversation: 0, conversation: 0, + }; + render(); + expect(factValues()).toEqual(['openbitfun', '1.5K', '~0 / Unavailable']); + + render(); + expect(factValues()).toEqual(['openbitfun', '1.5K', 'Unavailable / 256K']); + + for (const context of [ + { ...emptyContext, conversation: -1, contextWindow: 0 }, + { ...emptyContext, conversation: Number.NaN, contextWindow: -1 }, + { ...emptyContext, conversation: Number.MAX_SAFE_INTEGER, systemPrompt: 1, contextWindow: Infinity }, + ]) { + render(); + expect(factValues()).toEqual(['openbitfun', '1.5K', 'Unavailable']); + } + + render(); + expect(factValues()).toEqual(['openbitfun', 'Unavailable', '~0 / 256K']); + }); + }); describe('Session usage report i18n and theme guards', () => { diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss index f4b2ccd498..481db197e8 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss @@ -24,17 +24,26 @@ overflow-y: auto; } - &__header { - display: grid; - grid-template-columns: var(--openbitfun-control-height-sm) minmax(0, 1fr) var(--openbitfun-control-height-sm); - align-items: center; - gap: var(--openbitfun-space-2); + &__intro { + display: flex; + flex-direction: column; flex: 0 0 auto; min-width: 0; + gap: var(--openbitfun-space-3); margin-block-end: var(--openbitfun-space-3); + } - > [data-openbitfun-part='content'] { grid-column: 2; } - > [data-openbitfun-part='action'] { grid-column: 3; justify-self: end; } + &__eyebrow { + margin: 0; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + &__header { + align-items: flex-start; + gap: var(--openbitfun-space-3); + min-width: 0; } &__title { @@ -56,21 +65,18 @@ &__summary-facts { display: grid; flex: 0 0 auto; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.5fr); + gap: var(--openbitfun-space-5); margin: 0; - padding-block: var(--openbitfun-space-3); - border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-3xl); + padding-block-end: var(--openbitfun-space-6); } &__summary-fact { display: flex; - flex-direction: column-reverse; - justify-content: center; + flex-direction: column; min-inline-size: 0; gap: var(--openbitfun-space-2); - padding-inline: var(--openbitfun-space-3); - text-align: center; + text-align: start; dt { color: var(--openbitfun-color-content-secondary); @@ -79,16 +85,17 @@ } dd { margin: 0; - font-size: var(--openbitfun-type-heading-section-font-size); - font-weight: var(--openbitfun-type-heading-section-font-weight); - line-height: var(--openbitfun-type-heading-section-line-height); + font-size: var(--openbitfun-type-body-lg-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-lg-line-height); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } } - &__summary-fact + &__summary-fact { - border-inline-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); + @container usage-report (max-width: 360px) { + &__summary-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } + &__summary-fact:first-child { grid-column: 1 / -1; } } &__distribution { @@ -110,7 +117,7 @@ &__donut-track, &__donut-segment { fill: none; stroke-width: 24; } - &__donut-track { stroke: color-mix(in srgb, var(--openbitfun-component-session-usage-input) 12%, var(--openbitfun-color-surface-scene)); } + &__donut-track { stroke: var(--openbitfun-color-border-default); } &__donut-segment { stroke: currentColor; } &__donut-segment, @@ -236,6 +243,7 @@ &__series--subagentDefinitions { color: color-mix(in srgb, var(--openbitfun-component-session-usage-input) 50%, var(--openbitfun-color-status-success-emphasis)); } &__series--summarizedConversation { color: var(--openbitfun-color-status-danger-emphasis); } &__series--conversation { color: color-mix(in srgb, var(--openbitfun-color-status-warning-emphasis) 60%, var(--openbitfun-color-status-danger-emphasis)); } + &__series--unused { color: var(--openbitfun-color-border-default); } &__chart-meta { color: var(--openbitfun-color-content-muted); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx index 2b808a28f3..b2949a7d3a 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { MarkdownRenderer } from '@/infrastructure/markdown'; import { Empty, Icon, IconButton, LoadingState, PageHeader, ScrollArea, Tooltip } from '@openbitfun/ui'; -import type { SessionUsageReport, UsageContextCategory } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; import { buildSessionUsageExportMarkdown, isUnusedSession, @@ -12,9 +12,7 @@ import { } from './usageReportUtils'; import { SessionUsageSummary } from './SessionUsageSummary'; import { SessionUsageEmptyState } from './SessionUsageEmptyState'; -import { openSessionContextPreview } from '../../services/openSessionContextPreview'; -import { notificationService } from '@/shared/notification-system'; -import { getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +import { useContextPreview } from './useContextPreview'; import type { SessionUsagePanelTab } from './sessionUsagePanelTypes'; import './SessionUsagePanel.scss'; @@ -27,6 +25,7 @@ interface SessionUsagePanelProps { error?: string; workspacePath?: string; workspaceId?: string; + onTitleChange?: (title: string) => void; /** Accepted for saved panels from versions with detail tabs. */ initialTab?: SessionUsagePanelTab; } @@ -40,16 +39,25 @@ export const SessionUsagePanel: React.FC = ({ error, workspacePath, workspaceId, + onTitleChange, }) => { const { t } = useI18n('flow-chat'); + const reportTitle = t('usage.panel.report'); const [copied, setCopied] = useState(false); - const [previewLoading, setPreviewLoading] = useState(null); + const { previewLoading, previewContext: handlePreviewContext } = useContextPreview({ + sessionId: sessionId ?? report?.sessionId, reportSessionId: report?.sessionId, + workspaceId: workspaceId ?? report?.workspace.workspaceId, + }); const [redactExportPaths, setRedactExportPaths] = useState(getUsageExportRedactPathsPreference); useEffect(() => ( subscribeUsageExportRedactPathsPreference(setRedactExportPaths) ), []); + useEffect(() => { + onTitleChange?.(reportTitle); + }, [onTitleChange, reportTitle]); + const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(buildSessionUsageExportMarkdown(markdown, report, { @@ -63,31 +71,6 @@ export const SessionUsagePanel: React.FC = ({ } }, [markdown, redactExportPaths, report, t]); - const handlePreviewContext = async (category: UsageContextCategory) => { - if (!report || previewLoading) return; - const surface = getActiveSurfaceScope(); - setPreviewLoading(category); - try { - const result = await openSessionContextPreview({ - sessionId: sessionId ?? report.sessionId, - reportSessionId: report.sessionId, - workspaceId: workspaceId ?? report.workspace.workspaceId, - title: `${t(`usage.context.categories.${category}`)}${category === 'toolDefinitions' ? '' : '.md'}`, - category, - emptyContent: t('usage.context.previewEmpty'), - }); - if (surface.isCurrent() && result === 'unavailable') { - notificationService.warning(t('usage.context.previewUnavailable')); - } - } catch (error) { - if (surface.isCurrent() && !isSurfaceChangedError(error)) { - notificationService.error(t('usage.context.previewFailed')); - } - } finally { - setPreviewLoading(null); - } - }; - if (isLoading || error) { return (
@@ -132,24 +115,27 @@ export const SessionUsagePanel: React.FC = ({ : t('usage.unavailable'); return ( - {sessionTitle || t('usage.title')}} - action={ - - : } - /> - - } - /> +
+

{reportTitle}

+ {sessionTitle || reportTitle}} + action={ + + : } + /> + + } + /> +
diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageSummary.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageSummary.tsx index 840ceaef39..8a3dd44c92 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageSummary.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageSummary.tsx @@ -1,9 +1,10 @@ -import { useState, type FocusEvent, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { useUsageSeriesHighlight } from './useUsageSeriesHighlight'; import { Empty, Icon, IconButton, OverflowText, PageHeader, Tooltip } from '@openbitfun/ui'; import { ChartPie, ExternalLink } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import type { SessionUsageReport, UsageContextBreakdown, UsageContextCategory } from '@/infrastructure/api/service-api/SessionAPI'; -import { formatTokenCount, formatUsageNumber, formatUsagePercent } from './usageReportUtils'; +import { formatTokenCount, formatUsagePercent } from './usageReportUtils'; const CONTEXT_CATEGORIES = [ 'systemPrompt', 'toolDefinitions', 'rules', 'skills', 'mcpTools', @@ -25,35 +26,9 @@ interface UsageSeries { shareSuffix?: ReactNode; } -function useSeriesHighlight(rows: UsageSeries[]) { - const [hoveredKey, setHoveredKey] = useState(null); - const [focusedKey, setFocusedKey] = useState(null); - const activeKey = [hoveredKey, focusedKey].find(key => ( - rows.some(row => row.key === key && (row.share ?? 0) > 0) - )); - const activeRow = rows.find(row => row.key === activeKey); - - const highlight = (row: UsageSeries) => { - const hasSegment = (row.share ?? 0) > 0; - const isActive = activeRow !== undefined && (activeKey === row.key - || row.parentKey === activeKey || activeRow.parentKey === row.key); - return { - 'data-usage-active': isActive || undefined, - 'data-usage-muted': activeRow !== undefined && !isActive || undefined, - onMouseEnter: hasSegment ? () => setHoveredKey(row.key) : undefined, - onMouseLeave: hasSegment ? () => setHoveredKey(null) : undefined, - onFocus: hasSegment ? () => setFocusedKey(row.key) : undefined, - onBlur: hasSegment ? (event: FocusEvent) => { - if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null); - } : undefined, - }; - }; - return { activeRow, highlight }; -} - function UsageLegend({ rows, highlight, className = '', showShares = true }: { rows: UsageSeries[]; - highlight: ReturnType['highlight']; + highlight: ReturnType>['highlight']; className?: string; showShares?: boolean; }) { @@ -93,10 +68,13 @@ export function SessionUsageSummary({ report, workspaceName, onPreviewContext, p const context = report.context; const hasContext = !!context && CONTEXT_CATEGORIES.every(key => isCount(context[key])); const contextTotal = hasContext ? CONTEXT_CATEGORIES.reduce((sum, key) => sum + context[key], 0) : 0; - const hasContextDistribution = hasContext && Number.isSafeInteger(contextTotal) && contextTotal > 0; + const hasContextTotal = hasContext && Number.isSafeInteger(contextTotal); + const contextWindow = isCount(context?.contextWindow) && context.contextWindow > 0 + ? context.contextWindow : undefined; + const hasContextDistribution = hasContextTotal && contextWindow !== undefined && contextTotal <= contextWindow; let offset = 0; - const contextRows = CONTEXT_CATEGORIES.map(key => { - const share = hasContextDistribution ? context![key] / contextTotal : undefined; + const contextRows: Array = CONTEXT_CATEGORIES.map(key => { + const share = hasContextDistribution ? context![key] / contextWindow : undefined; const row = { key, label: t(`usage.context.categories.${key}`), value: hasContext ? context![key] : undefined, share, offset, action: key !== 'summarizedConversation' && key !== 'conversation' ? ( @@ -109,6 +87,13 @@ export function SessionUsageSummary({ report, workspaceName, onPreviewContext, p offset += (share ?? 0) * 100; return row; }); + if (hasContextDistribution) { + const unused = contextWindow - contextTotal; + contextRows.push({ + key: 'unused', label: t('usage.context.unused'), value: unused, + share: unused / contextWindow, offset, + }); + } const { inputTokens, outputTokens, totalTokens, cachedTokens, cacheCoverage } = report.tokens; const hasTokenDistribution = isCount(inputTokens) && isCount(outputTokens) && isCount(totalTokens) && inputTokens + outputTokens === totalTokens && totalTokens > 0; @@ -155,8 +140,8 @@ export function SessionUsageSummary({ report, workspaceName, onPreviewContext, p ); } - const { activeRow: activeContext, highlight: highlightContext } = useSeriesHighlight(contextRows); - const { highlight: highlightTokens } = useSeriesHighlight([...tokenRows, ...cacheRows]); + const { activeRow: activeContext, highlight: highlightContext } = useUsageSeriesHighlight(contextRows); + const { highlight: highlightTokens } = useUsageSeriesHighlight([...tokenRows, ...cacheRows]); const tokenUsage = (
@@ -190,10 +175,23 @@ export function SessionUsageSummary({ report, workspaceName, onPreviewContext, p
{t('usage.panel.workspace')}
{workspaceName}
-
{t('usage.panel.totalTokens')}
{formatTokenCount(totalTokens, t)}
+
{t('usage.panel.totalUsage')}
+
{formatTokenCount(isCount(totalTokens) ? totalTokens : undefined, t)}
-
{t('usage.table.turns')}
{formatUsageNumber(report.scope.turnCount, t)}
+
{t('usage.context.label')}
+
+ + {hasContextTotal || contextWindow !== undefined + ? t('usage.context.usageOfCapacity', { + used: hasContextTotal + ? t('usage.context.approximateTokens', { value: formatTokenCount(contextTotal, t) }) + : t('usage.unavailable'), + size: formatTokenCount(contextWindow, t), + }) + : t('usage.unavailable')} + +
{tokenUsage} @@ -201,25 +199,29 @@ export function SessionUsageSummary({ report, workspaceName, onPreviewContext, p {t('usage.context.estimated')} } /> - {hasContextDistribution ? ( -
-
- -
- {activeContext ? formatUsagePercent(activeContext.share! * 100, t) : formatTokenCount(contextTotal, t)} - {activeContext ? activeContext.label : t('usage.context.estimatedTokens')} -
+ {hasContextTotal ? ( + <> + {!hasContextDistribution &&

{t(contextWindow === undefined + ? 'usage.context.capacityUnavailable' : 'usage.context.exceedsCapacity')}

} +
+ {hasContextDistribution &&
+ +
+ {formatUsagePercent((activeContext?.share ?? contextTotal / contextWindow) * 100, t)} + {activeContext ? activeContext.label : t('usage.context.utilization')} +
+
} +
- -
+ ) : } title={t('usage.context.unavailable')} />} diff --git a/src/web-ui/src/flow_chat/components/usage/appearance.ts b/src/web-ui/src/flow_chat/components/usage/appearance.ts index 4fac303445..6924c52780 100644 --- a/src/web-ui/src/flow_chat/components/usage/appearance.ts +++ b/src/web-ui/src/flow_chat/components/usage/appearance.ts @@ -1,5 +1,14 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const contextUsagePopoverAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'context-usage-popover', + componentAttribute: 'data-openbitfun-product-component', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'totals' }, { id: 'bar', propertyProfile: 'layout' }, + { id: 'legend' }, { id: 'item' }, { id: 'status', propertyProfile: 'paint', visualRole: 'content' }, + ], +}; + export const toolDefinitionsPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'tool-definitions-preview', componentAttribute: 'data-openbitfun-product-component', diff --git a/src/web-ui/src/flow_chat/components/usage/useContextPreview.ts b/src/web-ui/src/flow_chat/components/usage/useContextPreview.ts new file mode 100644 index 0000000000..81f49bd4f1 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/useContextPreview.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import type { UsageContextCategory } from '@/infrastructure/api/service-api/SessionAPI'; +import { openSessionContextPreview } from '../../services/openSessionContextPreview'; +import { notificationService } from '@/shared/notification-system'; +import { getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; + +/** Both usage surfaces open the same session-owned, capability-checked preview. */ +export function useContextPreview(params: { sessionId?: string; reportSessionId?: string; workspaceId?: string }) { + const { t } = useI18n('flow-chat'); + const [previewLoading, setPreviewLoading] = useState(null); + const requestRef = useRef(null); + useEffect(() => { + requestRef.current = null; + setPreviewLoading(null); + return () => { requestRef.current = null; }; + }, [params.sessionId, params.reportSessionId, params.workspaceId]); + + const previewContext = async (category: UsageContextCategory) => { + if (!params.sessionId || !params.reportSessionId || requestRef.current) return; + const request = Symbol(); + requestRef.current = request; + const surface = getActiveSurfaceScope(); + const isCurrent = () => surface.isCurrent() && requestRef.current === request; + setPreviewLoading(category); + try { + const result = await openSessionContextPreview({ + sessionId: params.sessionId, reportSessionId: params.reportSessionId, workspaceId: params.workspaceId, + title: `${t(`usage.context.categories.${category}`)}${category === 'toolDefinitions' ? '' : '.md'}`, + category, emptyContent: t('usage.context.previewEmpty'), + }); + if (isCurrent() && result === 'unavailable') notificationService.warning(t('usage.context.previewUnavailable')); + } catch (error) { + if (isCurrent() && !isSurfaceChangedError(error)) notificationService.error(t('usage.context.previewFailed')); + } finally { + if (requestRef.current === request) { + requestRef.current = null; + setPreviewLoading(null); + } + } + }; + return { previewLoading, previewContext }; +} diff --git a/src/web-ui/src/flow_chat/components/usage/useUsageSeriesHighlight.ts b/src/web-ui/src/flow_chat/components/usage/useUsageSeriesHighlight.ts new file mode 100644 index 0000000000..c0c6b97dd0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/useUsageSeriesHighlight.ts @@ -0,0 +1,29 @@ +import { useState, type FocusEvent } from 'react'; + +interface UsageSeriesIdentity { key: string; parentKey?: string; share?: number } + +/** Shared chart/legend interaction for the compact overview and detailed report. */ +export function useUsageSeriesHighlight(rows: T[]) { + const [hoveredKey, setHoveredKey] = useState(null); + const [focusedKey, setFocusedKey] = useState(null); + const activeKey = [hoveredKey, focusedKey].find(key => ( + rows.some(row => row.key === key && (row.share ?? 0) > 0) + )); + const activeRow = rows.find(row => row.key === activeKey); + const highlight = (row: UsageSeriesIdentity) => { + const hasSegment = (row.share ?? 0) > 0; + const isActive = activeRow !== undefined && (activeKey === row.key + || row.parentKey === activeKey || activeRow.parentKey === row.key); + return { + 'data-usage-active': isActive || undefined, + 'data-usage-muted': activeRow !== undefined && !isActive || undefined, + onMouseEnter: hasSegment ? () => setHoveredKey(row.key) : undefined, + onMouseLeave: hasSegment ? () => setHoveredKey(null) : undefined, + onFocus: hasSegment ? () => setFocusedKey(row.key) : undefined, + onBlur: hasSegment ? (event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null); + } : undefined, + }; + }; + return { activeRow, highlight }; +} diff --git a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts index 4090122ef1..79e94c2254 100644 --- a/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts +++ b/src/web-ui/src/flow_chat/services/openSessionUsageReport.ts @@ -94,7 +94,7 @@ export function buildSessionUsagePanelContent(params: SessionUsagePanelData): Pa type: SESSION_USAGE_PANEL_TYPE, // Caller supplies the localized title so this panel helper does not eagerly // initialize the i18n singleton in message-rendering tests. - title: params.title ?? 'Session Usage', + title: params.title ?? 'Context Usage Report', data: params, metadata: { duplicateCheckKey: getSessionUsageDuplicateKey(params.report.reportId), diff --git a/src/web-ui/src/flow_chat/services/usageReportService.test.ts b/src/web-ui/src/flow_chat/services/usageReportService.test.ts index 530d83ede9..baabdb564a 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.test.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.test.ts @@ -110,7 +110,7 @@ const usageReport = (overrides: Partial = {}): SessionUsageR const uiParams = { isProcessing: false, - reportTitle: 'Session Usage', + reportTitle: 'Context Usage Report', busyMessage: 'busy', noWorkspaceMessage: 'missing workspace', failedTitle: 'failed', @@ -143,6 +143,52 @@ describe('runUsageReportCommand', () => { })); }); + it('defaults newly generated report tabs to the context report title', async () => { + sessionApiMocks.getSessionUsageReport.mockResolvedValue(usageReport()); + const { runUsageReportCommand } = await import('./usageReportService'); + await runUsageReportCommand({ session: createSession(), ...uiParams, reportTitle: undefined }); + expect(panelContent()?.title).toBe('Context Usage Report'); + }); + + it('presents a read-only overview during a turn without creating a side-panel tab', async () => { + const report = usageReport(); + sessionApiMocks.getSessionUsageReport.mockResolvedValue(report); + const presentReport = vi.fn(() => true); + const { runUsageReportCommand } = await import('./usageReportService'); + const result = await runUsageReportCommand({ session: createSession(), ...uiParams, isProcessing: true, presentReport }); + expect(result.shown).toBe(true); + expect(presentReport).toHaveBeenCalledWith(report); + expect(panelContent()).toBeUndefined(); + expect(transcriptOf()).toEqual([]); + }); + + it('routes inline dispatch reports to the supplied source and discards an old peer response', async () => { + activateSurface('peer-a'); + let resolve!: (report: SessionUsageReport) => void; + const fetchReport = vi.fn(() => new Promise(done => { resolve = done; })); + const presentReport = vi.fn(() => true); + const { runUsageReportCommand } = await import('./usageReportService'); + const pending = runUsageReportCommand({ session: createSession(), ...uiParams, fetchReport, presentReport }); + expect(panelContent()).toBeUndefined(); + activateSurface('peer-b'); + resolve(usageReport()); + expect((await pending).shown).toBe(false); + expect(presentReport).not.toHaveBeenCalled(); + expect(sessionApiMocks.getSessionUsageReport).not.toHaveBeenCalled(); + }); + + it('opens a legacy report payload without a title and preserves its data through a round trip', async () => { + const { buildSessionUsagePanelContent } = await import('./openSessionUsageReport'); + const legacy = JSON.parse(JSON.stringify({ + report: usageReport(), markdown: '# Legacy report', sessionId: 'session-1', + workspacePath: 'D:/workspace/OpenBitFun', initialTab: 'overview', + })); + const content = buildSessionUsagePanelContent(legacy); + expect(content.title).toBe('Context Usage Report'); + expect(content.metadata?.sessionId).toBe('session-1'); + expect(JSON.parse(JSON.stringify(content)).data).toEqual(legacy); + }); + it('shows the waiting state immediately and replaces it with the report', async () => { let resolveReport: (report: SessionUsageReport) => void = () => {}; sessionApiMocks.getSessionUsageReport.mockReturnValue(new Promise(resolve => { @@ -159,6 +205,7 @@ describe('runUsageReportCommand', () => { sessionId: 'session-1', }); expect(panelData().report).toBeUndefined(); + expect(panelContent()?.title).toBe('Context Usage Report'); resolveReport(usageReport()); const result = await pending; @@ -168,6 +215,7 @@ describe('runUsageReportCommand', () => { expect(state).toMatchObject({ isLoading: false }); expect(state.report?.reportId).toBe('usage-report-1'); expect(state.markdown).toContain('# Session Usage Report'); + expect(panelContent()?.title).toBe('Context Usage Report'); expect(state.markdown).toContain('Session span'); expect(state.markdown).toContain('not reported'); expect(state.markdown).not.toContain('Wall time'); diff --git a/src/web-ui/src/flow_chat/services/usageReportService.ts b/src/web-ui/src/flow_chat/services/usageReportService.ts index 8b9643ac99..50cf164ba4 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.ts @@ -2,6 +2,7 @@ import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; import { notificationService } from '@/shared/notification-system'; import { beginSessionUsagePanel } from './openSessionUsageReport'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { Session } from '../types/flow-chat'; import { requireSessionOwningWorkspaceId } from '../utils/sessionOrdering'; import { i18nService } from '@/infrastructure/i18n'; @@ -18,6 +19,7 @@ type UsageModelIdentitySource = NonNullable boolean; reportTitle?: string; busyMessage: string; noWorkspaceMessage: string; @@ -47,7 +49,8 @@ export interface UsageReportCommandResult { export async function runUsageReportCommand( params: UsageReportCommandParams ): Promise { - if (params.isProcessing) { + // An inline overview only reads the latest recorded request and can be viewed during a turn. + if (params.isProcessing && !params.presentReport) { notificationService.warning(params.busyMessage); return { shown: false, reason: 'busy' }; } @@ -62,12 +65,13 @@ export async function runUsageReportCommand( notificationService.error(params.noWorkspaceMessage); return { shown: false, reason: 'missing_workspace' }; } - const panel = beginSessionUsagePanel({ + const surface = getActiveSurfaceScope(); + const panel = params.presentReport ? undefined : beginSessionUsagePanel({ sessionId: params.session.sessionId, sessionTitle: params.session.title, workspaceId, workspacePath: params.session.workspacePath, - title: params.reportTitle ?? 'Session Usage', + title: params.reportTitle ?? 'Context Usage Report', }); try { @@ -79,11 +83,14 @@ export async function runUsageReportCommand( includeHiddenSubagents: true, }); const report = enrichUsageReportModelIdentity(rawReport, params.session); - const shown = panel.complete(report, renderUsageReportMarkdown(report)); + if (!surface.isCurrent()) return { shown: false }; + const shown = params.presentReport + ? params.presentReport(report) + : panel!.complete(report, renderUsageReportMarkdown(report)); return { shown, report }; } catch (error) { - if (!panel.isCurrent()) return { shown: false }; - panel.fail(error instanceof Error ? error.message : params.unknownErrorMessage); + if (!surface.isCurrent()) return { shown: false }; + panel?.fail(error instanceof Error ? error.message : params.unknownErrorMessage); notificationService.error( error instanceof Error ? error.message : params.unknownErrorMessage, { diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 1c84053eae..5756e6eb99 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -20,6 +20,7 @@ import type { ImageContextData as ImageInputContextData } from '@/infrastructure import type { SessionDriverId } from './resolve'; import type { SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { LineRange } from '@/shared/editor/LineRange'; +import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; export type { SessionDriverId } from './resolve'; @@ -127,9 +128,11 @@ export interface TurnTracker { */ export type StartTurnResult = 'completed' | 'detached' | 'queued'; -/** Localized strings the usage-report flow surfaces; supplied by the caller. */ +/** Presentation and localized strings supplied by the usage-report caller. */ export interface UsageReportUiParams { isProcessing: boolean; + /** Present an inline overview without opening a side-panel tab. False discards a stale request. */ + presentReport?: (report: SessionUsageReport) => boolean; reportTitle?: string; busyMessage: string; noWorkspaceMessage: string; diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index cf6352809b..765a42724c 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -9,7 +9,7 @@ import { markdownAppearanceDescriptor } from '@/infrastructure/markdown/appearan import { chatInputAppearanceDescriptor } from '@/flow_chat/components/ChatInput.appearance'; import { harnessProfileSelectorAppearanceDescriptor } from '@/flow_chat/components/HarnessProfileSelector.appearance'; import { composerVoiceInputAppearanceDescriptor } from '@/flow_chat/components/voice/ComposerVoiceInputButton.appearance'; -import { sessionUsagePanelAppearanceDescriptor, toolDefinitionsPreviewAppearanceDescriptor } from '@/flow_chat/components/usage/appearance'; +import { contextUsagePopoverAppearanceDescriptor, sessionUsagePanelAppearanceDescriptor, toolDefinitionsPreviewAppearanceDescriptor } from '@/flow_chat/components/usage/appearance'; import { richTextInputAppearanceDescriptor } from '@/flow_chat/components/RichTextInput.appearance'; import { modelRoundItemAppearanceDescriptor } from '@/flow_chat/components/modern/ModelRoundItem.appearance'; import { deepReviewActionBarAppearanceDescriptor } from '@/flow_chat/deep-review/action-bar/appearance'; @@ -272,6 +272,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(harnessProfileSelectorAppearanceDescriptor) .registerComponent(composerVoiceInputAppearanceDescriptor) .registerComponent(sessionUsagePanelAppearanceDescriptor) + .registerComponent(contextUsagePopoverAppearanceDescriptor) .registerComponent(toolDefinitionsPreviewAppearanceDescriptor) .registerComponent(richTextInputAppearanceDescriptor) .registerComponent(modelRoundItemAppearanceDescriptor) diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index f3a2d6ba8e..22bf4314cb 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -241,7 +241,20 @@ "notes": "Tool calling instructions" }, "context": { + "viewReport": "View Report", + "collapse": "Collapse context usage", + "full": "{{value}} Full", + "tokenCapacity": "{{used}} / {{size}} Tokens", + "loading": "Loading context usage…", + "loadFailed": "Unable to load context usage. Close and reopen to try again.", "title": "Context composition", + "label": "Context", + "utilization": "Context usage", + "unused": "Unused", + "capacityUnavailable": "Context capacity was not reported. Only recorded usage by category is shown.", + "exceedsCapacity": "Estimated context usage exceeds the reported capacity. Only recorded usage by category is shown.", + "usageOfCapacity": "{{used}} / {{size}}", + "approximateTokens": "~{{value}}", "estimated": "Estimated", "estimatedTokens": "Estimated tokens", "estimateHelp": "Estimated from the latest recorded model request. Rules include workspace and user instructions; Skills and subagents include their listings. These are not provider-reported token counts.", @@ -442,9 +455,10 @@ "toolErrors": "Tool errors", "modelErrors": "Model errors", "errorScope": "Error scope", - "report": "Session usage report", + "report": "Context Usage Report", "workspace": "Project", "totalTokens": "Total tokens", + "totalUsage": "Total usage", "explorer": "Usage explorer", "cachedSubset": "Cached tokens are included in input tokens, not added to the total again.", "distributionUnavailable": "A token breakdown is unavailable because complete, consistent input and output counts were not reported.", diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index f445231dd6..6e1e585b21 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -1,4 +1,9 @@ { + "navigation": { + "add": "Add to left navigation", + "remove": "Remove from left navigation", + "removeApp": "Remove {{name}} from left navigation" + }, "presentation": { "titlebar": "App window controls", "minimize": "$t(shared:window.minimize)", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index a190fdaf50..db050d59ae 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -241,7 +241,20 @@ "notes": "工具调用说明" }, "context": { + "viewReport": "查看报告", + "collapse": "收起上下文用量", + "full": "已使用 {{value}}", + "tokenCapacity": "{{used}} / {{size}} Tokens", + "loading": "正在加载上下文用量…", + "loadFailed": "无法加载上下文用量,请收起后重新打开。", "title": "上下文构成", + "label": "上下文", + "utilization": "使用占比", + "unused": "未使用", + "capacityUnavailable": "未提供上下文容量,仅显示已记录的用量构成。", + "exceedsCapacity": "上下文估算用量超出已报告的容量,仅显示已记录的用量构成。", + "usageOfCapacity": "{{used}} / {{size}}", + "approximateTokens": "~{{value}}", "estimated": "估算", "estimatedTokens": "估算 Token", "estimateHelp": "基于最近一次已记录的模型请求估算。规则包含项目及用户指令,技能和子代理包含其定义列表;分类数量并非模型提供方上报的精确值。", @@ -407,9 +420,10 @@ "toolErrors": "工具错误", "modelErrors": "模型错误", "errorScope": "错误范围", - "report": "会话用量报告", + "report": "上下文使用报告", "workspace": "项目", "totalTokens": "累计 Token", + "totalUsage": "总用量", "explorer": "用量明细", "cachedSubset": "缓存 Token 已包含在输入 Token 中,不会重复计入总量。", "distributionUnavailable": "未记录完整且一致的输入与输出数量,暂时无法展示 Token 分布。", diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index f716e3ae78..90d5e1cb84 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -1,4 +1,9 @@ { + "navigation": { + "add": "添加到左侧导航", + "remove": "从左侧导航移除", + "removeApp": "从左侧导航移除 {{name}}" + }, "presentation": { "titlebar": "应用窗口控制", "minimize": "$t(shared:window.minimize)", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index f19e6d94c4..e805f3834e 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -241,7 +241,20 @@ "notes": "工具呼叫說明" }, "context": { + "viewReport": "查看報告", + "collapse": "收合上下文用量", + "full": "已使用 {{value}}", + "tokenCapacity": "{{used}} / {{size}} Tokens", + "loading": "正在載入上下文用量…", + "loadFailed": "無法載入上下文用量,請收合後重新開啟。", "title": "上下文組成", + "label": "上下文", + "utilization": "使用占比", + "unused": "未使用", + "capacityUnavailable": "未提供上下文容量,僅顯示已記錄的用量組成。", + "exceedsCapacity": "上下文估算用量超出已回報的容量,僅顯示已記錄的用量組成。", + "usageOfCapacity": "{{used}} / {{size}}", + "approximateTokens": "~{{value}}", "estimated": "估算", "estimatedTokens": "估算 Token", "estimateHelp": "依據最近一次已記錄的模型請求估算。規則包含專案及使用者指令,技能與子代理包含其定義清單;分類數量並非模型提供方回報的精確值。", @@ -407,9 +420,10 @@ "toolErrors": "工具錯誤", "modelErrors": "模型錯誤", "errorScope": "錯誤範圍", - "report": "工作階段用量報告", + "report": "上下文使用報告", "workspace": "專案", "totalTokens": "累計 Token", + "totalUsage": "總用量", "explorer": "用量明細", "cachedSubset": "快取 Token 已包含在輸入 Token 中,不會重複計入總量。", "distributionUnavailable": "未記錄完整且一致的輸入與輸出數量,暫時無法顯示 Token 分布。", diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index cd8abfd518..8cde33105f 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -1,4 +1,9 @@ { + "navigation": { + "add": "新增至左側導覽", + "remove": "從左側導覽移除", + "removeApp": "從左側導覽移除 {{name}}" + }, "presentation": { "titlebar": "應用視窗控制", "minimize": "$t(shared:window.minimize)",