diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..bd98b4ff
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "shadcn": {
+ "command": "npx",
+ "args": [
+ "shadcn@latest",
+ "mcp"
+ ]
+ }
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 398cd91b..2e84cd72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+* **sidebar:** right-click context menu on stacks with full lifecycle actions (deploy, stop, restart, update, delete, alerts, check for updates)
+* **sidebar:** expanded three-dot dropdown menu to match context menu actions
+* **settings:** new "Support" tab with tier-appropriate help channels (docs, GitHub Issues, email support for Pro)
+* **ui:** TierBadge component with distinctive icons per tier — Globe (Community), Crown (Pro), Users (Team)
+
+### Changed
+
+* **sidebar:** centered logo and app name in sidebar header
+* **settings:** removed duplicate Documentation and GitHub Issues links from About section (now in Support tab)
+* replaced ProBadge with TierBadge across UserProfileDropdown and Settings
+
## [0.9.0](https://github.com/AnsoCode/Sencho/compare/v0.8.0...v0.9.0) (2026-03-27)
diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx
index 834c2ab6..1ce5fca2 100644
--- a/docs/features/stack-management.mdx
+++ b/docs/features/stack-management.mdx
@@ -60,11 +60,16 @@ The stack header exposes four actions:
Right-click or use the **⋮** button on any stack in the sidebar to access:
-
+
-- **Alerts** - configure metric-based alerting rules for this stack
-- **Check for updates** - manually trigger an image update check
+- **Alerts** — configure metric-based alerting rules for this stack
+- **Check for updates** — manually trigger an image update check
+- **Deploy** — run `docker compose up -d`
+- **Stop** — stop all containers in the stack
+- **Restart** — restart all containers in the stack
+- **Update** — pull latest images and recreate containers
+- **Delete** — stop and remove the stack (admin only)
## Converting a `docker run` command
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 075bf391..10690a2c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
diff --git a/frontend/package.json b/frontend/package.json
index bfead8d0..7ef5114e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index d2d33015..2fb9307e 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
-import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2 } from 'lucide-react';
+import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download } from 'lucide-react';
import { UserProfileDropdown } from './UserProfileDropdown';
import { apiFetch, fetchForNode } from '@/lib/api';
import { toast } from 'sonner';
@@ -27,7 +27,8 @@ import { Skeleton } from './ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
+import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SettingsModal } from './SettingsModal';
import { StackAlertSheet } from './StackAlertSheet';
@@ -891,6 +892,54 @@ export default function EditorLayout() {
}
};
+ // Context-menu-friendly stack actions (accept file name directly)
+ const executeStackActionByFile = async (stackFile: string, action: string, endpoint: string) => {
+ if (loadingAction !== null) return;
+ const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
+ setLoadingAction(action);
+ try {
+ const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
+ if (!response.ok) {
+ const errText = await response.text();
+ throw new Error(errText || `${action} failed`);
+ }
+ toast.success(`Stack ${action}ed successfully!`);
+ if (selectedFile === stackFile) {
+ const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
+ const conts = await containersRes.json();
+ setContainers(Array.isArray(conts) ? conts : []);
+ }
+ await refreshStacks(true);
+ if (action === 'deploy' && isPro) {
+ try {
+ const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
+ if (backupRes.ok) setBackupInfo(await backupRes.json());
+ } catch { /* ignore */ }
+ }
+ } catch (error) {
+ console.error(`Failed to ${action}:`, error);
+ const msg = (error as Error).message || `Failed to ${action} stack`;
+ toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
+ } finally {
+ setLoadingAction(null);
+ }
+ };
+
+ const checkUpdatesForStack = async () => {
+ try {
+ const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
+ if (res.ok) {
+ toast.success('Checking for image updates...');
+ setTimeout(() => fetchImageUpdates(), 3000);
+ } else {
+ const data = await res.json().catch(() => ({}));
+ toast.error(data.error || 'Failed to check for updates');
+ }
+ } catch {
+ toast.error('Failed to check for updates');
+ }
+ };
+
const handleCreateStack = async () => {
if (!newStackName.trim()) return;
// Send stackName directly (no .yml extension - backend creates directory)
@@ -979,7 +1028,7 @@ export default function EditorLayout() {
{/* Left Sidebar (Stacks) */}
{/* Branding Header */}
-
+
Sencho
@@ -1069,44 +1118,127 @@ export default function EditorLayout() {
) : (
(filteredFiles || []).map(file => (
-
loadFile(file)}
- className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
- >
-
-
-
{getDisplayName(file)}
+
+
+
+
loadFile(file)}
+ className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
+ >
+
+
+
{getDisplayName(file)}
- {stackUpdates[file] && (
-
- )}
+ {stackUpdates[file] && (
+
+ )}
-
e.stopPropagation()}>
-
-
-
-
-
- openAlertSheet(file)}>
-
- Alerts
-
-
-
+
e.stopPropagation()}>
+
+
+
+
+
+ openAlertSheet(file)}>
+
+ Alerts
+
+ checkUpdatesForStack()}>
+
+ Check for updates
+
+
+ executeStackActionByFile(file, 'deploy', 'deploy')}>
+
+ Deploy
+
+ executeStackActionByFile(file, 'stop', 'stop')}>
+
+ Stop
+
+ executeStackActionByFile(file, 'restart', 'restart')}>
+
+ Restart
+
+ executeStackActionByFile(file, 'update', 'update')}>
+
+ Update
+
+ {isAdmin && (
+ <>
+
+ {
+ setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
+ setDeleteDialogOpen(true);
+ }}
+ >
+
+ Delete
+
+ >
+ )}
+
+
+
+
+
-
-
+
+
+ openAlertSheet(file)}>
+
+ Alerts
+
+ checkUpdatesForStack()}>
+
+ Check for updates
+
+
+ executeStackActionByFile(file, 'deploy', 'deploy')}>
+
+ Deploy
+
+ executeStackActionByFile(file, 'stop', 'stop')}>
+
+ Stop
+
+ executeStackActionByFile(file, 'restart', 'restart')}>
+
+ Restart
+
+ executeStackActionByFile(file, 'update', 'update')}>
+
+ Update
+
+ {isAdmin && (
+ <>
+
+ {
+ setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
+ setDeleteDialogOpen(true);
+ }}
+ >
+
+ Delete
+
+ >
+ )}
+
+
))
)}
diff --git a/frontend/src/components/ProBadge.tsx b/frontend/src/components/ProBadge.tsx
deleted file mode 100644
index 3238037a..00000000
--- a/frontend/src/components/ProBadge.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Crown } from 'lucide-react';
-import { Badge } from '@/components/ui/badge';
-
-interface ProBadgeProps {
- className?: string;
-}
-
-export function ProBadge({ className }: ProBadgeProps) {
- return (
-
-
- Pro
-
- );
-}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 0011f520..8667e3a2 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -18,14 +18,14 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard } from 'lucide-react';
+import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug } from 'lucide-react';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { NodeManager } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
-import { ProBadge } from './ProBadge';
+import { TierBadge } from './TierBadge';
import { ProGate } from './ProGate';
interface Agent {
@@ -48,7 +48,7 @@ interface PatchableSettings {
log_retention_days?: string;
}
-type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
+type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
interface WebhookItem {
id: number;
@@ -190,7 +190,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
return (
-
Webhooks
+
Webhooks
Trigger stack actions from CI/CD pipelines via HTTP.
@@ -207,7 +207,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
-
Webhooks
+
Webhooks
Trigger stack actions from CI/CD pipelines via HTTP.
@@ -1087,7 +1088,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
- {license?.tier === 'pro' && }
+
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
@@ -1525,6 +1526,90 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)}
+ {activeSection === 'support' && (
+
+
+
Help & Support
+
Get help with Sencho based on your plan.
+
+
+ {/* Self-serve channels (all tiers) */}
+
+
+ {/* Pro support channels */}
+ {isPro && (
+
+ )}
+
+ {/* Upsell for Community */}
+ {!isPro && (
+
+
+
+
+
Need faster support?
+
+ Upgrade to Pro for direct email support and priority issue handling.
+
+
+
+
+
+ )}
+
+ )}
+
{activeSection === 'about' && (
@@ -1539,7 +1624,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
Tier
-
{license?.tier === 'pro' ?
:
Community}
+
License Status
@@ -1556,14 +1641,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
diff --git a/frontend/src/components/TierBadge.tsx b/frontend/src/components/TierBadge.tsx
new file mode 100644
index 00000000..0d15fb93
--- /dev/null
+++ b/frontend/src/components/TierBadge.tsx
@@ -0,0 +1,39 @@
+import { Crown, Globe, Users } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { useLicense, type LicenseTier, type LicenseVariant, type LicenseStatus } from '@/context/LicenseContext';
+
+interface TierBadgeProps {
+ tier?: LicenseTier;
+ variant?: LicenseVariant;
+ status?: LicenseStatus;
+ className?: string;
+}
+
+const tierConfig = {
+ community: { icon: Globe, label: 'Community' },
+ pro: { icon: Crown, label: 'Pro' },
+ team: { icon: Users, label: 'Team' },
+} as const;
+
+function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
+ // Only show Team badge for active team licenses, not trials
+ // (trials default to team variant to unlock all features)
+ if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team;
+ if (tier === 'pro') return tierConfig.pro;
+ return tierConfig.community;
+}
+
+export function TierBadge({ tier, variant, status, className }: TierBadgeProps) {
+ const { license } = useLicense();
+ const resolvedTier = tier ?? license?.tier ?? 'community';
+ const resolvedVariant = variant !== undefined ? variant : license?.variant ?? null;
+ const resolvedStatus = status ?? license?.status ?? 'community';
+ const { icon: Icon, label } = resolveTier(resolvedTier, resolvedVariant, resolvedStatus);
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/UserProfileDropdown.tsx b/frontend/src/components/UserProfileDropdown.tsx
index f39cd960..7517fbfc 100644
--- a/frontend/src/components/UserProfileDropdown.tsx
+++ b/frontend/src/components/UserProfileDropdown.tsx
@@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
-import { ProBadge } from './ProBadge';
+import { TierBadge } from './TierBadge';
type Theme = 'light' | 'dark' | 'auto';
@@ -16,7 +16,7 @@ interface UserProfileDropdownProps {
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
const { logout, user, isAdmin } = useAuth();
- const { license, isPro } = useLicense();
+ const { license } = useLicense();
return (
@@ -39,7 +39,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
{user?.role ?? 'admin'}
·
- {isPro ? : Community}
+
diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx
new file mode 100644
index 00000000..0cd4b2c0
--- /dev/null
+++ b/frontend/src/components/ui/context-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ContextMenu = ContextMenuPrimitive.Root
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger
+
+const ContextMenuGroup = ContextMenuPrimitive.Group
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal
+
+const ContextMenuSub = ContextMenuPrimitive.Sub
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+ContextMenuCheckboxItem.displayName =
+ ContextMenuPrimitive.CheckboxItem.displayName
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
+
+const ContextMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+ContextMenuShortcut.displayName = "ContextMenuShortcut"
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+}