diff --git a/package.json b/package.json index b259d03aab7..82815816638 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ ], "scripts": { "lint": "pnpm run local-web:lint && pnpm run ui:lint && pnpm run backend:lint && node scripts/check-unused-i18n-keys.mjs", - "format": "pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format", + "format": "pnpm run backend:format && pnpm run web-core:format && pnpm run local-web:format && pnpm run remote-web:format && pnpm run ui:format", "check": "pnpm run local-web:legacy-path-guard && pnpm run local-web:check && pnpm run remote-web:check && pnpm run web-core:check && pnpm run ui:check && pnpm run backend:check", "dev": "export FRONTEND_PORT=$(node scripts/setup-dev-environment.js frontend) && export BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) && export PREVIEW_PROXY_PORT=$(node scripts/setup-dev-environment.js preview_proxy) && export VK_ALLOWED_ORIGINS=\"http://localhost:${FRONTEND_PORT}\" && export VITE_VK_SHARED_API_BASE=${VK_SHARED_API_BASE:-} && concurrently \"pnpm run backend:dev:watch\" \"pnpm run local-web:dev\"", "test:npm": "./test-npm-package.sh", @@ -26,6 +26,7 @@ "remote-web:format": "pnpm --filter @vibe/remote-web run format", "ui:lint": "pnpm --filter @vibe/ui run lint", "ui:check": "pnpm --filter @vibe/ui run check", + "ui:format": "pnpm --filter @vibe/ui run format", "backend:lint": "cargo clippy --workspace --all-targets --features qa-mode -- -D warnings && cargo clippy --manifest-path crates/remote/Cargo.toml --all-targets -- -D warnings", "backend:format": "cargo fmt --all && cargo fmt --all --manifest-path crates/remote/Cargo.toml", "backend:dev": "BACKEND_PORT=$(node scripts/setup-dev-environment.js backend) pnpm run backend:dev:watch", diff --git a/packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx b/packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx index 9a60bfbc615..a60c83c9bc1 100644 --- a/packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx +++ b/packages/remote-web/src/app/layout/RemoteNavbarContainer.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useMemo, type ReactNode } from "react"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; -import { - MOBILE_TABS, - Navbar, - type MobileTabId, -} from "@vibe/ui/components/Navbar"; +import { MOBILE_TABS, Navbar } from "@vibe/ui/components/Navbar"; import { SettingsDialog } from "@/shared/dialogs/settings/SettingsDialog"; import { CommandBarDialog } from "@/shared/dialogs/command-bar/CommandBarDialog"; import { useMobileActiveTab } from "@/shared/stores/useUiPreferencesStore"; @@ -27,7 +23,9 @@ export function RemoteNavbarContainer({ const remoteMobileTabs = useMemo( () => - MOBILE_TABS.filter((t) => t.id !== "preview" && t.id !== "workspaces"), + MOBILE_TABS.filter( + (t) => t.id !== "preview" && t.id !== "workspaces" && t.id !== "files", + ), [], ); @@ -83,7 +81,7 @@ export function RemoteNavbarContainer({ mobileShowBack={mobileShowBack} onOpenSettings={handleOpenSettings} onOpenCommandBar={handleOpenCommandBar} - mobileActiveTab={mobileActiveTab as MobileTabId} + mobileActiveTab={mobileActiveTab} onMobileTabChange={(tab) => setMobileActiveTab(tab)} mobileTabs={remoteMobileTabs} showMobileTabs={isOnWorkspaceView} diff --git a/packages/ui/src/components/Navbar.tsx b/packages/ui/src/components/Navbar.tsx index 295dd5d82cb..7b187b80998 100644 --- a/packages/ui/src/components/Navbar.tsx +++ b/packages/ui/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import { Terminal as TerminalIcon, Desktop as DesktopIcon, GitFork as GitForkIcon, + FolderSimple as FolderIcon, List as ListIcon, Gear as GearIcon, CaretLeft as CaretLeftIcon, @@ -90,13 +91,17 @@ function NavbarIconButton({ ); } +// Keep in lockstep with MobileTab in +// packages/web-core/src/shared/stores/useUiPreferencesStore.ts — ui can't +// depend on web-core, so these two unions are intentionally duplicated. export type MobileTabId = | 'workspaces' | 'chat' | 'changes' | 'logs' | 'preview' - | 'git'; + | 'git' + | 'files'; export const MOBILE_TABS: { id: MobileTabId; icon: Icon; label: string }[] = [ { id: 'workspaces', icon: LayoutIcon, label: 'Wksps' }, @@ -105,6 +110,7 @@ export const MOBILE_TABS: { id: MobileTabId; icon: Icon; label: string }[] = [ { id: 'logs', icon: TerminalIcon, label: 'Logs' }, { id: 'preview', icon: DesktopIcon, label: 'Preview' }, { id: 'git', icon: GitForkIcon, label: 'Git' }, + { id: 'files', icon: FolderIcon, label: 'Files' }, ]; export interface NavbarBreadcrumbItem { @@ -264,6 +270,10 @@ export function Navbar({ diff --git a/packages/ui/src/lib/cn.ts b/packages/ui/src/lib/cn.ts index b4b4fa298c7..cc4d0c930c2 100644 --- a/packages/ui/src/lib/cn.ts +++ b/packages/ui/src/lib/cn.ts @@ -1,5 +1,9 @@ import { type ClassValue, clsx } from 'clsx'; +// Intentionally bare clsx (no tailwind-merge) until the Tailwind v4 migration, +// matching web-core's cn. Never combine conflicting utilities in one cn() call; +// pass mutually exclusive values (for example, with a ternary), because cn() +// will not deduplicate or resolve which one wins. export function cn(...inputs: ClassValue[]) { return clsx(inputs); } diff --git a/packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx b/packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx index 581b6f65ccf..084d62b1a3b 100644 --- a/packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx +++ b/packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx @@ -12,6 +12,7 @@ import type { CreateModeInitialState } from '@/shared/types/createMode'; import { useWorkspaceContext } from '@/shared/hooks/useWorkspaceContext'; import { usePageTitle } from '@/shared/hooks/usePageTitle'; import { useIsMobile } from '@/shared/hooks/useIsMobile'; +import { useHostId } from '@/shared/providers/HostIdProvider'; import { useMobileActiveTab } from '@/shared/stores/useUiPreferencesStore'; import { cn } from '@/shared/lib/utils'; import { CreateModeProvider } from '@/features/create-mode/model/CreateModeProvider'; @@ -30,6 +31,7 @@ import { type WorkspacesMainContainerHandle, } from './WorkspacesMainContainer'; import { RightSidebar } from './RightSidebar'; +import { WorkspaceFilesContainer } from './WorkspaceFilesContainer'; import { ChangesPanelContainer } from './ChangesPanelContainer'; import { CreateChatBoxContainer } from '@/shared/components/CreateChatBoxContainer'; import { PreviewBrowserContainer } from './PreviewBrowserContainer'; @@ -120,6 +122,7 @@ export function WorkspacesLayout() { const isMobile = useIsMobile(); const [mobileTab] = useMobileActiveTab(); + const hostId = useHostId(); const mainContainerRef = useRef(null); useLayoutEffect(() => { @@ -229,6 +232,11 @@ export function WorkspacesLayout() { ); // ── Mobile layout ────────────────────────────────────────────────── + // These seven panes must stay in lockstep with MobileTabId + // (packages/ui/src/components/Navbar.tsx) and MobileTab + // (packages/web-core/src/shared/stores/useUiPreferencesStore.ts). Adding a + // tab to those unions without a matching pane here compiles cleanly but + // silently renders a dead tab button. // Uses `hidden` CSS class (NOT conditional rendering) to preserve // WebSocket connections and scroll positions across tab switches. if (isMobile) { @@ -355,6 +363,18 @@ export function WorkspacesLayout() { /> )} + + {/* Files tab */} +
+ {selectedWorkspace?.id && !hostId && ( + + )} +
diff --git a/packages/web-core/src/shared/components/ui-new/containers/NavbarContainer.tsx b/packages/web-core/src/shared/components/ui-new/containers/NavbarContainer.tsx index f496755ce84..ea8dde986ee 100644 --- a/packages/web-core/src/shared/components/ui-new/containers/NavbarContainer.tsx +++ b/packages/web-core/src/shared/components/ui-new/containers/NavbarContainer.tsx @@ -6,9 +6,10 @@ import { useSyncErrorContext } from '@/shared/hooks/useSyncErrorContext'; import { useUserOrganizations } from '@/shared/hooks/useUserOrganizations'; import { useOrganizationStore } from '@/shared/stores/useOrganizationStore'; import { + MOBILE_TABS, Navbar, - type NavbarSectionItem, type MobileTabId, + type NavbarSectionItem, } from '@vibe/ui/components/Navbar'; import { Tooltip } from '@vibe/ui/components/Tooltip'; import { AppBarUserPopoverContainer } from './AppBarUserPopoverContainer'; @@ -37,6 +38,7 @@ import { CommandBarDialog } from '@/shared/dialogs/command-bar/CommandBarDialog' import { SettingsDialog } from '@/shared/dialogs/settings/SettingsDialog'; import { useAppNavigation } from '@/shared/hooks/useAppNavigation'; import { getRemoteAuthDegradedMessage } from '@/shared/lib/auth/remoteAuthDegraded'; +import { useHostId } from '@/shared/providers/HostIdProvider'; /** * Check if a NavbarItem is a divider @@ -117,6 +119,7 @@ export function NavbarContainer({ mobileMode?: boolean; }) { const { t } = useTranslation('common'); + const hostId = useHostId(); const { executeAction } = useActions(); const { workspace: selectedWorkspace, isCreateMode } = useWorkspaceContext(); const syncErrorContext = useSyncErrorContext(); @@ -126,6 +129,10 @@ export function NavbarContainer({ const { isSignedIn } = useAuth(); const appNavigation = useAppNavigation(); const [mobileActiveTab, setMobileActiveTab] = useMobileActiveTab(); + const mobileTabs = useMemo( + () => (hostId ? MOBILE_TABS.filter((t) => t.id !== 'files') : MOBILE_TABS), + [hostId] + ); const { data: orgsData } = useUserOrganizations(); const organizations = useMemo( @@ -327,8 +334,9 @@ export function NavbarContainer({ onOpenCommandBar={handleOpenCommandBar} onOpenSettings={handleOpenSettings} onNavigateBack={handleNavigateBack} - mobileActiveTab={mobileActiveTab as MobileTabId} - onMobileTabChange={(tab) => setMobileActiveTab(tab)} + mobileActiveTab={mobileActiveTab} + onMobileTabChange={(tab: MobileTabId) => setMobileActiveTab(tab)} + mobileTabs={mobileTabs} /> ); } diff --git a/packages/web-core/src/shared/stores/useUiPreferencesStore.ts b/packages/web-core/src/shared/stores/useUiPreferencesStore.ts index 4e684c5d508..df7d5d50530 100644 --- a/packages/web-core/src/shared/stores/useUiPreferencesStore.ts +++ b/packages/web-core/src/shared/stores/useUiPreferencesStore.ts @@ -9,18 +9,25 @@ export const RIGHT_MAIN_PANEL_MODES = { PREVIEW: 'preview', } as const; +// RightMainPanelMode must stay a subset of MobileTab. The assignments below +// intentionally rely on structural typing (no cast), so adding a mode without +// a matching mobile tab fails to compile. export type RightMainPanelMode = (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES]; export type LayoutMode = 'workspaces' | 'kanban'; +// Keep in lockstep with MobileTabId in +// packages/ui/src/components/Navbar.tsx — web-core can't be imported by ui, +// so these two unions are intentionally duplicated. export type MobileTab = | 'workspaces' | 'chat' | 'changes' | 'logs' | 'preview' - | 'git'; + | 'git' + | 'files'; export type MobileFontScale = 'default' | 'small' | 'smaller'; export const DEFAULT_CREATE_DRAFT_WORKSPACE_BY_DEFAULT = false; @@ -490,7 +497,7 @@ export const useUiPreferencesStore = create()((set, get) => ({ listViewStatusFilter: null, // Mobile tab state - mobileActiveTab: 'chat' as MobileTab, + mobileActiveTab: 'chat', // Mobile font scale mobileFontScale: loadMobileFontScale(), @@ -581,8 +588,7 @@ export const useUiPreferencesStore = create()((set, get) => ({ : isWideScreen() ? state.isLeftSidebarVisible : false, - ...(isMobile && - !isCurrentlyActive && { mobileActiveTab: mode as MobileTab }), + ...(isMobile && !isCurrentlyActive && { mobileActiveTab: mode }), }); }, @@ -605,7 +611,7 @@ export const useUiPreferencesStore = create()((set, get) => ({ ? state.isLeftSidebarVisible : false, }), - ...(isMobile && mode !== null && { mobileActiveTab: mode as MobileTab }), + ...(isMobile && mode !== null && { mobileActiveTab: mode }), }); },