feat(control): add live workboard v0

This commit is contained in:
2026-06-19 14:41:43 +09:00
parent be0120bd0c
commit 02476c001f
12 changed files with 1526 additions and 99 deletions

View File

@@ -0,0 +1,547 @@
# 하나랑 대시보드 Rebuild From Scratch Implementation Plan
> **For Hermes:** 이 문서는 하랑이 planner role 산출물이다. 승인 전 구현/배포 금지. 승인 후 나랑이 builder 또는 Codex가 task 단위로 실행하고, 다랑이 reviewer가 spec/code quality를 검증한다.
**Goal:** 기존 하나랑 대시보드를 “자기야가 4자매가 작업하는 과정을 실시간으로 보는 서비스”로 처음부터 다시 설계하고 구현한다. 메인 화면의 핵심은 이랑이/하랑이/나랑이/다랑이가 PLAN → BUILD → REVIEW → DEPLOY 흐름에서 무엇을 하고 있는지 live timeline/board/scene으로 보여주는 것이다.
**Architecture:** 기존 `hanarang-dashboard` repo는 유지하되, 제품/정보구조/UI를 새로 잡는다. Frontend는 Next.js 16 + styled-components를 유지하고, Backend는 NestJS + Prisma/MariaDB + Socket.IO를 유지한다. 메인 서비스는 `4 Sisters Live Workboard`이며, v0는 mock event stream으로 “작업 과정이 보이는 화면”을 먼저 완성한다. v1에서 NestJS Socket.IO event stream과 workflow state API를 붙인다. 기존 코드는 삭제보다 `legacy` 보존/점진 교체를 우선한다.
**Tech Stack:** Next.js 16, React 19, styled-components, NestJS 11, Prisma 7, MariaDB, Socket.IO, Hermes role skills, Proxmox read-only API.
---
## 0. 배경
기존 대시보드는 다음 전제를 갖고 있었다.
- 하랑/나랑/다랑/이랑이 각각 독립 gateway로 상시 동작
- 4자매 서버 상태를 곧 agent 상태로 봄
- OpenClaw/Lobster workflow 흔적이 UI에 남아 있음
- 실시간 office/metaphor와 운영 패널이 섞여 있음
현재 운영 방향은 바뀌었다.
- 자기야의 기본 진입점은 이랑이 하나
- 하랑=planner, 나랑=builder, 다랑=reviewer는 role skill로 호출
- 하랑/나랑/다랑 gateway는 나중에 standby 전환 가능
- Proxmox 서버 상태와 role runnable 상태를 분리해서 봐야 함
- 하네스 엔지니어링 원칙: PLAN → 승인 → 구현 → QA → 배포 게이트
그래서 단순 리팩토링보다 “처음부터 다시 만드는” 수준의 IA/UI 재설계가 맞다.
---
## 1. 제품 방향
### 한 줄 정의
하나랑 대시보드는 자기야가 **4자매가 작업하는 과정을 실시간으로 보는 서비스**다. 단순 관제센터가 아니라, 하랑이의 기획, 나랑이의 구현, 다랑이의 리뷰, 이랑이의 인프라/배포 판단이 어떤 순서로 흘러가는지 메인 화면에서 바로 보여준다.
### 핵심 사용자
- 자기야: 최종 사용자/승인자
- 이랑이: 메인 비서, 오케스트레이터, 인프라 책임자
- 하랑이 role: planner
- 나랑이 role: builder
- 다랑이 role: reviewer
### 새 핵심 은유
기존 “정적인 관제 카드 모음”이 아니라 **4자매 작업실을 실시간으로 보는 화면**이 메인이다.
```text
4 Sisters Live Workboard
├─ Live Work Scene: 지금 누가 어떤 작업 중인지
├─ Workflow Timeline: PLAN → BUILD → REVIEW → DEPLOY 이벤트 흐름
├─ Active Task Focus: 현재 선택된 작업의 상세 상태
├─ Sister Activity Lanes: 하랑/나랑/다랑/이랑 역할별 lane
└─ Infra/Approval Side Panel: 막힌 이유, 승인 필요, 인프라 위험
```
이랑이는 여전히 단일 진입점이지만, 화면의 주인공은 “이랑이 혼자”가 아니라 **4자매가 함께 일하는 과정**이다. 하랑/나랑/다랑이 gateway로 상시 떠 있지 않아도, role execution/event log를 통해 작업 과정이 보여야 한다.
---
## 2. Scope
### 포함 — v0
- 메인 화면을 `4 Sisters Live Workboard`로 설계
- 하랑/나랑/다랑/이랑 activity lane
- PLAN → BUILD → REVIEW → DEPLOY live timeline
- 현재 작업 focus panel
- approval/blocker side panel
- mock event stream으로 실시간처럼 흐르는 UI
- 새 AppShell/Navigation
- 4자매 role status 카드
- Proxmox monitoring link/panel placeholder
- 프로젝트 list placeholder
- mobile-first layout
- mock/static data로 UI 완성
- 기존 route와 충돌하지 않는 방식으로 새 route 구성
### 포함 — v1
- Backend API 계약 연결
- role agent health endpoint 연결
- Proxmox v0 live inventory 연결
- project/workflow API 연결
- Socket.IO live update 일부 적용
### 포함 — v2
- 기존 legacy 화면 정리
- 인증/권한 흐름 재검토
- 실제 action gate UI
- deployment/QA report UI
### 제외 — 당장 하지 않음
- 기존 DB schema 삭제
- 기존 production deploy
- 하랑/나랑/다랑 gateway 중지
- Proxmox write action
- DB migration
- 도메인/Nginx 변경
---
## 3. Product IA
### Primary Navigation
1. **Live** — 4자매 작업 과정 실시간 메인 화면
2. **Harness** — PLAN/Build/Review/Deploy 흐름 상세
3. **Projects** — repo/project/sprint/task
4. **Infra** — Proxmox/server/service health
5. **Agents** — 이랑이 + 하랑/나랑/다랑 role health
6. **Settings** — 토큰/환경/연동 상태. secret 값 표시 금지
### Live 메인 화면
메인 화면은 대시보드의 핵심 서비스다.
상단:
- 현재 active workflow 이름
- live/snapshot/mock/fallback source badge
- 전체 phase: Planning / Building / Reviewing / Waiting Approval / Deploying
- pending approvals / blockers
중앙:
- **4 Sisters Live Workboard**
- 하랑 lane: 요구사항 정리, PLAN 작성, handoff
- 나랑 lane: 구현 task, test/build, diff 생성
- 다랑 lane: spec review, code review, QA 결과
- 이랑 lane: 인프라 확인, 배포 게이트, 위험 차단
- 각 lane에는 현재 상태, 마지막 이벤트, 진행 중 작업, 다음 handoff를 표시한다.
우측:
- Active Task Focus
- 지금 선택한 이벤트/작업 상세
- 관련 파일/plan/QA report 링크
- tool call 또는 verification command 상태
하단:
- Workflow Timeline
- event stream 형태
- PLAN_CREATED, BUILD_STARTED, TEST_PASSED, REVIEW_REQUESTED, QA_FAILED, APPROVAL_NEEDED 같은 이벤트
- Infra/Approval compact panel
### Harness 화면
Live 화면보다 상세한 작업 단위 board다.
- workflow list
- stage별 task cards
- approval gates
- QA report links
- build/test logs
### Agents 화면
agent 상태를 4축으로 표시한다.
| 항목 | 의미 |
|---|---|
| role | erang-main, harang-planner, narang-builder, darang-reviewer |
| mode | main, role-skill, profile, gateway |
| runnable | 현재 호출 가능한지 |
| gateway | active, standby, failed, unknown |
| server | LXC running 여부 |
### Infra 화면
처음엔 Proxmox monitoring PLAN과 연결한다.
- `/infra/proxmox`
- live-only v0
- DB 없음
- write action 없음
---
## 4. Design Principles
1. **운영 판단이 먼저**
- 예쁜 오피스보다 “지금 위험한가?”를 먼저 보여준다.
2. **상태의 출처를 숨기지 않음**
- live / snapshot / mock / fallback 구분.
3. **상시 gateway와 role runnable을 분리**
- gateway standby는 장애가 아니다.
4. **모바일 우선**
- 자기야 iPhone에서 빠르게 봐야 함.
5. **과한 애니메이션 금지**
- 상태 파악 방해 금지.
6. **하네스 원칙 내장**
- PLAN 승인 없이는 Build가 시작되지 않는 UI.
---
## 5. Repo Strategy
### 기본 전략
기존 repo 유지:
```text
/home/erang/hanarang-dashboard
```
새 구현은 기존 route를 바로 갈아엎지 않고, feature branch에서 새 구조를 만든다.
추천 branch:
```bash
git checkout -b feature/rebuild-control-center
```
### 파일 구조 제안
Frontend:
```text
frontend/app/(control)/page.tsx
frontend/app/(control)/harness/page.tsx
frontend/app/(control)/projects/page.tsx
frontend/app/(control)/infra/page.tsx
frontend/app/(control)/infra/proxmox/page.tsx
frontend/app/(control)/agents/page.tsx
frontend/app/(control)/settings/page.tsx
frontend/components/control/AppFrame.tsx
frontend/components/control/TopStatusBar.tsx
frontend/components/control/NavigationRail.tsx
frontend/components/control/StatusCard.tsx
frontend/components/control/SourceBadge.tsx
frontend/components/harness/HarnessBoard.tsx
frontend/components/harness/PipelineStageCard.tsx
frontend/components/harness/ApprovalQueue.tsx
frontend/components/agents/AgentRoleCard.tsx
frontend/components/agents/AgentRoleMap.tsx
frontend/components/infra/InfraSummaryPanel.tsx
frontend/components/infra/ProxmoxSummaryCard.tsx
frontend/lib/control/mock-data.ts
frontend/lib/control/types.ts
frontend/lib/control/api.ts
```
Backend v0/v1:
```text
backend/src/infra/agents/agent-role-health.controller.ts
backend/src/infra/agents/agent-role-health.service.ts
backend/src/infra/proxmox/*
backend/src/control/control.controller.ts
backend/src/control/control.service.ts
```
---
## 6. Tasks
### Task 1: 현재 구조 조사
**Objective:** 새 대시보드를 얹을 수 있는 실제 route/component/API 구조를 확인한다.
**Files:**
- Read: `frontend/app/**`
- Read: `frontend/components/**`
- Read: `frontend/AGENTS.md`
- Read: `backend/src/app.module.ts`
- Read: `backend/src/dashboard/**`
- Read: `backend/src/events/**`
**Commands:**
```bash
cd /home/erang/hanarang-dashboard
find frontend/app -maxdepth 4 -type f | sort
find frontend/components -maxdepth 3 -type f | sort
find backend/src -maxdepth 3 -type f | sort
```
**Verification:**
- Next.js route 구조 확인
- styled-components 사용 패턴 확인
- 기존 API/gateway 패턴 확인
---
### Task 2: 새 제품 스펙 작성
**Objective:** 기존 PRD를 새 운영 구조에 맞게 대체한다.
**Files:**
- Create: `.plans/rebuild-from-scratch/product-spec.md`
- Modify: `docs/product-specs/openclaw-office-dashboard-prd.md` 또는 새 파일 생성
**Acceptance criteria:**
- 이랑이 단일 진입점 명시
- 하랑/나랑/다랑 role agent 모델 명시
- gateway standby != down 명시
- Proxmox monitoring 연결 명시
**Verification:**
- 다랑이 review에서 scope ambiguity 없음
---
### Task 3: Design system 초안
**Objective:** 새 dashboard의 UI 토큰과 컴포넌트 원칙을 정한다.
**Files:**
- Create: `.plans/rebuild-from-scratch/design-system.md`
- Create: `frontend/components/control/README.md`
**Design tokens:**
- background: dark infra control center
- status colors: ok/warning/critical/standby/unknown
- density: desktop 3-column, mobile 1-column
- source badges: live/snapshot/mock/fallback
**Verification:**
- 모든 상태가 색상만이 아니라 text label도 가짐
---
### Task 4: Frontend live workboard skeleton
**Objective:** mock event stream만으로 4자매가 작업하는 과정이 메인 화면에서 실시간처럼 보이게 만든다.
**Files:**
- Create: `frontend/app/(control)/page.tsx`
- Create: `frontend/components/control/AppFrame.tsx`
- Create: `frontend/components/control/TopStatusBar.tsx`
- Create: `frontend/components/control/NavigationRail.tsx`
- Create: `frontend/components/live/LiveWorkboard.tsx`
- Create: `frontend/components/live/SisterActivityLane.tsx`
- Create: `frontend/components/live/WorkflowTimeline.tsx`
- Create: `frontend/components/live/ActiveTaskFocus.tsx`
- Create: `frontend/lib/control/types.ts`
- Create: `frontend/lib/control/mock-events.ts`
**Rules:**
- 기존 `frontend/app/page.tsx`를 바로 삭제하지 않는다.
- 새 route가 충돌하면 `/control` 아래에 먼저 만든다.
- mock data는 `source: 'mock'`을 반드시 표시한다.
- 메인 화면의 첫 impression은 “4자매가 일하고 있다”여야 한다. 인프라/프로젝트 카드는 보조 패널로 둔다.
**Verification:**
```bash
cd frontend
npm run lint
npm run build
```
---
### Task 5: Harness detail board UI
**Objective:** 메인 Live Workboard에서 선택한 workflow를 더 자세히 보는 상세 board를 만든다.
**Files:**
- Create: `frontend/components/harness/HarnessBoard.tsx`
- Create: `frontend/components/harness/PipelineStageCard.tsx`
- Create: `frontend/components/harness/ApprovalQueue.tsx`
**Acceptance criteria:**
- PLAN_REQUIRED, BUILDING, REVIEWING, WAITING_APPROVAL, DEPLOY_READY 상태 표시
- 각 단계에 responsible role 표시
- 승인 대기 항목이 명확함
- Live 화면의 event와 같은 workflow id로 연결됨
**Verification:**
- mock pipeline 3개 렌더링
- 모바일 1열 전환
---
### Task 6: Agent role map UI
**Objective:** 이랑이 중심 role agent 상태를 표시한다.
**Files:**
- Create: `frontend/components/agents/AgentRoleMap.tsx`
- Create: `frontend/components/agents/AgentRoleCard.tsx`
**Acceptance criteria:**
- erang-main = active main
- harang-planner/narang-builder/darang-reviewer = role-skill runnable
- gateway active/standby/failed와 runnable 분리
---
### Task 7: Infra summary placeholder
**Objective:** Proxmox monitoring v0와 연결될 홈 요약 영역을 만든다.
**Files:**
- Create: `frontend/components/infra/InfraSummaryPanel.tsx`
- Create: `frontend/components/infra/ProxmoxSummaryCard.tsx`
**Acceptance criteria:**
- live/snapshot/mock source badge
- critical 우선 정렬
- `/infra/proxmox`로 이동 link
---
### Task 8: Backend live workflow summary API
**Objective:** frontend mock event stream을 backend live/snapshot API 계약으로 전환할 수 있게 한다.
**Files:**
- Create: `backend/src/control/control.module.ts`
- Create: `backend/src/control/control.controller.ts`
- Create: `backend/src/control/control.service.ts`
- Create: `backend/src/control/workflow-events.gateway.ts`
- Modify: `backend/src/app.module.ts`
**Endpoints:**
```text
GET /control/summary
GET /control/workflows/active
WS /control/events
```
**Response:**
```ts
type ControlSummary = {
activeWorkflow: WorkflowSummary | null;
sisters: SisterActivityLaneState[];
events: WorkflowEvent[];
infra: InfraSummary;
approvals: ApprovalItem[];
source: 'live' | 'snapshot' | 'mock' | 'fallback';
updatedAt: string;
};
```
**Verification:**
```bash
cd backend
npm test -- control
npm run build
```
---
### Task 9: API client 연결
**Objective:** frontend가 `/control/summary`를 읽고 실패 시 fallback mock을 쓴다.
**Files:**
- Create: `frontend/lib/control/api.ts`
- Modify: `frontend/app/(control)/page.tsx`
**Acceptance criteria:**
- API 실패 시 fallback 표시
- fallback임을 UI에 명확히 표시
- loading/error/empty 상태 있음
---
### Task 10: QA 문서 작성
**Objective:** 다랑이가 검증할 QA checklist를 만든다.
**Files:**
- Create: `.plans/rebuild-from-scratch/qa-checklist.md`
**Checks:**
- iPhone viewport
- source badge 정확성
- gateway standby != down
- mock/fallback 구분
- PLAN approval gate 표시
- build/lint pass
---
## 7. Approval Gates
### Gate A — Rebuild plan approval
지금 단계. 자기야가 이 PLAN을 승인해야 구현 시작 가능.
### Gate B — Frontend v0 implementation
허용:
- frontend route/component 추가
- mock/static data
- no DB migration
- no deploy
### Gate C — Backend API v0
허용:
- read-only summary endpoint
- mock/fallback service
- no DB migration
### Gate D — Proxmox integration
별도 Proxmox monitoring PLAN과 연결.
### Gate E — Deploy
L3. 별도 승인 필요.
---
## 8. Rollback Plan
- feature branch에서 작업한다.
- 기존 `main`과 production route는 건드리지 않는다.
- 새 route가 문제면 branch 폐기 또는 route 제거.
- 배포 전까지 기존 사이트 영향 없음.
---
## 9. Open Questions
1. 이름을 계속 `하나랑 대시보드`로 둘지, `하나랑 Control Center`로 바꿀지
2. 첫 화면을 `/`로 교체할지, `/control`에서 먼저 검증할지
3. 기존 오피스/등축 UI를 완전히 버릴지, 나중에 `Agents` 화면의 보조 시각화로 남길지
4. 로그인/권한을 유지할지, 자기야 개인망 전용으로 단순화할지
## 10. 이랑이 추천
- 이름: `하나랑 Live Workboard` 또는 `하나랑 Control Center`
- 메인 서비스: 4자매 작업 과정 실시간 화면
- 구현 시작 route: `/control` 또는 `/live`
- 기존 `/`는 당장 유지
- v0는 mock event stream으로 “작업 과정이 보이는 화면”부터 완성
- 이후 backend `/control/summary`, `/control/events` 연결
- 마지막에 `/`를 새 live 화면으로 교체
이 방식이면 자기야가 원하는 “4자매가 일하는 걸 실시간으로 보는 메인 서비스”를 먼저 만들고, 인프라/프로젝트 관제는 그 옆에 붙일 수 있어.

View File

@@ -0,0 +1,57 @@
# Narang Builder Report — Live Workboard v0
## Scope Executed
- Plan: `.plans/rebuild-from-scratch/PLAN.md`
- Task: Frontend live workboard skeleton
- Scope: mock event stream 기반 4자매 작업 과정 메인 화면 구현
## Files Changed
- `frontend/app/page.tsx`
- 기존 사무실 메인 화면을 Live Workboard 메인 화면으로 교체
- `frontend/app/control/page.tsx`
- `/control` route에서도 같은 Live Workboard 확인 가능
- `frontend/components/common/Sidebar.tsx`
- nav에 `Live`, `Control` 항목 추가
- `frontend/components/live/LiveWorkboard.tsx`
- 메인 Live Workboard container
- phase strip, 4자매 lane, active focus, timeline, v0 scope note 구성
- `frontend/components/live/SisterActivityLane.tsx`
- 하랑/나랑/다랑/이랑 activity lane card
- `frontend/components/live/WorkflowTimeline.tsx`
- mock event stream timeline
- `frontend/components/live/ActiveTaskFocus.tsx`
- 선택 이벤트 상세와 approval/risk panel
- `frontend/lib/control/types.ts`
- Live Workboard 타입 정의
- `frontend/lib/control/mock-events.ts`
- v0 mock workflow/sister/event/approval data
## Verification
- `npm run lint -- components/live app/control app/page.tsx lib/control components/common/Sidebar.tsx`: PASS
- `npm run build`: PASS
- `curl -I http://localhost:3004/`: HTTP 200 during dev server smoke test
- Browser accessibility snapshot: `/`에 Live Workboard, 4자매 lane, Workflow Timeline, Active Task Focus 확인
## Known Existing Issues
- `npm run lint` 전체 실행은 기존 파일들의 React 19 lint rule 위반 때문에 FAIL.
- 새로 추가/수정한 Live Workboard 관련 파일만 대상으로 한 lint는 PASS.
- 기존 오류 위치 예시:
- `components/office-room/SisterDetailPanel.tsx`
- `components/rails/FileViewerModal.tsx`
- `components/rails/SubTaskDetailDrawer.tsx`
- `components/rails/TransitionsTimeline.tsx`
## Risks / Notes
- 현재 데이터는 `source: mock`으로 명확히 표시됨.
- 아직 real Socket.IO event stream은 연결하지 않음.
- production deploy는 수행하지 않음.
- DB/Prisma/Nginx/systemd 변경 없음.
## Ready for Darang Review
yes

View File

@@ -0,0 +1,59 @@
# Darang Review Report — Live Workboard v0
## Verdict
PASS WITH IMPORTANT NOTES
## Spec Compliance
- [x] 메인 화면의 핵심을 4자매 작업 과정 실시간 시각화로 변경했다.
- [x] 하랑/나랑/다랑/이랑 activity lane을 구현했다.
- [x] Workflow Timeline을 구현했다.
- [x] Active Task Focus를 구현했다.
- [x] Approval / Risk side panel을 구현했다.
- [x] `source: mock · v0`를 UI에 명확히 표시했다.
- [x] `/` 메인 화면과 `/control` route에서 Live Workboard를 볼 수 있다.
- [x] DB migration, 배포, 인프라 변경 없이 frontend v0 범위만 수행했다.
## Critical Issues
없음.
## Important Issues
1. 전체 `npm run lint`는 기존 legacy 파일 오류 때문에 실패한다.
- 새 파일/수정 파일 대상 lint는 PASS.
- build는 PASS.
2. 브라우저 vision 검증은 인증 상태 차이 때문에 로그인 화면을 보기도 했다.
- accessibility snapshot과 curl smoke에서는 `/` route가 정상 응답하고 Live Workboard 구조가 확인됐다.
- 실제 운영 계정 로그인 후 시각 QA가 한 번 더 필요하다.
## Minor Issues
- `/control``/`가 같은 화면을 보여준다. v1에서 `/control`을 상세 관제 route로 분리할지 결정 필요.
- mock event 자동 회전은 데모용이다. 실제 event stream 연결 시 선택 이벤트 유지 정책을 다시 정해야 한다.
## Verification Checked
- `npm run lint -- components/live app/control app/page.tsx lib/control components/common/Sidebar.tsx`: PASS
- `npm run build`: PASS
- `curl -I http://localhost:3004/`: PASS, HTTP 200
- browser snapshot: PASS, Live Workboard 주요 섹션 확인
## Deployment Risk
Low for local/frontend code.
이유:
- frontend-only 변경
- DB/인프라/배포 없음
- 기존 office 컴포넌트 삭제 없음
- production 반영은 아직 별도 승인 필요
## Required Fixes Before Proceeding
없음.
## Recommended Next Step
v1 전에 먼저 자기야가 화면 방향을 확인한다. 방향이 맞으면 다음 task는 backend event contract (`GET /control/summary`, `WS /control/events`) 설계/구현이다.

View File

@@ -0,0 +1,7 @@
'use client';
import LiveWorkboard from '@/components/live/LiveWorkboard';
export default function ControlPage() {
return <LiveWorkboard />;
}

View File

@@ -1,103 +1,7 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { API_URL } from '@/lib/config';
import OfficeRoom from '@/components/office-room/OfficeRoom';
import ActivePipelineStrip from '@/components/dashboard/ActivePipelineStrip';
const PageRoot = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-xl);
`;
const PageHeader = styled.header`
display: flex;
flex-direction: column;
gap: 4px;
`;
const PageTitle = styled.h1`
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
`;
const PageSub = styled.p`
font-size: 12px;
color: var(--text-secondary);
margin: 0;
font-family: var(--font-mono);
`;
type Stage = 'plan' | 'implement' | 'review' | 'deploy' | null;
const STATE_TO_STAGE: Record<string, Stage> = {
planning: 'plan',
implementing: 'implement',
reviewing: 'review',
deploying: 'deploy',
};
import LiveWorkboard from '@/components/live/LiveWorkboard';
export default function HomePage() {
const [activeStage, setActiveStage] = useState<Stage>(null);
// Poll the rails pipelines list every 4s and pick the most recently
// updated non-terminal pipeline. Map its currentState to a stage so the
// matching sister card pulses.
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const res = await fetch(`${API_URL}/api/rails/pipelines?limit=10`, {
credentials: 'include',
});
if (!res.ok) return;
const data = (await res.json()) as {
pipelines?: Array<{ currentState: string; updatedAt: string }>;
};
if (cancelled) return;
const live = (data.pipelines ?? []).filter((p) =>
['planning', 'implementing', 'reviewing', 'deploying'].includes(
p.currentState,
),
);
if (live.length === 0) {
setActiveStage(null);
return;
}
// Most recently updated wins
const sorted = live.slice().sort((a, b) =>
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''),
);
const top = sorted[0];
if (top) setActiveStage(STATE_TO_STAGE[top.currentState] ?? null);
} catch {
// ignore
}
};
void load();
const t = setInterval(load, 4000);
return () => {
cancelled = true;
clearInterval(t);
};
}, []);
return (
<PageRoot>
<PageHeader>
<PageTitle>🏢 </PageTitle>
<PageSub>
4 , /rails
</PageSub>
</PageHeader>
<OfficeRoom activeStage={activeStage} />
<ActivePipelineStrip />
</PageRoot>
);
return <LiveWorkboard />;
}

View File

@@ -7,7 +7,8 @@ import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/AuthContext';
const NAV_ITEMS = [
{ href: '/', label: '🏢 사무실' },
{ href: '/', label: '🟠 Live' },
{ href: '/control', label: '🧭 Control' },
{ href: '/rails', label: '🚦 레일' },
{ href: '/settings', label: '⚙️ 설정' },
];

View File

@@ -0,0 +1,136 @@
'use client';
import styled from 'styled-components';
import type { ApprovalItem, WorkflowEvent } from '@/lib/control/types';
const Panel = styled.aside`
display: grid;
gap: 14px;
`;
const Card = styled.section`
border: 1px solid var(--border-color);
border-radius: 22px;
padding: 18px;
background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.018));
`;
const Title = styled.h2`
margin: 0 0 12px;
font-size: 15px;
`;
const Eyebrow = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 6px;
`;
const EventTitle = styled.h3`
margin: 0;
font-size: 20px;
line-height: 1.2;
`;
const Detail = styled.p`
margin: 12px 0 0;
color: var(--text-secondary);
font-size: 13px;
`;
const MetaGrid = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 16px;
`;
const Meta = styled.div`
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 10px;
background: rgba(0,0,0,0.18);
`;
const MetaValue = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
overflow-wrap: anywhere;
`;
const ApprovalList = styled.div`
display: grid;
gap: 8px;
`;
const ApprovalRow = styled.div<{ $status: ApprovalItem['status'] }>`
display: grid;
grid-template-columns: 42px 1fr auto;
gap: 8px;
align-items: center;
border: 1px solid ${({ $status }) => {
if ($status === 'blocked') return 'rgba(255, 112, 112, 0.42)';
if ($status === 'waiting') return 'rgba(255, 204, 102, 0.38)';
return 'rgba(139, 211, 255, 0.28)';
}};
border-radius: 14px;
padding: 10px;
background: rgba(0,0,0,0.14);
font-size: 12px;
`;
const Risk = styled.span`
font-family: var(--font-mono);
color: var(--text-secondary);
`;
const Status = styled.span`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
`;
export default function ActiveTaskFocus({
selectedEvent,
approvals,
}: {
selectedEvent: WorkflowEvent;
approvals: ApprovalItem[];
}) {
return (
<Panel>
<Card>
<Title>Active Task Focus</Title>
<Eyebrow>{selectedEvent.sister} · {selectedEvent.type}</Eyebrow>
<EventTitle>{selectedEvent.title}</EventTitle>
<Detail>{selectedEvent.detail}</Detail>
<MetaGrid>
<Meta>
<Eyebrow>workflow</Eyebrow>
<MetaValue>{selectedEvent.workflowId}</MetaValue>
</Meta>
<Meta>
<Eyebrow>source</Eyebrow>
<MetaValue>{selectedEvent.source}</MetaValue>
</Meta>
</MetaGrid>
</Card>
<Card>
<Title>Approval / Risk</Title>
<ApprovalList>
{approvals.map((item) => (
<ApprovalRow key={item.id} $status={item.status}>
<Risk>{item.risk}</Risk>
<span>{item.title}</span>
<Status>{item.status}</Status>
</ApprovalRow>
))}
</ApprovalList>
</Card>
</Panel>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import SisterActivityLane from './SisterActivityLane';
import WorkflowTimeline from './WorkflowTimeline';
import ActiveTaskFocus from './ActiveTaskFocus';
import { mockLiveWorkboard, rotateEvents } from '@/lib/control/mock-events';
const Root = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-lg);
`;
const Hero = styled.header`
border: 1px solid rgba(255,255,255,0.12);
border-radius: 28px;
padding: 24px;
background:
radial-gradient(circle at 16% 18%, rgba(255,143,112,0.18), transparent 34%),
radial-gradient(circle at 82% 12%, rgba(139,211,255,0.16), transparent 28%),
linear-gradient(135deg, rgba(255,255,255,0.075), rgba(255,255,255,0.02));
`;
const HeroTop = styled.div`
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
@media (max-width: 767px) {
flex-direction: column;
}
`;
const TitleBlock = styled.div`
display: grid;
gap: 8px;
`;
const Label = styled.div`
font-family: var(--font-mono);
color: var(--text-secondary);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
`;
const Title = styled.h1`
margin: 0;
font-size: clamp(30px, 5vw, 58px);
line-height: 0.95;
letter-spacing: -0.055em;
`;
const Summary = styled.p`
margin: 0;
max-width: 760px;
color: var(--text-secondary);
font-size: 14px;
`;
const SourceBadge = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 204, 102, 0.35);
border-radius: 999px;
padding: 8px 12px;
color: #ffcc66;
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
`;
const Dot = styled.span`
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffcc66;
box-shadow: 0 0 18px rgba(255, 204, 102, 0.95);
`;
const PhaseGrid = styled.div`
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
margin-top: 22px;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
`;
const PhaseCell = styled.div<{ $active: boolean }>`
border: 1px solid ${({ $active }) => ($active ? 'rgba(255,255,255,0.36)' : 'rgba(255,255,255,0.09)')};
border-radius: 14px;
padding: 10px;
color: ${({ $active }) => ($active ? 'var(--text-primary)' : 'var(--text-secondary)')};
background: ${({ $active }) => ($active ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.16)')};
font-family: var(--font-mono);
font-size: 11px;
`;
const MainGrid = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: var(--space-lg);
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
`;
const LanesGrid = styled.section`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
`;
const BottomGrid = styled.div`
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
gap: var(--space-lg);
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
`;
const NoteCard = styled.section`
border: 1px solid var(--border-color);
border-radius: 22px;
padding: 18px;
background: rgba(255,255,255,0.025);
`;
const NoteTitle = styled.h2`
margin: 0 0 8px;
font-size: 15px;
`;
const NoteText = styled.p`
margin: 0;
color: var(--text-secondary);
font-size: 13px;
`;
const phases = [
['planning', 'PLAN'],
['building', 'BUILD'],
['reviewing', 'REVIEW'],
['waiting_approval', 'APPROVAL'],
['deploying', 'DEPLOY'],
] as const;
export default function LiveWorkboard() {
const [tick, setTick] = useState(0);
const [selectedId, setSelectedId] = useState(mockLiveWorkboard.events[0]?.id ?? '');
const events = useMemo(() => rotateEvents(mockLiveWorkboard.events, tick), [tick]);
const selected = events.find((event) => event.id === selectedId) ?? events[0];
useEffect(() => {
const timer = window.setInterval(() => {
setTick((value) => value + 1);
}, 4200);
return () => window.clearInterval(timer);
}, []);
return (
<Root>
<Hero>
<HeroTop>
<TitleBlock>
<Label>4 Sisters Live Workboard</Label>
<Title> 4 </Title>
<Summary>{mockLiveWorkboard.workflow.summary}</Summary>
</TitleBlock>
<SourceBadge aria-label="data source mock">
<Dot /> source: {mockLiveWorkboard.source} · v0
</SourceBadge>
</HeroTop>
<PhaseGrid>
{phases.map(([phase, label]) => (
<PhaseCell key={phase} $active={mockLiveWorkboard.workflow.phase === phase}>
{label}
</PhaseCell>
))}
</PhaseGrid>
</Hero>
<MainGrid>
<LanesGrid>
{mockLiveWorkboard.sisters.map((lane) => (
<SisterActivityLane key={lane.id} lane={lane} />
))}
</LanesGrid>
<ActiveTaskFocus selectedEvent={selected} approvals={mockLiveWorkboard.approvals} />
</MainGrid>
<BottomGrid>
<WorkflowTimeline
events={events}
selectedId={selected.id}
onSelect={(event) => setSelectedId(event.id)}
/>
<NoteCard>
<NoteTitle>v0 </NoteTitle>
<NoteText>
mock event stream이야. 4 . Socket.IO live event와 `/control/summary` .
</NoteText>
</NoteCard>
</BottomGrid>
</Root>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import styled, { keyframes } from 'styled-components';
import type { SisterLaneState } from '@/lib/control/types';
const pulse = keyframes`
0%, 100% { box-shadow: 0 0 0 rgba(255, 255, 255, 0); }
50% { box-shadow: 0 0 24px rgba(255, 255, 255, 0.08); }
`;
const LaneCard = styled.article<{ $color: string; $active: boolean }>`
position: relative;
min-height: 258px;
border: 1px solid color-mix(in srgb, ${({ $color }) => $color} 42%, var(--border-color));
border-radius: 22px;
padding: 18px;
overflow: hidden;
background:
radial-gradient(circle at top right, color-mix(in srgb, ${({ $color }) => $color} 18%, transparent), transparent 34%),
linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.015));
animation: ${({ $active }) => ($active ? pulse : 'none')} 2.8s ease-in-out infinite;
`;
const TopRow = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
`;
const Identity = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const Name = styled.h3`
margin: 0;
font-size: 18px;
letter-spacing: -0.02em;
`;
const Role = styled.span`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
`;
const StatusPill = styled.span<{ $color: string }>`
border: 1px solid color-mix(in srgb, ${({ $color }) => $color} 56%, var(--border-color));
color: ${({ $color }) => $color};
border-radius: 999px;
padding: 5px 9px;
font-family: var(--font-mono);
font-size: 10px;
background: rgba(0, 0, 0, 0.22);
`;
const TaskBlock = styled.div`
display: grid;
gap: 12px;
`;
const Label = styled.div`
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
font-family: var(--font-mono);
margin-bottom: 4px;
`;
const Body = styled.p`
margin: 0;
font-size: 14px;
color: var(--text-primary);
`;
const Muted = styled.p`
margin: 0;
font-size: 12px;
color: var(--text-secondary);
`;
const ProgressTrack = styled.div`
height: 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
margin-top: 16px;
`;
const ProgressFill = styled.div<{ $progress: number; $color: string }>`
width: ${({ $progress }) => $progress}%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, ${({ $color }) => $color}, color-mix(in srgb, ${({ $color }) => $color} 54%, white));
`;
const MetaRow = styled.div`
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
`;
export default function SisterActivityLane({ lane }: { lane: SisterLaneState }) {
const active = lane.status === 'working' || lane.status === 'thinking' || lane.status === 'reviewing';
return (
<LaneCard $color={lane.color} $active={active}>
<TopRow>
<Identity>
<Name>{lane.name}</Name>
<Role>{lane.role} · {lane.mode}</Role>
</Identity>
<StatusPill $color={lane.color}>{lane.status}</StatusPill>
</TopRow>
<TaskBlock>
<div>
<Label>Current task</Label>
<Body>{lane.currentTask}</Body>
</div>
<div>
<Label>Last event</Label>
<Muted>{lane.lastEvent}</Muted>
</div>
<div>
<Label>Next handoff</Label>
<Muted>{lane.nextHandoff}</Muted>
</div>
</TaskBlock>
<ProgressTrack aria-label={`${lane.name} progress`}>
<ProgressFill $progress={lane.progress} $color={lane.color} />
</ProgressTrack>
<MetaRow>
<span>{lane.phase}</span>
<span>{lane.progress}%</span>
</MetaRow>
</LaneCard>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import styled from 'styled-components';
import type { WorkflowEvent } from '@/lib/control/types';
const TimelineRoot = styled.section`
border: 1px solid var(--border-color);
border-radius: 22px;
background: rgba(255, 255, 255, 0.025);
padding: 18px;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 14px;
`;
const Title = styled.h2`
font-size: 15px;
margin: 0;
`;
const LiveBadge = styled.span`
font-family: var(--font-mono);
font-size: 10px;
color: #ffcc66;
border: 1px solid rgba(255, 204, 102, 0.35);
border-radius: 999px;
padding: 4px 8px;
`;
const EventList = styled.ol`
list-style: none;
display: grid;
gap: 10px;
`;
const EventItem = styled.li<{ $selected: boolean }>`
display: grid;
grid-template-columns: 54px 1fr;
gap: 12px;
padding: 12px;
border-radius: 16px;
border: 1px solid ${({ $selected }) => ($selected ? 'rgba(255,255,255,0.34)' : 'rgba(255,255,255,0.08)')};
background: ${({ $selected }) => ($selected ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.16)')};
cursor: pointer;
transition: border-color .15s ease, background .15s ease;
&:hover {
border-color: rgba(255,255,255,0.26);
}
`;
const EventTime = styled.div`
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
`;
const EventBody = styled.div`
min-width: 0;
`;
const EventTitle = styled.div`
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 700;
`;
const EventDetail = styled.p`
margin: 5px 0 0;
color: var(--text-secondary);
font-size: 12px;
`;
const TypeBadge = styled.span`
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-secondary);
`;
export default function WorkflowTimeline({
events,
selectedId,
onSelect,
}: {
events: WorkflowEvent[];
selectedId: string;
onSelect: (event: WorkflowEvent) => void;
}) {
return (
<TimelineRoot>
<Header>
<Title>Workflow Timeline</Title>
<LiveBadge>mock stream</LiveBadge>
</Header>
<EventList>
{events.map((event) => (
<EventItem
key={event.id}
$selected={event.id === selectedId}
onClick={() => onSelect(event)}
>
<EventTime>{event.at}</EventTime>
<EventBody>
<EventTitle>
<span>{event.title}</span>
<TypeBadge>{event.type}</TypeBadge>
</EventTitle>
<EventDetail>{event.detail}</EventDetail>
</EventBody>
</EventItem>
))}
</EventList>
</TimelineRoot>
);
}

View File

@@ -0,0 +1,146 @@
import type { LiveWorkboardState, WorkflowEvent } from './types';
export const mockLiveWorkboard: LiveWorkboardState = {
source: 'mock',
workflow: {
id: 'wf-hanarang-live-workboard-v0',
title: '하나랑 Live Workboard v0',
phase: 'building',
summary: '4자매가 작업하는 과정을 메인 화면에서 실시간으로 보여주는 첫 버전',
planPath: '.plans/rebuild-from-scratch/PLAN.md',
updatedAt: '방금 전',
source: 'mock',
},
sisters: [
{
id: 'harang',
name: '하랑이',
role: 'Planner',
color: '#8bd3ff',
phase: 'planning',
status: 'waiting',
currentTask: 'PLAN.md 승인 범위 정리 완료',
lastEvent: '나랑이에게 v0 구현 handoff 전달',
nextHandoff: '구현 중 scope 변경 생기면 플랜 보강',
progress: 100,
mode: 'role-skill',
},
{
id: 'narang',
name: '나랑이',
role: 'Builder',
color: '#ffcc66',
phase: 'building',
status: 'working',
currentTask: 'Live Workboard mock UI 구현',
lastEvent: 'Sister activity lane 컴포넌트 생성',
nextHandoff: 'lint/build 결과를 다랑이에게 전달',
progress: 58,
mode: 'role-skill',
},
{
id: 'darang',
name: '다랑이',
role: 'Reviewer',
color: '#c6a5ff',
phase: 'reviewing',
status: 'idle',
currentTask: '구현 완료 대기',
lastEvent: 'QA checklist 준비',
nextHandoff: 'spec compliance review 시작',
progress: 20,
mode: 'role-skill',
},
{
id: 'erang',
name: '이랑이',
role: 'Infra / Orchestrator',
color: '#ff8f70',
phase: 'waiting_approval',
status: 'thinking',
currentTask: '승인 게이트와 배포 위험 분리',
lastEvent: 'DB/배포 변경 없음 확인',
nextHandoff: '다랑이 PASS 후 자기야에게 배포 승인 요청',
progress: 42,
mode: 'main',
},
],
approvals: [
{
id: 'approval-v0-local',
title: 'v0 로컬 UI 구현',
risk: 'L2',
status: 'not-required',
},
{
id: 'approval-deploy',
title: 'production 배포',
risk: 'L3',
status: 'waiting',
},
{
id: 'approval-proxmox-write',
title: 'Proxmox write API / restart action',
risk: 'L4',
status: 'blocked',
},
],
events: [
{
id: 'evt-001',
at: '13:04',
sister: 'harang',
type: 'PLAN_CREATED',
title: '재구축 PLAN 작성',
detail: '메인 서비스를 4 Sisters Live Workboard로 재정의',
workflowId: 'wf-hanarang-live-workboard-v0',
source: 'mock',
},
{
id: 'evt-002',
at: '13:09',
sister: 'erang',
type: 'APPROVAL_NEEDED',
title: 'Gate A 승인 확인',
detail: '자기야가 “그거 먼저 만들어보자”로 v0 구현 승인',
workflowId: 'wf-hanarang-live-workboard-v0',
source: 'mock',
},
{
id: 'evt-003',
at: '13:12',
sister: 'narang',
type: 'BUILD_STARTED',
title: 'Frontend v0 구현 시작',
detail: 'mock event stream 기반 lane/timeline/focus UI 생성',
workflowId: 'wf-hanarang-live-workboard-v0',
source: 'mock',
},
{
id: 'evt-004',
at: '13:15',
sister: 'narang',
type: 'FILES_CHANGED',
title: 'Live Workboard 컴포넌트 추가',
detail: 'SisterActivityLane, WorkflowTimeline, ActiveTaskFocus',
workflowId: 'wf-hanarang-live-workboard-v0',
source: 'mock',
},
{
id: 'evt-005',
at: 'next',
sister: 'darang',
type: 'REVIEW_REQUESTED',
title: '다랑이 리뷰 예정',
detail: 'lint/build 결과 확인 후 spec compliance review',
workflowId: 'wf-hanarang-live-workboard-v0',
source: 'mock',
},
],
};
export function rotateEvents(events: WorkflowEvent[], tick: number): WorkflowEvent[] {
if (events.length === 0) return events;
const offset = tick % events.length;
return [...events.slice(offset), ...events.slice(0, offset)];
}

View File

@@ -0,0 +1,75 @@
export type SourceKind = 'live' | 'snapshot' | 'mock' | 'fallback';
export type SisterId = 'harang' | 'narang' | 'darang' | 'erang';
export type WorkflowPhase =
| 'planning'
| 'building'
| 'reviewing'
| 'waiting_approval'
| 'deploying'
| 'blocked'
| 'done';
export type WorkflowEventType =
| 'PLAN_CREATED'
| 'HANDOFF_SENT'
| 'BUILD_STARTED'
| 'FILES_CHANGED'
| 'TEST_RUNNING'
| 'TEST_PASSED'
| 'REVIEW_REQUESTED'
| 'QA_BLOCKED'
| 'APPROVAL_NEEDED'
| 'INFRA_CHECKED'
| 'DEPLOY_READY';
export type SisterLaneState = {
id: SisterId;
name: string;
role: string;
color: string;
phase: WorkflowPhase;
status: 'idle' | 'thinking' | 'working' | 'reviewing' | 'blocked' | 'waiting';
currentTask: string;
lastEvent: string;
nextHandoff: string;
progress: number;
mode: 'main' | 'role-skill' | 'gateway' | 'standby';
};
export type WorkflowEvent = {
id: string;
at: string;
sister: SisterId;
type: WorkflowEventType;
title: string;
detail: string;
workflowId: string;
source: SourceKind;
};
export type ActiveWorkflow = {
id: string;
title: string;
phase: WorkflowPhase;
summary: string;
planPath: string;
updatedAt: string;
source: SourceKind;
};
export type ApprovalItem = {
id: string;
title: string;
risk: 'L1' | 'L2' | 'L3' | 'L4';
status: 'waiting' | 'not-required' | 'blocked';
};
export type LiveWorkboardState = {
workflow: ActiveWorkflow;
sisters: SisterLaneState[];
events: WorkflowEvent[];
approvals: ApprovalItem[];
source: SourceKind;
};