v2.2.16: Astra Office UI Overhaul & Operations Floor
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user