v2.2.16: Astra Office UI Overhaul & Operations Floor

This commit is contained in:
g1nation
2026-05-16 22:21:09 +09:00
parent 9ca95ab997
commit 961e2cb4ea
20 changed files with 2632 additions and 212 deletions
+107 -11
View File
@@ -138,6 +138,7 @@ function setSprite(role,mode,frame=0,dir=0){
const a=anim[role]; if(!a) return;
a.mode=mode;a.frame=frame;a.dir=dir;
ch.classList.toggle('walking',mode==='walk');
ch.classList.toggle('working',mode==='work');
const img=ch.querySelector('img');
if(mode==='walk'){
// 4방향 walk sprite — 좌우 sprite를 따로 제공하므로 scaleX 반전은 *안* 한다.
@@ -148,7 +149,9 @@ function setSprite(role,mode,frame=0,dir=0){
img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
img.style.transform='none';
} else if(mode==='work'){
img.src=png('work-r'+a.row+'-f'+frame);
// 기존 work sprite 는 불꽃 연출이 과도해 제품 톤을 깨뜨렸다.
// 작업 중임은 CSS focus treatment 로 표현하고 캐릭터 본체는 idle 계열을 유지.
img.src=png('idle-r'+a.row+'-f'+(frame%2));
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
} else {
img.src=png('idle-r'+a.row+'-f'+frame);
@@ -266,13 +269,14 @@ function sendHome(role,mode='sit'){
walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode));
}
setInterval(()=>{
if(!['idle','done'].includes(_prevStatus || 'idle')) return;
const free=Object.keys(chars).filter(k=>anim[k]?.mode==='sit'&&!chars[k].classList.contains('active'));
if(!free.length)return;
const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k];
if(!st || !Array.isArray(st.roam) || !st.roam.length || !Array.isArray(st.dock)) return;
const pt=st.roam[Math.floor(Math.random()*st.roam.length)];
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
},5600);
},9000);
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';b.textContent=text;b.style.left=(parseFloat(ch.style.left)+28)+'px';b.style.top=(parseFloat(ch.style.top)-6)+'px';stage.appendChild(b);setTimeout(()=>b.remove(),2400)}
@@ -346,19 +350,43 @@ function routeBubble(b){
const role = roleMap[b?.agentId] || 'ceo';
bubble(role, b?.text || '');
}
const STATUS_COPY = {
idle: { label:'대기 중', note:'새로운 작업 요청을 기다리고 있습니다.', tone:'neutral' },
intake: { label:'요청 수신', note:'요청을 읽고 작업 범위를 정리하고 있습니다.', tone:'neutral' },
analyzing: { label:'의도 분석', note:'목표와 제약을 정리하는 중입니다.', tone:'neutral' },
need_clarification: { label:'확인 필요', note:'결정을 위해 추가 입력이 필요합니다.', tone:'warning' },
contract_ready: { label:'브리프 확정', note:'요구사항을 실행 가능한 형태로 정리했습니다.', tone:'neutral' },
planning: { label:'설계 중', note:'실행 순서와 담당을 배치하고 있습니다.', tone:'neutral' },
executing: { label:'실행 중', note:'담당 에이전트가 실제 작업을 진행 중입니다.', tone:'neutral' },
reviewing: { label:'검수 중', note:'산출물의 완성도와 리스크를 점검하고 있습니다.', tone:'neutral' },
waiting_approval: { label:'승인 대기', note:'다음 진행을 위해 사용자의 판단을 기다립니다.', tone:'warning' },
error: { label:'주의 필요', note:'흐름을 멈춘 이슈를 확인해야 합니다.', tone:'danger' },
done: { label:'완료', note:'이번 작업 라운드가 정리되었습니다.', tone:'success' },
};
function _statusMeta(status){
return STATUS_COPY[status] || STATUS_COPY.idle;
}
function _pct(v){
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
}
let _prevStatus = null;
let _lastRenderedLog = null;
function apply(s){
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
const st = s?.status || 'idle';
const meta = _statusMeta(st);
// 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
document.getElementById('status').textContent = st;
document.getElementById('status').className = 'status s-' + st;
const statusEl = document.getElementById('status');
const phasePill = document.getElementById('phasePill');
if(statusEl) statusEl.textContent = meta.label;
if(phasePill) phasePill.dataset.tone = meta.tone;
document.getElementById('agent').textContent = s?.agentName || 'Astra';
document.getElementById('task').textContent = s?.currentTask || '';
document.getElementById('step').textContent = s?.currentStep || '—';
document.getElementById('task').textContent = s?.currentTask || '새 요청을 기다리고 있습니다.';
document.getElementById('step').textContent = s?.currentStep || meta.label;
document.getElementById('phaseLabel').textContent = meta.label;
document.getElementById('phaseNote').textContent = meta.note;
const lastLog = (s?.recentLogs||[]).slice(-1)[0];
document.getElementById('log').textContent = lastLog || '';
document.getElementById('log').textContent = lastLog || '아직 기록된 활동이 없습니다.';
// 작업 중 (executing/reviewing) 상태에 활성 에이전트 머리 위 말풍선 — 로그 변할 때마다.
if(lastLog && lastLog !== _lastRenderedLog){
_lastRenderedLog = lastLog;
@@ -372,7 +400,29 @@ function apply(s){
_bubbleFromLog(r, lastLog);
}
}
document.getElementById('bar').style.width = Math.round((s?.progress||0)*100) + '%';
const progressPct = _pct(s?.progress);
document.getElementById('bar').style.width = progressPct + '%';
document.getElementById('progressLabel').textContent = progressPct + '%';
const attentionCard = document.getElementById('attentionCard');
const attentionTitle = document.getElementById('attentionTitle');
const attentionBody = document.getElementById('attentionBody');
if(s?.awaitingApproval){
attentionCard.dataset.tone = 'warning';
attentionTitle.textContent = '승인 필요';
attentionBody.textContent = s.awaitingApproval;
} else if(Array.isArray(s?.needUserInput) && s.needUserInput.length){
attentionCard.dataset.tone = 'warning';
attentionTitle.textContent = '추가 입력 필요';
attentionBody.textContent = s.needUserInput[0];
} else if(st === 'error'){
attentionCard.dataset.tone = 'danger';
attentionTitle.textContent = '이슈 감지';
attentionBody.textContent = lastLog || meta.note;
} else {
attentionCard.dataset.tone = '';
attentionTitle.textContent = '없음';
attentionBody.textContent = '현재 막힘 없이 진행 중입니다.';
}
// B. 미니 맵 렌더 — pipelineStages가 있을 때만 보임.
const mm = document.getElementById('miniMap');
const stages = s?.pipelineStages;
@@ -555,13 +605,12 @@ function _renderTicker(){
const track = document.getElementById('tickerTrack');
if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
// 무한 스크롤처럼 보이게 동일 리스트 2벌 연결.
const html = _tickerItems.map(it => {
const cls = _classifyTickerItem(it.text);
const ag = it.agentId ? '<span class="tk-agent">'+ it.agentId +'</span>' : '';
return '<span class="tk-item '+ cls +'">'+ ag + (it.text || '').replace(/[<>]/g,'') +'</span>';
}).join('');
track.innerHTML = html + html;
track.innerHTML = html;
}
// ─────────────── Dual-mode message handler (refactor #D) ───────────────
// 옛 pixelOfficeUpdate/Activity 와 새 officeSnapshot 둘 다 listen. 첫 officeSnapshot
@@ -576,6 +625,51 @@ function _phaseToStatus(phase){
if(phase === 'intake') return 'analyzing';
return phase || 'idle';
}
const ROLE_LABELS = {
ceo:'CEO',
planner:'기획',
researcher:'리서치',
designer:'디자인',
developer:'개발',
qa:'QA',
inspector:'감리',
support:'지원',
writer:'문서',
};
const ROLE_SHORT = {
ceo:'CEO',
planner:'PL',
researcher:'RS',
designer:'UX',
developer:'DEV',
qa:'QA',
inspector:'PO',
support:'PM',
writer:'WR',
};
function _renderRoster(roster, activeAgentId){
const wrap = document.getElementById('rosterList');
const count = document.getElementById('rosterCount');
if(!wrap || !count) return;
const list = Array.isArray(roster) ? roster : [];
count.textContent = String(list.length);
if(!list.length){
wrap.innerHTML = '<div class="roster-item"><div class="roster-copy"><strong>등록된 팀이 없습니다</strong><span class="roster-meta">회사 모드를 켜면 라인업이 표시됩니다.</span></div></div>';
return;
}
wrap.innerHTML = list.map((r)=>{
const active = r.agentId === activeAgentId;
const role = ROLE_LABELS[r.roleCategory] || r.roleCategory || '지원';
const short = ROLE_SHORT[r.roleCategory] || 'AG';
return '<div class="roster-item '+(active?'active':'')+'" data-agent="'+(r.agentId||'')+'">'+
'<div class="roster-avatar">'+short+'</div>'+
'<div class="roster-copy">'+
'<strong>'+(r.agentName || r.agentId || 'Agent')+'</strong>'+
'<div class="roster-meta"><span class="roster-status"></span><span>'+role+'</span></div>'+
'</div>'+
'</div>';
}).join('');
}
// refactor #G-full — roster 에 있는 agent 중 desk 가 없는 경우 자동 생성.
// 한 번 처리된 agentId 는 _autoDeskedFor 에 기록 → 사용자가 그 desk 를 지워도 재생성 안 함.
const _autoDeskedFor = new Set();
@@ -589,6 +683,7 @@ function _roleCategoryToCharRow(role){
case 'qa': return 5;
case 'inspector': return 6;
case 'support': return 7;
case 'writer': return 1;
default: return 0;
}
}
@@ -641,6 +736,7 @@ function _ensureRosterDesks(roster){
function applyFromSnapshot(snap){
if(!snap) return;
const roster = Array.isArray(snap.roster) ? snap.roster : [];
_renderRoster(roster, snap.activeAgentId);
_ensureRosterDesks(roster);
const active = (snap.activeAgentId && roster.find(a => a.agentId === snap.activeAgentId)) || roster[0];
const synthetic = {
@@ -872,7 +968,7 @@ function _setEdit(on){
_editMode=!!on;
document.body.dataset.editMode = _editMode?'true':'false';
document.getElementById('editToolbar').style.display = _editMode?'flex':'none';
document.getElementById('editBtn').textContent = _editMode?'편집 종료':'✏️ 편집';
document.getElementById('editBtn').textContent = _editMode?'편집 종료':'Customize';
if(_editMode){
_snapshotBeforeEdit = _snapshotLayout();
} else {