diff --git a/.yarn/changelogs/frontend.f07443ec.md b/.yarn/changelogs/frontend.f07443ec.md
new file mode 100644
index 0000000..cc0c393
--- /dev/null
+++ b/.yarn/changelogs/frontend.f07443ec.md
@@ -0,0 +1,60 @@
+
+# frontend
+
+## ✨ Features
+
+### Services is now the stack default page
+
+Opening a stack (`/stacks/:stackName`) or the legacy Setup route (`/stacks/:stackName/setup`) redirects to **Services**. Create, import, and service-wizard flows land on Services directly so day-to-day work starts on the page you actually use.
+
+### Stack actions menu on Services
+
+A **Stack actions** menu (`⋯`) on the Services header groups stack-level operations that previously lived only on Overview:
+
+- **Edit Stack** — open stack settings
+- **Export Stack** — export stack configuration
+- **Set Up All** — run the stack batch setup endpoint (`POST /stacks/:id/setup`) for every service
+
+### Sidebar stack status at a glance
+
+Each stack in the sidebar accordion shows a **traffic-light dot** derived from live service run state:
+
+- Green — at least one service running
+- Amber — services starting or stopping
+- Red — at least one service in error
+- Grey — all stopped or no services
+
+The **Services** sub-link also shows a count so you can spot empty stacks without opening them.
+
+### Dashboard cards with inline status and per-stack actions
+
+Global dashboard stack cards now show **status chips inline with the stack name** (running, stopped, error, etc.) and expose **Start All**, **Stop All**, and **Update All** in the card footer. Actions use `stopPropagation` so clicking them does not navigate away from the dashboard.
+
+### Slimmer Services table actions
+
+Service rows now expose three actions instead of six:
+
+- **Primary** — context-aware play/stop/retry for the current pipeline stage
+- **Logs** — jump to the service logs tab
+- **Chevron** — open service details
+
+The service name is a link; double-clicking a row also opens details. Bulk selection still works on single-click.
+
+**Commits behind upstream** moved from the removed Update button to a badge on the **Branch** cell (`N behind`).
+
+### Secret warnings and guided empty state on Services
+
+- **Potential secrets** warnings (previously on stack Overview) now appear above the Services table when detected in service definitions.
+- Stacks with **no services** show a **3-step getting-started guide** (Repositories → Prerequisites → Create Services). The old “Run Setup” step was removed — setup is available via bulk actions and the stack menu.
+
+## ♻️ Refactoring
+
+- Removed stack **Overview** (`stack-dashboard.tsx`) and **Setup** (`stack-setup.tsx`) pages; unique concerns relocated to Services, sidebar, and the stack actions menu
+- Extracted `StackRedirect`, `StackCard`, `SidebarStackItem`, `ServicesEmptyState`, and `StackActionsMenu` components
+- Added `getStackRunSummary()` helper in `stack-status.ts` for shared stack run-state derivation (sidebar dot and future consumers)
+- Simplified global `Dashboard` to list-only mode
+
+## 🧪 Tests
+
+- Added unit tests for `getStackRunSummary` and `getStackStatusPaletteKey`
+- Added component tests for `StackRedirect`, `StackCard`, `ServiceTable`, `SidebarStackItem`, `ServicesEmptyState`, and `StackActionsMenu`
diff --git a/.yarn/changelogs/stack-craft.f07443ec.md b/.yarn/changelogs/stack-craft.f07443ec.md
new file mode 100644
index 0000000..8ef9468
--- /dev/null
+++ b/.yarn/changelogs/stack-craft.f07443ec.md
@@ -0,0 +1,17 @@
+
+# stack-craft
+
+## ✨ Features
+
+### Navigation restructure — Services-first stack workflow
+
+StackCraft now treats **Services** as the default landing page for every stack. Legacy URLs (`/stacks/:name`, `/stacks/:name/setup`) redirect automatically, and stack-level actions (Edit, Export, Set Up All) are reachable from the Services header menu instead of a separate Overview page.
+
+The sidebar shows a **per-stack status dot** and service count so you can tell at a glance whether a cloned stack still has processes running — useful when the same stack exists in multiple feature branches and port conflicts are a risk.
+
+The global dashboard adds **per-card Start/Stop/Update All** controls alongside inline status chips, without leaving the dashboard.
+
+## 🧪 Tests
+
+- Added `e2e/navigation.spec.ts` covering legacy-route redirects, stack actions menu (Edit/Export), dashboard card actions that do not navigate away, and sidebar status dots
+- Updated E2E helpers: `createStack` asserts landing on Services; `deleteStack` reaches Edit Stack via the stack actions menu; sidebar helper drops the removed Overview link
diff --git a/.yarn/versions/f07443ec.yml b/.yarn/versions/f07443ec.yml
new file mode 100644
index 0000000..e231c93
--- /dev/null
+++ b/.yarn/versions/f07443ec.yml
@@ -0,0 +1,3 @@
+releases:
+ frontend: patch
+ stack-craft: patch
diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts
index 0ab391a..16b1448 100644
--- a/e2e/helpers/index.ts
+++ b/e2e/helpers/index.ts
@@ -5,4 +5,4 @@ export { addPrerequisite, type PrerequisiteParams } from './prerequisite.js'
export { addRepository, type AddRepositoryParams } from './repository.js'
export { fillServiceForm, navigateToCreateService, submitServiceForm, type CreateServiceParams } from './service.js'
export { navigateViaSidebar } from './sidebar.js'
-export { createStack, deleteStack, type CreateStackParams } from './stack.js'
+export { createStack, deleteStack, openStackActionsMenu, type CreateStackParams } from './stack.js'
diff --git a/e2e/helpers/sidebar.ts b/e2e/helpers/sidebar.ts
index 83ce5b3..b8dfd0b 100644
--- a/e2e/helpers/sidebar.ts
+++ b/e2e/helpers/sidebar.ts
@@ -1,13 +1,10 @@
import type { Page } from '@playwright/test'
-export const navigateViaSidebar = async (
- page: Page,
- stackDisplayName: string,
- link: 'Overview' | 'Services' | 'Repositories' | 'Prerequisites',
-) => {
+export type StackSidebarLink = 'Services' | 'Repositories' | 'Prerequisites'
+
+export const navigateViaSidebar = async (page: Page, stackDisplayName: string, link: StackSidebarLink) => {
const stackSidebar = page.locator('shade-accordion-item').filter({ hasText: stackDisplayName })
- // Expand the accordion if it's collapsed
const isExpanded = await stackSidebar.getAttribute('data-expanded')
if (isExpanded === null) {
await stackSidebar.locator('.accordion-header').click()
diff --git a/e2e/helpers/stack.ts b/e2e/helpers/stack.ts
index 6ac4a11..933f996 100644
--- a/e2e/helpers/stack.ts
+++ b/e2e/helpers/stack.ts
@@ -21,26 +21,28 @@ export const createStack = async (page: Page, params: CreateStackParams) => {
await page.locator('button', { hasText: 'Create' }).click()
await expectNotification(page, `Stack "${params.displayName}" was created successfully.`)
- await expect(page.locator('shade-dashboard')).toBeVisible()
- await expect(page.getByTestId('page-header-title')).toContainText(params.displayName)
+ await expect(page.locator('shade-services-list')).toBeVisible()
+ await expect(page.getByTestId('page-header-title')).toContainText('Services (0)')
+}
+
+export const openStackActionsMenu = async (page: Page) => {
+ await page.getByRole('button', { name: 'Stack actions' }).click()
}
export const deleteStack = async (page: Page, displayName: string) => {
- // Navigate to the main dashboard via the sidebar "Dashboard" link (always visible, no accordion)
await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click()
await expect(page.locator('stack-list-dashboard')).toBeVisible()
- // Click on the stack card to open its overview
- await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click()
- await expect(page.getByTestId('page-header-title')).toContainText(displayName)
+ await page.locator('stack-list-dashboard stack-card', { hasText: displayName }).click()
+ await expect(page.locator('shade-services-list')).toBeVisible()
- // Navigate to Edit Stack, then delete
- await page.locator('a', { hasText: 'Edit Stack' }).click()
+ await openStackActionsMenu(page)
+ await page.getByRole('menuitem', { name: 'Edit Stack' }).click()
await expect(page.locator('shade-edit-stack')).toBeVisible()
await page.locator('button', { hasText: 'Delete Stack' }).click()
await page.locator('shade-dialog .dialog-confirm-btn').click()
await expectNotification(page, `"${displayName}" was deleted.`)
- await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10_000 })
+ await expect(page.locator('stack-list-dashboard')).toBeVisible({ timeout: 10_000 })
}
diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts
new file mode 100644
index 0000000..2248559
--- /dev/null
+++ b/e2e/navigation.spec.ts
@@ -0,0 +1,73 @@
+import { tmpdir } from 'os'
+import { join } from 'path'
+
+import { expect, test } from '@playwright/test'
+
+import { createStack, deleteStack, login, openStackActionsMenu } from './helpers/index.js'
+
+test('Navigation restructure', async ({ page, browserName }) => {
+ const uuid = crypto.randomUUID()
+ const stackName = `e2e-nav-${uuid}`
+ const displayName = `Nav Stack - ${browserName} - ${uuid}`
+
+ await page.goto('/')
+ await login(page)
+
+ try {
+ await test.step('Create stack lands on Services', async () => {
+ await createStack(page, {
+ name: stackName,
+ displayName,
+ description: 'Navigation restructure E2E',
+ mainDirectory: join(tmpdir(), `e2e-nav-${uuid}`),
+ })
+ await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))
+ })
+
+ await test.step('Legacy routes redirect to Services', async () => {
+ await page.goto(`/stacks/${stackName}`)
+ await expect(page.locator('shade-services-list')).toBeVisible()
+ await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))
+
+ await page.goto(`/stacks/${stackName}/setup`)
+ await expect(page.locator('shade-services-list')).toBeVisible()
+ await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))
+ })
+
+ await test.step('Stack actions menu reaches Edit and Export', async () => {
+ await openStackActionsMenu(page)
+ await page.getByRole('menuitem', { name: 'Edit Stack' }).click()
+ await expect(page.locator('shade-edit-stack')).toBeVisible()
+
+ await page.goto(`/stacks/${stackName}/services`)
+ await openStackActionsMenu(page)
+ await page.getByRole('menuitem', { name: 'Export Stack' }).click()
+ await expect(page.locator('shade-export-stack')).toBeVisible()
+ })
+
+ await test.step('Stack actions menu runs Set Up All', async () => {
+ await page.goto(`/stacks/${stackName}/services`)
+ await openStackActionsMenu(page)
+ await page.getByRole('menuitem', { name: 'Set Up All' }).click()
+ await expect(page.locator('shade-services-list')).toBeVisible()
+ })
+
+ await test.step('Dashboard card actions do not navigate away', async () => {
+ await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click()
+ await expect(page.locator('stack-list-dashboard')).toBeVisible()
+
+ const card = page.locator('stack-card', { hasText: displayName })
+ await card.getByRole('button', { name: 'Start All' }).click()
+ await expect(page.locator('stack-list-dashboard')).toBeVisible()
+ await expect(page).toHaveURL('/')
+ })
+
+ await test.step('Sidebar shows stack status dot', async () => {
+ await expect(page.locator('shade-sidebar-stack-item [data-testid="stack-status-dot"]').first()).toBeVisible()
+ })
+ } finally {
+ await test.step('Clean up stack', async () => {
+ await deleteStack(page, displayName)
+ })
+ }
+})
diff --git a/frontend/src/components/app-routes.tsx b/frontend/src/components/app-routes.tsx
index 9e7ad0c..ae04dbe 100644
--- a/frontend/src/components/app-routes.tsx
+++ b/frontend/src/components/app-routes.tsx
@@ -18,8 +18,8 @@ import { ServicesList } from '../pages/services/services-list.js'
import { UserSettings } from '../pages/settings/user-settings.js'
import { CreateStack } from '../pages/stacks/create-stack.js'
import { EditStack } from '../pages/stacks/edit-stack.js'
-import { StackSetup } from '../pages/stacks/stack-setup.js'
import { CreateServiceWizard } from '../pages/wizards/create-service-wizard/index.js'
+import { StackRedirect } from './stack-redirect.js'
export const appRoutes = {
'/': {
@@ -36,7 +36,7 @@ export const appRoutes = {
},
'/stacks/:stackName': {
component: ({ match }: { match: MatchResult<{ stackName: string }> }) => (
-
+
),
},
'/stacks/:stackName/edit': {
@@ -51,7 +51,7 @@ export const appRoutes = {
},
'/stacks/:stackName/setup': {
component: ({ match }: { match: MatchResult<{ stackName: string }> }) => (
-
+
),
},
'/stacks/:stackName/services': {
diff --git a/frontend/src/components/branch-selector.tsx b/frontend/src/components/branch-selector.tsx
index 60ce33a..81ed79b 100644
--- a/frontend/src/components/branch-selector.tsx
+++ b/frontend/src/components/branch-selector.tsx
@@ -10,6 +10,7 @@ type BranchSelectorProps = {
cloneStatus: CloneStatus
upstreamStatus?: UpstreamStatus
lastPullError?: string
+ commitsBehind?: number
}
/**
@@ -205,6 +206,16 @@ export const BranchSelector = Shade({
opacity: '0.5',
flexShrink: '0',
},
+ '& .commits-behind-badge': {
+ fontSize: '10px',
+ fontWeight: '600',
+ padding: '1px 5px',
+ borderRadius: '8px',
+ backgroundColor: 'var(--shades-theme-palette-primary-main)',
+ color: 'var(--shades-theme-palette-primary-mainContrast)',
+ flexShrink: '0',
+ lineHeight: '1.2',
+ },
'& .branch-spinner': {
display: 'inline-block',
width: '10px',
@@ -218,7 +229,7 @@ export const BranchSelector = Shade({
},
render: ({ props, injector, useState, useDisposable, useRef }) => {
const triggerRef = useRef('triggerRef')
- const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError } = props
+ const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError, commitsBehind } = props
const [branches, setBranches] = useState<{ local: string[]; remote: string[] } | null>('branches', null)
const [isLoading, setIsLoading] = useState('isLoading', false)
@@ -332,20 +343,27 @@ export const BranchSelector = Shade({
return (
<>
-
+
+
+ {commitsBehind ? (
+
+ {commitsBehind} behind
+
+ ) : null}
+