diff --git a/frontend/e2e/project-analysis-failure.spec.ts b/frontend/e2e/project-analysis-failure.spec.ts
index fd89e74..35bc2d4 100644
--- a/frontend/e2e/project-analysis-failure.spec.ts
+++ b/frontend/e2e/project-analysis-failure.spec.ts
@@ -12,11 +12,15 @@ test('Workspace 분석 실패를 샘플 성공으로 대체하지 않는다', as
await expect(page.getByText('분석 실패', { exact: true }).first()).toBeVisible()
await expect(page.getByRole('heading', { name: /분석하지 못했습니다/ })).toBeVisible()
await expect(page.getByText('샘플 프로젝트로 자동 전환하지 않았습니다.')).toBeVisible()
- await expect(page.getByRole('button', { name: /API 요청/ })).toBeDisabled()
- await expect(page.getByRole('button', { name: /^3 Trace/ })).toBeDisabled()
+ const workflow = page.getByRole('navigation', { name: 'StackFlow 작업 단계' })
+ await expect(workflow).toHaveCount(1)
+ await expect(page.getByRole('list', { name: '분석 증거 단계' })).toHaveCount(0)
+ await expect(workflow.getByRole('button', { name: /프로젝트 구조.*분석 실패/ })).toHaveAttribute('aria-current', 'page')
+ await expect(workflow.getByRole('button', { name: /API 요청.*분석 후 사용/ })).toBeDisabled()
+ await expect(workflow.getByRole('button', { name: /Trace.*API 준비 필요/ })).toBeDisabled()
await expect(page.getByRole('button', { name: '다시 분석' }).first()).toBeVisible()
await expect(page.getByRole('button', { name: '데모 프로젝트 열기' })).toBeVisible()
- await expect(page.getByText('StackFlow 샘플')).toHaveCount(0)
+ await expect(page.getByText('StackFlow 샘플', { exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
await expect.poll(() => page.evaluate(() =>
diff --git a/frontend/src/App.css b/frontend/src/App.css
index d4daefd..3a4a11b 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1,4 +1,8 @@
.app-shell {
+ --topbar-height: 68px;
+ --workflow-nav-height: 56px;
+ --workbench-sticky-offset: calc(var(--topbar-height) + var(--workflow-nav-height));
+ --workbench-panel-offset: calc(var(--workbench-sticky-offset) + 37px);
min-height: 100vh;
color: var(--color-ink);
background: var(--color-canvas);
@@ -29,10 +33,10 @@ summary:focus-visible {
top: 0;
z-index: 30;
display: grid;
- grid-template-columns: minmax(220px, 1fr) auto auto;
+ grid-template-columns: minmax(220px, 1fr) auto;
align-items: center;
gap: 24px;
- min-height: 68px;
+ min-height: var(--topbar-height);
border-bottom: 1px solid var(--color-line);
background: rgba(255, 255, 255, 0.96);
padding: 10px 24px;
@@ -103,68 +107,13 @@ summary:focus-visible {
font: 650 0.78rem/1.3 var(--font-mono);
}
-.evidence-progress {
- display: flex;
- align-items: center;
- gap: 0;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.evidence-progress li {
- position: relative;
- display: flex;
- align-items: center;
- gap: 6px;
- color: #8995a4;
- font-size: 0.72rem;
- font-weight: 650;
- white-space: nowrap;
-}
-
-.evidence-progress li:not(:last-child)::after {
- width: 34px;
- height: 1px;
- margin: 0 9px;
- background: var(--color-line-strong);
- content: '';
-}
-
-.evidence-progress li.is-ready {
- color: var(--color-spring);
-}
-
-.evidence-progress li.is-active {
- color: var(--color-action);
-}
-
-.evidence-progress__icon {
- display: grid;
- place-items: center;
- width: 24px;
- height: 24px;
- border: 1px solid var(--color-line-strong);
- border-radius: 50%;
- background: var(--color-surface);
-}
-
-.evidence-progress .is-ready .evidence-progress__icon {
- border-color: rgba(31, 122, 85, 0.28);
- background: var(--color-spring-soft);
-}
-
-.evidence-progress .is-active .evidence-progress__icon {
- border-color: rgba(37, 99, 235, 0.34);
- background: var(--color-action-soft);
-}
-
.workflow-tabs {
position: sticky;
- top: 68px;
+ top: var(--topbar-height);
z-index: 25;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+ height: var(--workflow-nav-height);
border-bottom: 1px solid var(--color-line);
background: var(--color-surface);
padding: 0 24px;
@@ -176,11 +125,11 @@ summary:focus-visible {
grid-template-columns: 22px 18px minmax(0, 1fr);
align-items: center;
gap: 9px;
- min-height: 62px;
+ height: 100%;
border: 0;
border-right: 1px solid var(--color-line);
background: transparent;
- padding: 9px 18px;
+ padding: 6px 18px;
text-align: left;
cursor: pointer;
}
@@ -234,6 +183,7 @@ summary:focus-visible {
.workflow-tab strong {
font-size: 0.86rem;
+ line-height: 1.2;
}
.workflow-tab small {
@@ -242,14 +192,27 @@ summary:focus-visible {
color: var(--color-muted);
font-size: 0.69rem;
font-weight: 450;
+ line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
+.workflow-tab--success small {
+ color: var(--color-spring);
+}
+
+.workflow-tab--warning small {
+ color: var(--color-warning);
+}
+
+.workflow-tab--error small {
+ color: var(--color-error);
+}
+
.workspace {
display: grid;
grid-template-columns: 300px minmax(560px, 1fr) 330px;
- min-height: calc(100vh - 131px);
+ min-height: calc(100vh - var(--workbench-sticky-offset));
}
.left-panel,
@@ -269,9 +232,9 @@ summary:focus-visible {
.control-rail,
.inspector-rail {
position: sticky;
- top: 131px;
+ top: var(--workbench-sticky-offset);
align-self: start;
- max-height: calc(100vh - 131px);
+ max-height: calc(100vh - var(--workbench-sticky-offset));
overflow: auto;
}
@@ -294,7 +257,7 @@ summary:focus-visible {
.panel-card--map,
.panel-card--api-flow,
.panel-card--graph {
- min-height: calc(100vh - 168px);
+ min-height: calc(100vh - var(--workbench-panel-offset));
border: 1px solid var(--color-line);
border-radius: 8px;
background: var(--color-surface);
@@ -1473,9 +1436,6 @@ summary:focus-visible {
border-right: 1px solid var(--color-line);
}
-.evidence-progress li:not(:last-child)::after {
- width: 18px;
- }
}
@media (max-width: 980px) {
@@ -1483,16 +1443,6 @@ summary:focus-visible {
grid-template-columns: minmax(0, 1fr) auto;
}
-.evidence-progress {
- grid-column: 1 / -1;
- grid-row: 2;
- justify-content: center;
- }
-
-.workflow-tabs {
- top: 96px;
- }
-
.workspace {
grid-template-columns: 1fr;
}
@@ -1545,16 +1495,6 @@ summary:focus-visible {
padding-top: 8px;
}
-.evidence-progress {
- justify-content: flex-start;
- overflow-x: auto;
- }
-
-.evidence-progress li:not(:last-child)::after {
- width: 12px;
- margin: 0 5px;
- }
-
.workflow-tabs {
position: sticky;
top: 0;
@@ -1564,14 +1504,12 @@ summary:focus-visible {
.workflow-tab {
grid-template-columns: auto auto;
justify-content: center;
- min-height: 54px;
gap: 6px;
padding: 8px 5px;
text-align: center;
}
-.workflow-tab__index,
-.workflow-tab small {
+.workflow-tab__index {
display: none;
}
@@ -1579,6 +1517,11 @@ summary:focus-visible {
font-size: 0.75rem;
}
+.workflow-tab small {
+ margin-top: 2px;
+ font-size: 0.6rem;
+ }
+
.graph-panel,
.control-card,
.inspector-workbench {
diff --git a/frontend/src/components/EvidenceProgress.tsx b/frontend/src/components/EvidenceProgress.tsx
deleted file mode 100644
index 593abc5..0000000
--- a/frontend/src/components/EvidenceProgress.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Check, Code2, LockKeyhole, Play, Route } from 'lucide-react'
-import type { ViewMode } from '../ui/copy'
-
-type EvidenceProgressProps = {
- activeView: ViewMode
- analysisReady: boolean
- requestReady: boolean
- traceReady: boolean
-}
-
-const STAGES = [
- { id: 'project', label: '코드 분석', icon: Code2 },
- { id: 'api', label: '요청 가능', icon: Play },
- { id: 'runtime', label: 'Trace 확보', icon: Route },
-] as const
-
-export function EvidenceProgress({ activeView, analysisReady, requestReady, traceReady }: EvidenceProgressProps) {
- const readiness = { project: analysisReady, api: requestReady, runtime: traceReady }
-
- return (
-
- {STAGES.map(({ id, label, icon: Icon }) => {
- const ready = readiness[id]
- const active = activeView === id
-
- return (
- -
-
- {ready && !active ? : active ? : }
-
- {label}
-
- )
- })}
-
- )
-}
diff --git a/frontend/src/components/WorkflowTabs.test.tsx b/frontend/src/components/WorkflowTabs.test.tsx
index 814746f..06602f2 100644
--- a/frontend/src/components/WorkflowTabs.test.tsx
+++ b/frontend/src/components/WorkflowTabs.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { WorkflowTabs } from './WorkflowTabs'
@@ -9,7 +9,9 @@ describe('WorkflowTabs', () => {
render(
{
await userEvent.click(screen.getByRole('button', { name: /API 요청/ }))
expect(onChange).toHaveBeenCalledWith('api')
+ expect(screen.getByRole('button', { name: /프로젝트 구조/ })).toHaveAttribute('aria-current', 'page')
+ expect(screen.getByText('분석 완료')).toBeInTheDocument()
+ expect(screen.getByText('요청 가능')).toBeInTheDocument()
+ expect(screen.getByText('요청 후 확인')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Trace/ })).toBeDisabled()
})
+
+ it('distinguishes analysis-only APIs from unavailable workflow stages', () => {
+ const { container, rerender } = render(
+ ,
+ )
+
+ expect(within(container).getByText('정적 분석만')).toBeInTheDocument()
+ expect(within(container).getByRole('button', { name: /API 요청/ })).toBeEnabled()
+ expect(within(container).getByRole('button', { name: /Trace/ })).toBeEnabled()
+
+ rerender(
+ ,
+ )
+
+ expect(within(container).getByText('분석 실패')).toBeInTheDocument()
+ expect(within(container).getByText('분석 후 사용')).toBeInTheDocument()
+ expect(within(container).getByText('API 준비 필요')).toBeInTheDocument()
+ expect(within(container).getByRole('button', { name: /API 요청/ })).toBeDisabled()
+ expect(within(container).getByRole('button', { name: /Trace/ })).toBeDisabled()
+ })
})
diff --git a/frontend/src/components/WorkflowTabs.tsx b/frontend/src/components/WorkflowTabs.tsx
index 6dde708..6c9473c 100644
--- a/frontend/src/components/WorkflowTabs.tsx
+++ b/frontend/src/components/WorkflowTabs.tsx
@@ -1,10 +1,14 @@
import { Braces, FolderTree, Route } from 'lucide-react'
+import type { ProjectAnalysisStatus } from '../types/trace'
import { VIEW_COPY } from '../ui/copy'
import type { ViewMode } from '../ui/copy'
+import { getWorkflowNavigationState } from './workflowNavigation'
type WorkflowTabsProps = {
activeView: ViewMode
+ projectStatus: ProjectAnalysisStatus
hasDetectedApis: boolean
+ requestReady: boolean
traceAvailable: boolean
externalProject: boolean
onChange: (view: ViewMode) => void
@@ -18,7 +22,9 @@ const TABS = [
export function WorkflowTabs({
activeView,
+ projectStatus,
hasDetectedApis,
+ requestReady,
traceAvailable,
externalProject,
onChange,
@@ -28,21 +34,31 @@ export function WorkflowTabs({
{TABS.map(({ id, icon: Icon }, index) => {
const disabled = (id !== 'project' && !hasDetectedApis)
|| (id === 'runtime' && externalProject && !traceAvailable)
+ const state = getWorkflowNavigationState(id, {
+ projectStatus,
+ hasDetectedApis,
+ requestReady,
+ traceAvailable,
+ })
+ const disabledTitle = id === 'runtime' && hasDetectedApis
+ ? '먼저 API 요청을 실행해 Trace를 수집하세요.'
+ : '먼저 실행 가능한 API를 준비하세요.'
return (
)
diff --git a/frontend/src/components/workflowNavigation.test.ts b/frontend/src/components/workflowNavigation.test.ts
new file mode 100644
index 0000000..d6f8113
--- /dev/null
+++ b/frontend/src/components/workflowNavigation.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { getWorkflowNavigationState } from './workflowNavigation'
+
+const READY_CONTEXT = {
+ projectStatus: 'SUCCESS' as const,
+ hasDetectedApis: true,
+ requestReady: true,
+ traceAvailable: false,
+}
+
+describe('getWorkflowNavigationState', () => {
+ it.each([
+ ['SUCCESS', '분석 완료', 'success'],
+ ['EMPTY', 'API 없음', 'warning'],
+ ['FAILED', '분석 실패', 'error'],
+ ] as const)('maps project status %s to %s', (projectStatus, label, tone) => {
+ expect(getWorkflowNavigationState('project', { ...READY_CONTEXT, projectStatus })).toEqual({ label, tone })
+ })
+
+ it('maps API and Trace readiness without guessing execution support', () => {
+ expect(getWorkflowNavigationState('api', { ...READY_CONTEXT, requestReady: false })).toEqual({
+ label: '정적 분석만', tone: 'warning',
+ })
+ expect(getWorkflowNavigationState('api', { ...READY_CONTEXT, hasDetectedApis: false })).toEqual({
+ label: '분석 후 사용', tone: 'neutral',
+ })
+ expect(getWorkflowNavigationState('runtime', { ...READY_CONTEXT, traceAvailable: true })).toEqual({
+ label: 'Trace 확보', tone: 'success',
+ })
+ })
+})
diff --git a/frontend/src/components/workflowNavigation.ts b/frontend/src/components/workflowNavigation.ts
new file mode 100644
index 0000000..4b86ad7
--- /dev/null
+++ b/frontend/src/components/workflowNavigation.ts
@@ -0,0 +1,36 @@
+import type { StatusTone } from './StatusBadge'
+import type { ProjectAnalysisStatus } from '../types/trace'
+import type { ViewMode } from '../ui/copy'
+
+export type WorkflowNavigationState = {
+ label: string
+ tone: StatusTone
+}
+
+type WorkflowNavigationContext = {
+ projectStatus: ProjectAnalysisStatus
+ hasDetectedApis: boolean
+ requestReady: boolean
+ traceAvailable: boolean
+}
+
+export function getWorkflowNavigationState(
+ view: ViewMode,
+ context: WorkflowNavigationContext,
+): WorkflowNavigationState {
+ if (view === 'project') {
+ if (context.projectStatus === 'SUCCESS') return { label: '분석 완료', tone: 'success' }
+ if (context.projectStatus === 'FAILED') return { label: '분석 실패', tone: 'error' }
+ return { label: 'API 없음', tone: 'warning' }
+ }
+
+ if (view === 'api') {
+ if (!context.hasDetectedApis) return { label: '분석 후 사용', tone: 'neutral' }
+ if (!context.requestReady) return { label: '정적 분석만', tone: 'warning' }
+ return { label: '요청 가능', tone: 'success' }
+ }
+
+ if (!context.hasDetectedApis) return { label: 'API 준비 필요', tone: 'neutral' }
+ if (context.traceAvailable) return { label: 'Trace 확보', tone: 'success' }
+ return { label: '요청 후 확인', tone: 'neutral' }
+}
diff --git a/frontend/src/features/workbench/components/AppShell.test.tsx b/frontend/src/features/workbench/components/AppShell.test.tsx
index 95948fc..0f88209 100644
--- a/frontend/src/features/workbench/components/AppShell.test.tsx
+++ b/frontend/src/features/workbench/components/AppShell.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AppShell } from './AppShell'
@@ -24,6 +24,31 @@ describe('AppShell', () => {
expect(screen.getByText('trace-lab')).toBeInTheDocument()
expect(screen.getByText('작업 화면')).toBeInTheDocument()
- expect(screen.getByText('분석 완료')).toBeInTheDocument()
+ expect(screen.getAllByText('분석 완료')).toHaveLength(2)
+ expect(screen.queryByLabelText('분석 증거 단계')).not.toBeInTheDocument()
+ expect(screen.getByRole('navigation', { name: 'StackFlow 작업 단계' })).toBeInTheDocument()
+ })
+
+ it('shows the active API readiness in the header and navigation', () => {
+ const { container } = render(
+
+
+ ,
+ )
+
+ expect(within(container).getAllByText('정적 분석만')).toHaveLength(2)
+ expect(within(container).getByRole('button', { name: /API 요청/ })).toHaveAttribute('aria-current', 'page')
})
})
diff --git a/frontend/src/features/workbench/components/AppShell.tsx b/frontend/src/features/workbench/components/AppShell.tsx
index ab563e6..11a0e5a 100644
--- a/frontend/src/features/workbench/components/AppShell.tsx
+++ b/frontend/src/features/workbench/components/AppShell.tsx
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
-import { EvidenceProgress } from '../../../components/EvidenceProgress'
import { StatusBadge } from '../../../components/StatusBadge'
import { WorkflowTabs } from '../../../components/WorkflowTabs'
-import { PROJECT_STATUS_LABEL, getResultStatusLabel } from '../../../ui/copy'
+import { getWorkflowNavigationState } from '../../../components/workflowNavigation'
+import { getResultStatusLabel } from '../../../ui/copy'
import type { EventStatus, ProjectAnalysisStatus } from '../../../types/trace'
import type { AnalysisTarget } from '../types'
import type { ViewMode } from '../../../ui/copy'
@@ -36,6 +36,13 @@ export function AppShell({
onViewChange,
children,
}: AppShellProps) {
+ const activeNavigationState = getWorkflowNavigationState(activeView, {
+ projectStatus,
+ hasDetectedApis,
+ requestReady,
+ traceAvailable: Boolean(traceId),
+ })
+
return (
@@ -46,12 +53,6 @@ export function AppShell({
{projectName}
-
{activeView === 'runtime' ? (
Trace ID{traceId?.slice(0, 8) ?? '대기'}
@@ -60,15 +61,17 @@ export function AppShell({
이벤트{traceEventCount}
) : (
-
- {PROJECT_STATUS_LABEL[projectStatus]}
+
+ {activeNavigationState.label}
)}