From bbbdb49520084bf86fdd61d18c7fea4582f625fe Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 14:53:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(workflow):=20=EC=B2=B4=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=ED=99=94=20=EC=8A=A4=ED=8E=99=20(=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-05-workflow-chain-view-design.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-workflow-chain-view-design.md diff --git a/docs/superpowers/specs/2026-08-05-workflow-chain-view-design.md b/docs/superpowers/specs/2026-08-05-workflow-chain-view-design.md new file mode 100644 index 0000000..7da0d21 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-workflow-chain-view-design.md @@ -0,0 +1,32 @@ +# Maestro Workflow 체인 시각화 설계 (대시보드) + +- 날짜: 2026-08-05 +- 상태: 확정 (이메일 유스케이스 잔여 — 운영자가 이전 단계를 보고 결정) +- 범위: `workflow/src/` 하위만 (서버 무변경 — 기존 chain API 사용) + +## 1. 동작 + +- **ChannelBoard 카드**: `parentRequestId`가 있는 요청에 "🔗 체인" 배지 표시. +- **DecisionSheet**: 선택한 요청이 체인에 속하면(App이 `chain` prop 전달) + 상단에 컴팩트 타임라인 표시 — 체인의 각 요청을 순서대로 + `subjectType · title · 상태(결정됨/대기)`로, 현재 요청은 강조. + 체인 로드 실패 시 조용히 생략(기존 "체인 이전 요청: id" 라인 유지). +- **App**: 요청 선택 시 `parentRequestId`가 있으면 `fetchRequestChain`으로 + 체인을 불러 sheet에 전달. 없으면 즉시 열림(로딩 블로킹 없음 — 체인은 + 도착하는 대로 표시). + +## 2. api.js + +`fetchRequestChain(requestId)` → `GET /api/decision-requests/:id/chain` +(기존 토큰 상태 재사용 — 운영자/서버 토큰). + +## 3. 테스트 + +- ChannelBoard: parentRequestId 카드에 배지, 없는 카드엔 없음. +- DecisionSheet: chain prop 타임라인 렌더(순서·현재 강조·상태 라벨). +- App: 체인 요청 선택 시 fetchRequestChain 호출 + sheet에 표시 (mock). + +## 4. 비범위 + +레인 자체를 체인으로 묶는 보드 재배치, 서버 변경, 체인 내 결정 코멘트 +전문 표시(타이틀·상태만). From 23922187dc96915997d1082163595b836043f1ff Mon Sep 17 00:00:00 2001 From: "sunjin.jo" Date: Wed, 5 Aug 2026 14:57:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(workflow):=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EC=B2=B4=EC=9D=B8=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94=20=E2=80=94=20=EB=B0=B0=EC=A7=80=20+=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=ED=8A=B8=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 체인에 속한 요청 카드에 🔗 배지를 표시하고, 선택 시 chain API로 체인 전체를 불러 결정 시트 상단에 타임라인(subjectType · 제목 · 상태, 현재 요청 강조)으로 보여준다. 로드는 논블로킹이며 실패 시 기존 "체인 이전 요청" 라인으로 폴백. 서버 무변경. Co-Authored-By: Claude Fable 5 --- workflow/src/App.chain.test.jsx | 70 +++++++++++++++++++ workflow/src/App.jsx | 21 +++++- workflow/src/components/ChannelBoard.jsx | 3 + workflow/src/components/ChannelBoard.test.jsx | 16 +++++ workflow/src/components/DecisionSheet.jsx | 16 ++++- .../src/components/DecisionSheet.test.jsx | 25 +++++++ workflow/src/lib/api.js | 6 ++ 7 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 workflow/src/App.chain.test.jsx diff --git a/workflow/src/App.chain.test.jsx b/workflow/src/App.chain.test.jsx new file mode 100644 index 0000000..9523d1c --- /dev/null +++ b/workflow/src/App.chain.test.jsx @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const fetchPendingRequests = vi.fn(); +const fetchRequestChain = vi.fn(); +vi.mock('./lib/api.js', () => ({ + WS_URL: 'ws://test', + fetchPendingRequests: (...args) => fetchPendingRequests(...args), + fetchHistory: vi.fn().mockResolvedValue([]), + decideRequest: vi.fn(), + fetchRequestChain: (...args) => fetchRequestChain(...args), + loadServerToken: vi.fn().mockReturnValue(''), + getServerToken: vi.fn().mockReturnValue(''), + setServerToken: vi.fn(), +})); + +import App from './App.jsx'; + +class FakeWebSocket { + send() {} + close() {} +} + +describe('App 체인 시각화', () => { + beforeEach(() => { + vi.stubGlobal('WebSocket', FakeWebSocket); + fetchPendingRequests.mockReset(); + fetchRequestChain.mockReset(); + }); + + it('체인 요청 선택 시 체인을 불러 시트에 타임라인으로 보여준다', async () => { + const reply = { + requestId: 'dcr_2', + parentRequestId: 'dcr_1', + subjectType: 'email-reply', + actorId: 'agent_mail', + subject: { title: '답장 초안 v1', summary: '', payload: {} }, + }; + fetchPendingRequests.mockResolvedValue([reply]); + fetchRequestChain.mockResolvedValue([ + { requestId: 'dcr_1', subjectType: 'email-triage', status: 'decided', subject: { title: '메일 분류' } }, + { ...reply, status: 'pending_decision' }, + ]); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('답장 초안 v1')); + + expect(fetchRequestChain).toHaveBeenCalledWith('dcr_2'); + const timeline = await screen.findByLabelText('결정 체인'); + expect(timeline.textContent).toContain('메일 분류'); + }); + + it('체인 없는 요청은 체인 조회를 하지 않는다', async () => { + fetchPendingRequests.mockResolvedValue([{ + requestId: 'dcr_solo', + subjectType: 'spend', + actorId: 'agent_a', + subject: { title: '단독 요청', summary: '', payload: {} }, + }]); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('단독 요청')); + + expect(fetchRequestChain).not.toHaveBeenCalled(); + expect(screen.queryByLabelText('결정 체인')).not.toBeInTheDocument(); + }); +}); diff --git a/workflow/src/App.jsx b/workflow/src/App.jsx index 8275152..96f9431 100644 --- a/workflow/src/App.jsx +++ b/workflow/src/App.jsx @@ -8,6 +8,7 @@ import { decideRequest, fetchHistory, fetchPendingRequests, + fetchRequestChain, getServerToken, loadServerToken, setServerToken, @@ -19,6 +20,7 @@ const MAX_RECONNECT_DELAY_MS = 15000; export default function App() { const [requests, setRequests] = useState([]); const [selected, setSelected] = useState(null); + const [selectedChain, setSelectedChain] = useState(null); const [connected, setConnected] = useState(false); const [showHistory, setShowHistory] = useState(false); const [history, setHistory] = useState([]); @@ -106,17 +108,32 @@ export default function App() { {showHistory ? ( ) : ( - + { + setSelected(request); + setSelectedChain(null); + if (request.parentRequestId) { + // 체인 로드는 논블로킹 — 도착하는 대로 시트에 타임라인 표시 (스펙 2026-08-05 §1) + fetchRequestChain(request.requestId).then(setSelectedChain).catch(() => {}); + } + }} + /> )} {selected ? ( setSelected(null)} + chain={selectedChain} + onClose={() => { + setSelected(null); + setSelectedChain(null); + }} onDecide={(decision, comment) => { decideRequest(selected.requestId, { decision, comment }) .catch(() => {}) .finally(() => { setSelected(null); + setSelectedChain(null); reload(); }); }} diff --git a/workflow/src/components/ChannelBoard.jsx b/workflow/src/components/ChannelBoard.jsx index 871510b..321f1bf 100644 --- a/workflow/src/components/ChannelBoard.jsx +++ b/workflow/src/components/ChannelBoard.jsx @@ -23,6 +23,9 @@ export default function ChannelBoard({ requests, channelCount = 4, onSelect }) { {request.subjectType} + {request.parentRequestId ? ( + 🔗 체인 + ) : null} {request.subject.title} {request.actorId} diff --git a/workflow/src/components/ChannelBoard.test.jsx b/workflow/src/components/ChannelBoard.test.jsx index 7cba3dc..1da3fa1 100644 --- a/workflow/src/components/ChannelBoard.test.jsx +++ b/workflow/src/components/ChannelBoard.test.jsx @@ -35,3 +35,19 @@ describe('ChannelBoard', () => { expect(onSelect).toHaveBeenCalledWith(requests[1]); }); }); + +it('체인에 속한 요청 카드에 체인 배지를 표시한다', () => { + render( + {}} + />, + ); + const notes = screen.getAllByTestId('decision-note'); + expect(notes.some((note) => note.textContent.includes('체인'))).toBe(true); + const rootNote = notes.find((note) => note.textContent.includes('분류')); + expect(rootNote.textContent.includes('체인')).toBe(false); +}); diff --git a/workflow/src/components/DecisionSheet.jsx b/workflow/src/components/DecisionSheet.jsx index 8971367..1427ed4 100644 --- a/workflow/src/components/DecisionSheet.jsx +++ b/workflow/src/components/DecisionSheet.jsx @@ -9,7 +9,7 @@ const SECONDARY_ACTIONS = [ ]; // 결정 시트: 상세 표시 + 승인/반려. 반려는 사유 칩 + 자유 입력 (본체 터치 반려 시트 패턴 계승). -export default function DecisionSheet({ request, onDecide, onClose }) { +export default function DecisionSheet({ request, onDecide, onClose, chain = null }) { const [rejecting, setRejecting] = useState(false); const [reason, setReason] = useState(''); const highlight = formatPresetHighlight(request.subjectType, request.subject.payload); @@ -42,7 +42,19 @@ export default function DecisionSheet({ request, onDecide, onClose }) { {JSON.stringify(request.subject.payload, null, 2)}
요청자: {request.actorId}
- {request.parentRequestId ? ( + {chain && chain.length > 1 ? ( +
    + {chain.map((item) => { + const isCurrent = item.requestId === request.requestId; + return ( +
  1. + {item.subjectType} · {item.subject?.title} · {item.status === 'pending_decision' ? '대기' : '결정됨'} + {isCurrent ? ' ← 현재' : ''} +
  2. + ); + })} +
+ ) : request.parentRequestId ? (
체인 이전 요청: {request.parentRequestId}
) : null} diff --git a/workflow/src/components/DecisionSheet.test.jsx b/workflow/src/components/DecisionSheet.test.jsx index 6a0eb92..a10c1f2 100644 --- a/workflow/src/components/DecisionSheet.test.jsx +++ b/workflow/src/components/DecisionSheet.test.jsx @@ -52,6 +52,31 @@ describe('DecisionSheet', () => { expect(onDecide).toHaveBeenCalledWith('revise', ''); }); + it('chain prop이 있으면 타임라인을 렌더하고 현재 요청을 강조한다', () => { + render( + {}} + onClose={() => {}} + />, + ); + const timeline = screen.getByLabelText('결정 체인'); + expect(timeline.textContent).toContain('메일 분류'); + expect(timeline.textContent).toContain('결정됨'); + expect(timeline.textContent).toContain('대기'); + expect(timeline.textContent).toContain('← 현재'); + }); + it('parentRequestId가 있으면 체인 이전 요청을 표시한다', () => { render(