/** * Pixel Office — Agent Work Pipeline 상태를 시각화하는 *UI Layer 전용* 모듈. * * ─────────────────── 설계 원칙 ─────────────────── * 1. **Agent 핵심 판단 로직을 절대 바꾸지 않는다.** Pipeline 진행, contract * 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher / chatHandlers / * SidebarChatProvider 안에서 결정된다. 이 모듈은 그 결정을 *읽고* webview용 * 상태 객체로 변환할 뿐이다. * 2. 입력은 기존에 emit되던 이벤트 (CompanyTurnEvent + alignment phase + * intent classifier 결과)뿐. 새로운 인터럽트 포인트를 만들지 않는다. * 3. 출력은 두 종류 — `AgentWorkState` (현재 작업 패널)와 `AgentBubble` 큐 * (말풍선 연출). 둘 다 webview가 그대로 받아 그리기만 하면 된다. * * 즉 dispatcher 안의 어떤 한 줄도 "if pixelOffice ..."로 분기하지 않는다. */ /** Agent의 현재 단계. 사용자가 명세한 11개 상태값 전부 포함. */ export type AgentStatus = | 'idle' | 'intake' | 'analyzing' | 'need_clarification' | 'contract_ready' | 'planning' | 'executing' | 'reviewing' | 'waiting_approval' | 'error' | 'done'; /** 말풍선이 어떤 카테고리에서 나왔는지 — 스타일링 / 우선순위에 활용. */ export type BubbleType = 'status' | 'event' | 'warning' | 'error' | 'success'; /** 말풍선 발생 트리거가 되는 이벤트 — 사용자가 명세한 10개 + 약간 확장. */ export type AgentEvent = | 'missing_required_info' | 'clarification_needed' | 'requirement_contract_created' | 'plan_completed' | 'execution_started' | 'review_failed' | 'review_passed' | 'risky_change_detected' | 'approval_required' | 'error_occurred' | 'task_completed' | 'stage_loop_retry'; export interface AgentWorkState { agentId: string; agentName: string; status: AgentStatus; /** 사용자 원본 요청 한 줄 — 패널 상단 "Current Task"에 표시. */ currentTask?: string; /** 현재 stage / phase 라벨 — "기획 논의", "QA 진행" 등. */ currentStep?: string; /** 다음 stage 라벨 (있으면) — 예측 표시용. */ nextStep?: string; /** 짧은 보조 메시지 (예: "라운드 2/3", "검수자: 민지"). */ message?: string; /** 진행률 0~1 — 파이프라인 모드일 때 stage index / total로 계산. */ progress?: number; /** Requirement Contract 요약 (alignment 완료 후 채워짐). */ requirementContract?: { goal?: string; context?: string; criteria?: string[]; format?: string; openQuestions?: string[]; confidence?: 'low' | 'medium' | 'high'; }; /** 사용자에게 던지는 미해결 질문 목록 — need_clarification 상태에서 채움. */ needUserInput?: string[]; /** 승인 대기 중 항목 — waiting_approval 상태에서 채움. */ awaitingApproval?: string; /** 짧은 최근 로그 — 사용자가 한눈에 흐름 파악. 최대 6개 ring buffer. */ recentLogs?: string[]; /** * 파이프라인 미니 맵용 stage 리스트. `plan-ready` 시점에 채워지고 agent-start/ * agent-done 으로 상태가 갱신된다. webview는 이 리스트를 dot strip 으로 렌더. * - status: 'done' | 'active' | 'pending' */ pipelineStages?: Array<{ label: string; agent?: string; status: 'done' | 'active' | 'pending' }>; /** epoch ms — webview의 "n초 전" 표시용. */ updatedAt: number; } export interface AgentBubble { id: string; /** 어떤 캐릭터 위에 띄울지 — 단일 캐릭터 모드면 'main' 고정도 가능. */ agentId: string; text: string; type: BubbleType; /** 생성 시각 epoch ms. */ createdAt: number; /** 자동 사라짐 ms (webview가 사용). 기본값은 webview에서 결정. */ durationMs?: number; } /** * 사용자가 설정으로 켜고 끌 수 있는 행동 옵션. * webview는 broadcast마다 같이 받아서 즉시 반영. */ export interface PixelOfficeConfig { enabled: boolean; bubblesEnabled: boolean; maxVisibleBubbles: number; bubbleDurationMs: number; } // ─────────────────── 상태→말풍선 텍스트 풀 ─────────────────── // 사용자가 명세한 톤(가벼운 사무실 코미디) 유지. 무작위 선택을 위해 같은 상태에 // 여러 안을 두되 너무 길어지지 않게 4~5개로 제한. const STATUS_BUBBLE_POOL: Record = { idle: [ '오늘은 무슨 일을 할까?', '주문 대기 중…', '커피 한 잔 더 하고 시작할까.', ], intake: [ '요청서 들어왔다.', '한번 읽어보자.', '오케이, 뭘 원하시는지 보자.', ], analyzing: [ '음… 의도가 조금 모호한데?', '맥락부터 정리해보자.', '핵심이 뭐였더라.', '이거 작업 범위가 어디까지지?', ], need_clarification: [ '이건 사용자 확인이 먼저야.', '질문 하나만 하고 가자.', '추측으로 가면 위험해.', '핵심 정보가 빠졌네.', ], contract_ready: [ '좋아, 작업 조건 정리 완료.', '이제 방향은 잡혔어.', '계약서 도장 찍었다.', '이제 진짜 시작.', ], planning: [ '순서부터 잡아보자.', '기존 기능은 건드리지 말자.', '화이트보드 좀 빌릴게.', '단계 나눠서 가자.', ], executing: [ '코드 들어간다.', '이번엔 단순하게 가자.', '집중 모드 진입.', '키보드 워밍업 완료.', ], reviewing: [ '잠깐, 이건 다시 보자.', '기존 기능 깨지는지 확인해야 해.', '검수자 시점으로 한 번 더.', '엣지 케이스 빠진 거 없나.', ], waiting_approval: [ '이건 승인 없이 못 바꿔.', '위험 작업 감지. 확인 필요!', '사장님 결재 부탁드립니다.', '도장 받기 전엔 멈춤.', ], error: [ '앗, 이건 예상 못 했는데…', '조건 하나 놓쳤네.', '잠깐, 다시 정리.', '엇, 이게 깨졌네.', ], done: [ '좋아, 끝났다!', '이번 작업 깔끔하게 완료.', '커피 한 잔.', '오늘치 끝!', ], }; const EVENT_BUBBLE_POOL: Record = { missing_required_info: [ '핵심 정보가 빠졌어.', '이거 빠지면 추측해야 해.', ], clarification_needed: [ '질문 하나만.', '확실하지 않으면 먼저 물어보자.', ], requirement_contract_created: [ '계약서 도장 찍었다.', '이제 방향은 잡혔어.', ], plan_completed: [ '계획 정리 끝!', '순서대로 가자.', ], execution_started: [ '코드 들어간다.', '시작!', ], review_failed: [ '조건 하나 놓쳤네. 다시 보자.', '너무 복잡하게 가는 거 아냐?', ], review_passed: [ '검수 통과!', '셋 다 만족이래.', ], risky_change_detected: [ '잠깐, 이건 승인 필요!', '파일 삭제는 함부로 하면 안 돼.', ], approval_required: [ '결재 부탁드립니다.', '도장 받고 이어 갈게.', ], error_occurred: [ '앗, 이건 예상 못 했는데…', '엇, 이게 깨졌네.', ], task_completed: [ '좋아, 끝났다!', '오늘치 끝.', ], stage_loop_retry: [ '한 번 더 가자.', '버그 잡고 다시 시도.', ], }; /** * 텍스트 풀에서 하나 뽑기. 같은 결과가 연달아 나오지 않게 lastPicked를 * 받아 회피. 풀이 1개뿐이면 어쩔 수 없이 그걸 반환. */ export function pickBubbleText(pool: string[], lastPicked?: string): string { if (!pool || pool.length === 0) return ''; if (pool.length === 1) return pool[0]; let candidates = pool; if (lastPicked) candidates = pool.filter((s) => s !== lastPicked); if (candidates.length === 0) candidates = pool; return candidates[Math.floor(Math.random() * candidates.length)]; } export function getStatusBubbleText(status: AgentStatus, lastPicked?: string): string { return pickBubbleText(STATUS_BUBBLE_POOL[status] ?? [], lastPicked); } export function getEventBubbleText(event: AgentEvent, lastPicked?: string): string { return pickBubbleText(EVENT_BUBBLE_POOL[event] ?? [], lastPicked); } /** 이벤트 → 말풍선 type 매핑. 색상/스타일을 webview가 결정할 때 사용. */ export function eventBubbleType(event: AgentEvent): BubbleType { switch (event) { case 'error_occurred': return 'error'; case 'review_failed': case 'risky_change_detected': case 'missing_required_info': return 'warning'; case 'task_completed': case 'review_passed': case 'plan_completed': case 'requirement_contract_created': return 'success'; default: return 'event'; } } /** AgentBubble factory — id 자동 생성. */ export function makeBubble(opts: { agentId: string; text: string; type?: BubbleType; durationMs?: number; }): AgentBubble { return { id: `b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, agentId: opts.agentId, text: opts.text, type: opts.type ?? 'status', createdAt: Date.now(), durationMs: opts.durationMs, }; }