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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions src/web-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({
createProject: vi.fn(),
pickDirectory: vi.fn(),
options: {} as Record<string, WorkspaceSourceOption[]>,
activeTabId: 'personal-overview',
}));
const t = (key: string) => key;
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t }) }));
Expand All @@ -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() } }));
Expand All @@ -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: () => <button data-testid="miniapp-shortcut">Notes</button> }));
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 }) => (
Expand All @@ -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;
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/app/components/NavPanel/MainNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -302,6 +305,7 @@ const MainNav: React.FC = () => {
{taskBoardLabel}
</NavigationPanelItem>
</Tooltip>
{pinnedAppIds.length > 0 && <Suspense fallback={null}><MiniAppShortcuts /></Suspense>}
</div>

{/* Unified sessions */}
Expand Down
32 changes: 32 additions & 0 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<MiniAppShortcuts />); });
const row = (id: string) => host.querySelector<HTMLButtonElement>(`[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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);
});
});
Loading
Loading