From 58b722717b7085c6e45d21812cfc81f35700b973 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 29 Aug 2026 11:00:27 +0800 Subject: [PATCH 1/2] fix(web-ui): make startup welcome tabless --- README.md | 2 +- .../components/MiniAppEntry.activity.test.tsx | 8 ++-- .../src/app/components/SceneBar/SceneBar.tsx | 2 +- .../src/app/components/SceneBar/types.ts | 1 - .../SceneBar/useSceneTabNavigation.ts | 2 +- .../components/SceneTopBar/SceneChrome.tsx | 8 ++-- src/web-ui/src/app/layout/AppLayout.tsx | 2 +- src/web-ui/src/app/scenes/SceneViewport.scss | 22 +-------- .../src/app/scenes/SceneViewport.test.tsx | 27 ++++++++++- src/web-ui/src/app/scenes/SceneViewport.tsx | 16 +++---- src/web-ui/src/app/scenes/registry.test.ts | 5 ++ src/web-ui/src/app/scenes/registry.ts | 9 ---- .../src/app/scenes/welcome/WelcomeScene.tsx | 6 +-- src/web-ui/src/app/stores/sceneStore.test.ts | 26 ++++++---- src/web-ui/src/app/stores/sceneStore.ts | 48 +++++++++++-------- 15 files changed, 97 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 694f72f284..cea218cf7a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Prerequisites: [Node.js](https://nodejs.org/) 22.12+ (LTS recommended), [pnpm](h ### First run -1. Launch BitFun, click **Open** on the Welcome tab, and choose a project folder. +1. Launch BitFun, then use the workspace controls to open a project folder. 2. Open **More options (…) → Settings → Models → Create First Configuration**. 3. Choose a provider, enter its API key, select one or more models, and click **Save**. BitFun makes the first saved model primary and tests the connection automatically. 4. Return to the **Session** tab, type a concrete task, and press Enter or click **Send**. diff --git a/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.activity.test.tsx b/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.activity.test.tsx index d0bf19ce15..ab1ab08823 100644 --- a/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.activity.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/MiniAppEntry.activity.test.tsx @@ -66,10 +66,10 @@ describe('MiniAppEntry activity', () => { container.remove(); useMiniAppStore.setState({ apps: [], runningWorkerIds: [], customizingAppIds: [] }); useSceneStore.setState({ - openTabs: [{ id: 'welcome', lastUsed: 1 }], - activeTabId: 'welcome', - navHistory: ['welcome'], - navCursor: 0, + openTabs: [], + activeTabId: null, + navHistory: [], + navCursor: -1, }); }); diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 108ddd2a3a..f0257d9993 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -152,7 +152,7 @@ const SceneBar: React.FC = ({ className="bitfun-scene-bar__tabs" aria-label={t('sceneBar.tabsLabel')} items={tabItems} - value={activeTabId} + value={activeTabId ?? undefined} onValueChange={handleTabValueChange} onScroll={handleTabsScroll} onWheel={handleTabsWheel} diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 931511d78f..c2c5ad44a0 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -13,7 +13,6 @@ export type SceneTabIcon = (props: SceneTabIconProps) => ReactNode; /** Scene tab identifier. Open scenes are kept until the user closes them. */ export type SceneTabId = - | 'welcome' | 'session' | 'terminal' | 'git' diff --git a/src/web-ui/src/app/components/SceneBar/useSceneTabNavigation.ts b/src/web-ui/src/app/components/SceneBar/useSceneTabNavigation.ts index 9a20af3831..f80f240d48 100644 --- a/src/web-ui/src/app/components/SceneBar/useSceneTabNavigation.ts +++ b/src/web-ui/src/app/components/SceneBar/useSceneTabNavigation.ts @@ -18,7 +18,7 @@ interface TabScrollState { } interface UseSceneTabNavigationOptions { - activeTabId: SceneTabId; + activeTabId: SceneTabId | null; navigationMotion: InteractionMotion; openTabIds: readonly SceneTabId[]; } diff --git a/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx b/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx index 3b5b01b48a..7ec83e7347 100644 --- a/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx +++ b/src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx @@ -17,7 +17,7 @@ interface SceneChromeContributionRecord { } interface SceneChromeContextValue { - activeSceneId: SceneTabId; + activeSceneId: SceneTabId | null; setContribution: ( sceneId: SceneTabId, owner: symbol, @@ -30,7 +30,7 @@ const SceneChromeContext = createContext(null); const SceneChromeContentContext = createContext(null); interface SceneChromeProviderProps { - activeSceneId: SceneTabId; + activeSceneId: SceneTabId | null; children: ReactNode; } @@ -73,7 +73,9 @@ export const SceneChromeProvider: React.FC = ({ setContribution, removeContribution, }), [activeSceneId, removeContribution, setContribution]); - const activeContent = contributions.get(activeSceneId)?.content ?? null; + const activeContent = activeSceneId + ? contributions.get(activeSceneId)?.content ?? null + : null; return ( diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index d256912c83..4bea2d7af5 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -200,7 +200,7 @@ const AppLayout: React.FC = ({ className = '' }) => { }, [canUseNativeWindowControls, handleToggleFullscreen, isToolbarMode, showWindowFullscreenHint]); const activeSceneId = useSceneStore(s => s.activeTabId); const isAgentScene = activeSceneId === 'session'; - const isWelcomeScene = activeSceneId === 'welcome'; + const isWelcomeScene = activeSceneId === null; const isTransitioning = false; const transitionDir: TransitionDirection = null; diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index d27dd7c390..809fbdd2c8 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -22,27 +22,7 @@ border-radius: inherit; } - // ── Welcome overlay (app start) ────────────────────── - - &__clip--welcome { - display: flex; - align-items: center; - justify-content: center; - } - - // ── Empty state (all tabs closed) ───────────────────── - - &__clip--empty { - display: flex; - align-items: center; - justify-content: center; - } - - &__empty-hint { - color: var(--bf-appearance-token-color-text-muted); - font-size: var(--bf-appearance-token-font-size-sm); - margin: 0; - } + // ── Tabless welcome state ───────────────────────────── &__empty { display: flex; diff --git a/src/web-ui/src/app/scenes/SceneViewport.test.tsx b/src/web-ui/src/app/scenes/SceneViewport.test.tsx index 8175447b7c..9a96a7f533 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.test.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.test.tsx @@ -17,7 +17,7 @@ const sceneHarness = vi.hoisted(() => { return { state: { openTabs: [{ id: 'session', lastUsed: 0 }], - activeTabId: 'session', + activeTabId: 'session' as string | null, navigationMotion: 'instant', navigationSequence: 0, }, @@ -55,6 +55,10 @@ vi.mock('./assistant/AssistantScene', () => ({ default: () =>
, })); +vi.mock('./welcome/WelcomeScene', () => ({ + default: () =>
, +})); + vi.mock('./agents/AgentsScene', () => ({ default: () => { if (!sceneHarness.agentsAreReady()) { @@ -85,6 +89,12 @@ describe('SceneViewport transitions', () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); + sceneHarness.state = { + openTabs: [{ id: 'session', lastUsed: 0 }], + activeTabId: 'session', + navigationMotion: 'instant', + navigationSequence: 0, + }; }); afterEach(() => { @@ -99,6 +109,21 @@ describe('SceneViewport transitions', () => { .filter(scene => scene.classList.contains('bitfun-scene-viewport__scene--visible')); } + it('renders the welcome surface when no tab is open', () => { + sceneHarness.state = { + openTabs: [], + activeTabId: null, + navigationMotion: 'instant', + navigationSequence: 0, + }; + + act(() => root.render()); + + expect(visibleScenes()).toHaveLength(1); + expect(container.querySelector('[data-testid="welcome-scene"]')).not.toBeNull(); + expect(container.querySelector('[role="tab"]')).toBeNull(); + }); + it('keeps one scene visible while a lazy pointer target becomes ready', async () => { act(() => root.render()); expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['session']); diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index 62e7d14765..592f9eb563 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -4,8 +4,8 @@ * All open scenes stay mounted, but only the active tab is visible, preserving * state across tab switches until the user explicitly closes a scene. * - * 'welcome' is a proper scene tab; it auto-closes when any other - * scene is explicitly opened. + * When no tabs are open, the viewport renders WelcomeScene as a shell-owned + * landing surface rather than manufacturing a tab for it. */ import React, { @@ -25,6 +25,7 @@ import { DotMatrixLoader } from '@/component-library'; import SettingsScene from './settings/SettingsScene'; import AssistantScene from './assistant/AssistantScene'; import SessionScene from './session/SessionScene'; +import WelcomeScene from './welcome/WelcomeScene'; import './SceneViewport.scss'; // Session is the primary interaction path. Keep it in the main scene bundle so @@ -44,7 +45,6 @@ const BrowserScene = lazy(() => import('./browser/BrowserScene')); const TodosScene = lazy(() => import('./todos/TodosScene')); const InsightsScene = lazy(() => import('./my-agent/InsightsScene')); const ShellScene = lazy(() => import('./shell/ShellScene')); -const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); const MiniAppScene = lazy(() => import('./miniapps/MiniAppScene')); const PanelViewScene = lazy(() => import('./panel-view/PanelViewScene')); @@ -94,9 +94,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering navigationSequence, } = useSceneManager(); const { t } = useI18n('common'); - const activeRenderedSceneId: RenderedSceneId = openTabs.length === 0 - ? EMPTY_SCENE_ID - : activeTabId; + const activeRenderedSceneId: RenderedSceneId = activeTabId ?? EMPTY_SCENE_ID; const [transition, setTransition] = useState(null); const [readyVersion, setReadyVersion] = useState(0); const readySceneIdsRef = useRef>(new Set([EMPTY_SCENE_ID])); @@ -243,7 +241,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering data-scene-active={isActive ? 'true' : 'false'} data-bf-scene="workbench" data-bf-part="scene" - data-bf-scene-id={isEmpty ? undefined : tabId} + data-bf-scene-id={isEmpty ? 'welcome' : tabId} data-bf-state={[ isActive && 'active', isEmpty && 'empty', @@ -257,7 +255,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering data-bf-part="empty" data-bf-state="empty" > -

{t('welcomeScene.emptyHint')}

+
) : ( ; case 'session': return ; case 'terminal': diff --git a/src/web-ui/src/app/scenes/registry.test.ts b/src/web-ui/src/app/scenes/registry.test.ts index afad5414a0..53b6ab707e 100644 --- a/src/web-ui/src/app/scenes/registry.test.ts +++ b/src/web-ui/src/app/scenes/registry.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from 'vitest'; import { SCENE_TAB_REGISTRY, getSceneDef } from './registry'; describe('scene tab icon registry', () => { + it('does not register a default welcome tab', () => { + expect(SCENE_TAB_REGISTRY.map(scene => scene.id)).not.toContain('welcome'); + expect(SCENE_TAB_REGISTRY.some(scene => scene.defaultOpen)).toBe(false); + }); + it('uses the design-system SessionIcon only for the session tab', () => { expect(getSceneDef('session')?.Icon).toBe(SessionIcon); expect( diff --git a/src/web-ui/src/app/scenes/registry.ts b/src/web-ui/src/app/scenes/registry.ts index abf2e50a3f..ac0535340c 100644 --- a/src/web-ui/src/app/scenes/registry.ts +++ b/src/web-ui/src/app/scenes/registry.ts @@ -28,15 +28,6 @@ function catalogSceneIcon(name: IconName): SceneTabIcon { } export const SCENE_TAB_REGISTRY: SceneTabDef[] = [ - { - id: 'welcome' as SceneTabId, - label: 'Welcome', - labelKey: 'welcomeScene.tabLabel', - Icon: catalogSceneIcon('side-chat'), - pinned: false, - singleton: true, - defaultOpen: true, - }, { id: 'session' as SceneTabId, label: 'Session', diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx index 0dad2b0daf..7e40ce2420 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx @@ -1,8 +1,6 @@ /** - * WelcomeScene — the lightweight landing scene shown inside SceneViewport. - * - * It remains a regular scene tab. This phase establishes the greeting region; - * composer integration is deliberately deferred to the next shell iteration. + * WelcomeScene — the lightweight, tabless landing surface shown by + * SceneViewport until the user opens a scene. */ import React, { useState } from 'react'; diff --git a/src/web-ui/src/app/stores/sceneStore.test.ts b/src/web-ui/src/app/stores/sceneStore.test.ts index 15ed664487..d7b5fef4b2 100644 --- a/src/web-ui/src/app/stores/sceneStore.test.ts +++ b/src/web-ui/src/app/stores/sceneStore.test.ts @@ -12,8 +12,17 @@ describe('sceneStore transition snapshots', () => { vi.restoreAllMocks(); }); - it('publishes the first scene switch atomically without a blank active scene', () => { - const snapshots: Array<{ activeTabId: string; openTabIds: string[] }> = []; + it('starts on the welcome surface without creating a tab', () => { + const state = useSceneStore.getState(); + + expect(state.openTabs).toEqual([]); + expect(state.activeTabId).toBeNull(); + expect(state.navHistory).toEqual([]); + expect(state.navCursor).toBe(-1); + }); + + it('publishes the first scene switch atomically from the tabless welcome surface', () => { + const snapshots: Array<{ activeTabId: string | null; openTabIds: string[] }> = []; const unsubscribe = useSceneStore.subscribe(state => { snapshots.push({ activeTabId: state.activeTabId, @@ -26,8 +35,7 @@ describe('sceneStore transition snapshots', () => { expect(snapshots).toHaveLength(1); expect(snapshots[0].activeTabId).toBe('settings'); - expect(snapshots[0].openTabIds).toContain('settings'); - expect(snapshots[0].openTabIds).not.toContain('welcome'); + expect(snapshots[0].openTabIds).toEqual(['session', 'settings']); }); it('records pointer scene navigation without animating keyboard activation', () => { @@ -104,7 +112,7 @@ describe('sceneStore transition snapshots', () => { expect(useSceneStore.getState().activeTabId).toBe('terminal'); }); - it('resets an expanded tab set when the peer host changes', () => { + it('resets an expanded tab set to the tabless welcome surface when the peer host changes', () => { useSceneStore.getState().openScene('settings'); useSceneStore.getState().openScene('terminal'); useSceneStore.getState().openScene('git'); @@ -113,9 +121,9 @@ describe('sceneStore transition snapshots', () => { useSceneStore.getState().resetForPeerSwitch(); const state = useSceneStore.getState(); - expect(state.openTabs.map(tab => tab.id)).toEqual(['welcome']); - expect(state.activeTabId).toBe('welcome'); - expect(state.navHistory).toEqual(['welcome']); - expect(state.navCursor).toBe(0); + expect(state.openTabs).toEqual([]); + expect(state.activeTabId).toBeNull(); + expect(state.navHistory).toEqual([]); + expect(state.navCursor).toBe(-1); }); }); diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index fc32c18714..d7fd89da7e 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -4,8 +4,8 @@ * Tab rules: * - Every explicitly opened scene stays in openTabs until the user closes it. * - Pinned tabs (e.g. session/agent) cannot be manually closed. - * - 'welcome' tab is the default initial tab; it auto-closes the first time - * any other scene is explicitly opened. + * - The app starts with no tabs. SceneViewport owns the tabless welcome + * surface until the first scene is explicitly opened. * * Navigation history (navHistory / navCursor): * - Records the sequence of activeTabId changes. @@ -26,7 +26,6 @@ import { import type { SceneTab, SceneTabId } from '../components/SceneBar/types'; const AGENT_SCENE_ID: SceneTabId = 'session'; -const WELCOME_SCENE_ID: SceneTabId = 'welcome'; function getSceneDefOrMiniapp(id: SceneTabId) { const d = getSceneDef(id); @@ -47,13 +46,14 @@ function buildSceneTab(id: SceneTabId, now: number): SceneTab { return { id, lastUsed: now }; } -function resolveNavSceneId(sceneId: SceneTabId): SceneTabId | null { +function resolveNavSceneId(sceneId: SceneTabId | null): SceneTabId | null { + if (sceneId === null) return null; return getSceneNav(sceneId) ? sceneId : null; } interface SceneState { openTabs: SceneTab[]; - activeTabId: SceneTabId; + activeTabId: SceneTabId | null; /** Ordered history of activeTabId values. */ navHistory: SceneTabId[]; /** Index of the current position in navHistory. */ @@ -112,13 +112,13 @@ function removeFromHistory( } const initialTabs = buildDefaultTabs(); -const initialActiveId: SceneTabId = initialTabs[0]?.id ?? WELCOME_SCENE_ID; +const initialActiveId = initialTabs[0]?.id ?? null; export const useSceneStore = create((set, get) => ({ openTabs: initialTabs, activeTabId: initialActiveId, - navHistory: [initialActiveId], - navCursor: 0, + navHistory: initialActiveId ? [initialActiveId] : [], + navCursor: initialActiveId ? 0 : -1, navigationMotion: 'instant', navigationSequence: 0, @@ -146,15 +146,9 @@ export const useSceneStore = create((set, get) => ({ let navHistory = state.navHistory; let navCursor = state.navCursor; - // Compute welcome removal and the target activation as one store update. - // Publishing the intermediate "welcome is active but no longer mounted" - // snapshot gives React a blank viewport that looks like a full page refresh. - if (id !== WELCOME_SCENE_ID && openTabs.some(tab => tab.id === WELCOME_SCENE_ID)) { - openTabs = openTabs.filter(tab => tab.id !== WELCOME_SCENE_ID); - navHistory = navHistory.filter(historyId => historyId !== WELCOME_SCENE_ID); - navCursor = Math.max(0, navHistory.length - 1); - - // If the first opened scene is not session, companion-open session alongside it. + // If the first opened scene is not session, companion-open the pinned + // session tab alongside it without turning the welcome surface into a tab. + if (openTabs.length === 0) { if (id !== AGENT_SCENE_ID && !openTabs.some(tab => tab.id === AGENT_SCENE_ID)) { openTabs = [buildSceneTab(AGENT_SCENE_ID, 0), ...openTabs]; } @@ -203,7 +197,7 @@ export const useSceneStore = create((set, get) => ({ if (nextTabs.length === 0) { set({ openTabs: [], - activeTabId: '' as SceneTabId, + activeTabId: null, navHistory: [], navCursor: -1, navigationMotion: getInteractionMotion(), @@ -214,6 +208,18 @@ export const useSceneStore = create((set, get) => ({ newActiveId = [...nextTabs].sort((a, b) => b.lastUsed - a.lastUsed)[0].id; } + if (newActiveId === null) { + set({ + openTabs: [], + activeTabId: null, + navHistory: [], + navCursor: -1, + navigationMotion: getInteractionMotion(), + navigationSequence: state.navigationSequence + 1, + }); + return; + } + const histUpdate = removeFromHistory(navHistory, navCursor, id, newActiveId); set({ openTabs: ensureAgentFirst(nextTabs), @@ -267,12 +273,12 @@ export const useSceneStore = create((set, get) => ({ resetForPeerSwitch: () => { const state = get(); const tabs = buildDefaultTabs(); - const activeTabId: SceneTabId = tabs[0]?.id ?? WELCOME_SCENE_ID; + const activeTabId = tabs[0]?.id ?? null; set({ openTabs: tabs, activeTabId, - navHistory: [activeTabId], - navCursor: 0, + navHistory: activeTabId ? [activeTabId] : [], + navCursor: activeTabId ? 0 : -1, navigationMotion: 'instant', navigationSequence: state.navigationSequence + 1, }); From a54e55062b1093386bff621924b54df463f3072a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 29 Aug 2026 11:04:15 +0800 Subject: [PATCH 2/2] fix(capabilities): drop welcome tab owner --- src/shared/interactive-capabilities/catalog.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 01230a6f78..1d0a8dc0b3 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -8889,7 +8889,6 @@ "settings.shortcuts.open": "setting.application.input" }, "sceneOwners": { - "welcome": "feature.ai-assistant", "session": "feature.ai-assistant", "terminal": "feature.terminal", "git": "feature.git",