Files
connectai/.astra-office-preview.html
T

1794 lines
86 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src http://127.0.0.1:8765 data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
:root{
--bg:#070A12;
--bg-soft:#0D1220;
--surface:rgba(15,21,36,.78);
--surface-strong:rgba(18,25,43,.94);
--surface-faint:rgba(255,255,255,.045);
--line:rgba(255,255,255,.09);
--line-strong:rgba(255,255,255,.16);
--text:#F5F7FC;
--muted:#9BA6BF;
--accent:#8A7CFF;
--accent-2:#46D8FF;
--success:#35D7A4;
--warning:#F5C45A;
--danger:#FF6B7A;
--shadow:0 18px 60px rgba(0,0,0,.42);
--radius-xl:24px;
--radius-lg:18px;
--radius-md:14px;
--radius-sm:10px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(circle at 12% 0%,rgba(138,124,255,.18),transparent 28%),
radial-gradient(circle at 90% 10%,rgba(70,216,255,.12),transparent 24%),
radial-gradient(circle at 50% 100%,rgba(53,215,164,.08),transparent 32%),
linear-gradient(180deg,#070A12 0%,#0A0F1A 100%);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
overflow:hidden;
}
button,input,select{font:inherit}
.office-app{height:100vh;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;gap:14px;padding:18px}
.topbar{
min-height:70px;
display:flex;
align-items:center;
justify-content:space-between;
gap:18px;
padding:14px 16px 14px 18px;
border:1px solid var(--line);
border-radius:var(--radius-xl);
background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.035));
backdrop-filter:blur(18px);
box-shadow:var(--shadow);
}
.brand-block{display:flex;flex-direction:column;gap:3px;min-width:180px}
.eyebrow,.panel-kicker{font-size:10px;line-height:1;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.h-title{font-size:22px;line-height:1.1;font-weight:750;letter-spacing:-.03em}
.topbar-center{display:flex;align-items:center;justify-content:center;gap:10px;flex:1;min-width:0}
.phase-pill,.agent-pill{
min-height:38px;
display:flex;
align-items:center;
gap:9px;
padding:0 14px;
border:1px solid var(--line);
border-radius:999px;
background:rgba(255,255,255,.05);
white-space:nowrap;
}
.phase-pill{font-weight:650}
.phase-pill .phase-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 16px rgba(138,124,255,.8)}
.phase-pill[data-tone="success"] .phase-dot{background:var(--success);box-shadow:0 0 16px rgba(53,215,164,.8)}
.phase-pill[data-tone="warning"] .phase-dot{background:var(--warning);box-shadow:0 0 16px rgba(245,196,90,.8)}
.phase-pill[data-tone="danger"] .phase-dot{background:var(--danger);box-shadow:0 0 16px rgba(255,107,122,.8)}
.agent-pill{color:var(--muted)}
.agent-pill strong{color:var(--text);font-size:13px}
.agent-label{font-size:11px}
.topbar-actions{display:flex;align-items:center;justify-content:flex-end;min-width:180px}
.edit-btn{
height:38px;
border-radius:999px;
padding:0 15px;
border:1px solid rgba(138,124,255,.35);
color:var(--text);
background:linear-gradient(180deg,rgba(138,124,255,.22),rgba(138,124,255,.12));
cursor:pointer;
transition:transform .16s ease,border-color .16s ease,background .16s ease;
}
.edit-btn:hover{transform:translateY(-1px);border-color:rgba(138,124,255,.62);background:linear-gradient(180deg,rgba(138,124,255,.3),rgba(138,124,255,.16))}
.edit-toolbar{
display:flex;
align-items:center;
gap:8px;
flex-wrap:wrap;
padding:10px 12px;
border:1px solid rgba(138,124,255,.28);
border-radius:var(--radius-lg);
background:rgba(138,124,255,.12);
backdrop-filter:blur(16px);
}
.edit-toolbar .et-hint{flex:1;min-width:220px;color:#DCE2F2;font-size:12px}
.edit-toolbar button{
min-height:32px;
padding:0 11px;
border-radius:999px;
border:1px solid var(--line-strong);
color:var(--text);
background:rgba(255,255,255,.07);
cursor:pointer;
}
.edit-toolbar button:hover{background:rgba(255,255,255,.12)}
.edit-toolbar button.add{border-color:rgba(53,215,164,.4);background:rgba(53,215,164,.12)}
.edit-toolbar button.del{border-color:rgba(255,107,122,.42);background:rgba(255,107,122,.12)}
.edit-toolbar button[disabled]{opacity:.42;cursor:not-allowed}
.workspace{
min-height:0;
display:grid;
grid-template-columns:260px minmax(620px,1fr) 280px;
gap:14px;
}
.side-panel,.office-shell,.activity-dock{
border:1px solid var(--line);
border-radius:var(--radius-xl);
background:var(--surface);
backdrop-filter:blur(20px);
box-shadow:var(--shadow);
}
.side-panel{padding:16px;min-height:0;overflow:hidden}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
.panel-head.compact{margin-bottom:16px}
.panel-head h2{font-size:16px;line-height:1.2;letter-spacing:-.02em;margin:6px 0 0}
.count-badge{
min-width:28px;
height:28px;
display:grid;
place-items:center;
border-radius:999px;
border:1px solid rgba(70,216,255,.25);
background:rgba(70,216,255,.12);
color:#BDEFFF;
font-size:12px;
font-weight:700;
}
.roster-list{display:flex;flex-direction:column;gap:9px;overflow:auto;padding-right:2px;max-height:calc(100vh - 220px)}
.roster-item{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:11px;
align-items:center;
padding:11px;
border-radius:var(--radius-md);
border:1px solid transparent;
background:rgba(255,255,255,.035);
transition:border-color .16s ease,background .16s ease,transform .16s ease;
}
.roster-item.active{border-color:rgba(138,124,255,.48);background:linear-gradient(180deg,rgba(138,124,255,.16),rgba(138,124,255,.08));transform:translateX(2px)}
.roster-avatar{
width:33px;height:33px;border-radius:12px;
display:grid;place-items:center;
background:rgba(255,255,255,.08);
border:1px solid var(--line);
color:var(--role-color,var(--accent));
font-size:11px;font-weight:700;
}
.roster-copy{min-width:0;display:flex;flex-direction:column;gap:3px}
.roster-copy strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.roster-meta{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px}
.roster-status{width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,.28)}
.roster-item.active .roster-status{background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent))}
.office-shell{min-width:0;display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden}
.mission-strip{
min-height:72px;
display:flex;
justify-content:space-between;
align-items:center;
gap:18px;
padding:15px 18px;
border-bottom:1px solid var(--line);
}
.mission-title{margin-top:6px;font-size:16px;font-weight:650;letter-spacing:-.02em;max-width:min(560px,52vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mission-step-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:5px;min-width:160px}
.mission-step-wrap span{font-size:11px;color:var(--muted)}
.mission-step-wrap strong{font-size:13px;font-weight:650;text-align:right;max-width:220px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mini-map{display:flex;align-items:center;gap:6px;min-height:42px;padding:0 18px;border-bottom:1px solid var(--line);overflow-x:auto;scrollbar-width:none}
.mini-map::-webkit-scrollbar{display:none}
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.18);flex-shrink:0;transition:all .2s ease}
.mini-map .mm-dot[data-status="done"]{background:var(--success);border-color:var(--success);box-shadow:0 0 12px rgba(53,215,164,.35)}
.mini-map .mm-dot[data-status="active"]{width:14px;height:14px;background:var(--accent);border-color:var(--accent);box-shadow:0 0 0 4px rgba(138,124,255,.16)}
.mini-map .mm-bar{flex:1;height:1px;min-width:22px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.18))}
.mini-map .mm-label{position:absolute;left:50%;top:-29px;transform:translateX(-50%);padding:4px 7px;border-radius:999px;background:rgba(6,9,16,.95);border:1px solid var(--line);font-size:10px;white-space:nowrap;color:var(--text);opacity:0;pointer-events:none;transition:opacity .14s ease;z-index:40}
.mini-map .mm-dot:hover .mm-label{opacity:1}
.mini-map .mm-counter{margin-left:6px;color:var(--muted);font-size:11px;white-space:nowrap}
.office-stage-wrap{min-height:0;padding:14px;display:flex}
.office{
position:relative;
flex:1;
min-height:420px;
overflow:hidden;
display:flex;
align-items:center;
justify-content:center;
border-radius:20px;
border:1px solid rgba(255,255,255,.08);
background:
linear-gradient(180deg,rgba(18,28,48,.98) 0 16%,transparent 16%),
radial-gradient(circle at 50% -10%,rgba(138,124,255,.24),transparent 32%),
radial-gradient(circle at 18% 100%,rgba(70,216,255,.12),transparent 28%),
linear-gradient(135deg,#31283A,#201A29 72%);
}
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.032) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.032) 1px,transparent 1px);background-size:48px 48px}
.office:after{content:'';position:absolute;inset:0;background:radial-gradient(circle at 50% 50%,transparent 0 40%,rgba(0,0,0,.18) 100%);pointer-events:none}
.stage{position:relative;width:720px;height:585px;margin:0;z-index:2}
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:2px solid rgba(215,228,255,.35);border-radius:8px;background:linear-gradient(180deg,rgba(160,208,255,.34),rgba(110,150,210,.08));box-shadow:inset 0 0 0 1px rgba(15,20,31,.55)}
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
.obj{filter:drop-shadow(3px 5px 0 rgba(0,0,0,.28));z-index:4}
.desk{width:112px;z-index:5;filter:drop-shadow(4px 7px 0 rgba(0,0,0,.28))}
.desk.boss{width:136px}
.label{position:absolute;left:50%;bottom:-15px;transform:translateX(-50%);font-size:10px;color:rgba(245,247,252,.8);white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.8)}
.char{width:56px;height:72px;z-index:7;transition:left .9s cubic-bezier(.2,.7,.2,1),top .9s cubic-bezier(.2,.7,.2,1)}
.char.walking{z-index:14}
.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 3px 0 rgba(0,0,0,.42));transform-origin:center bottom}
.char.active:before{content:'';position:absolute;left:19px;top:-14px;width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,var(--role-color,var(--accent)) 0 28%,rgba(255,255,255,.18) 30%,transparent 72%);filter:blur(.2px);animation:focusPulse 1.8s ease-in-out infinite}
.char.active::after{content:'';position:absolute;left:-4px;right:-4px;bottom:-7px;height:8px;border-radius:999px;background:radial-gradient(circle,var(--role-color,var(--accent)),transparent 70%);opacity:.82;filter:blur(1px)}
.char.working img{animation:workLean 1.6s ease-in-out infinite}
@keyframes focusPulse{0%,100%{transform:scale(.95);opacity:.88}50%{transform:scale(1.12);opacity:1}}
@keyframes workLean{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
.char[data-agent="ceo"],.desk[data-agent="ceo"],.roster-item[data-agent="ceo"]{--role-color:#A78BFA}
.char[data-agent="planner"],.desk[data-agent="planner"],.roster-item[data-agent="planner"]{--role-color:#60A5FA}
.char[data-agent="researcher"],.desk[data-agent="researcher"],.roster-item[data-agent="researcher"]{--role-color:#35D7A4}
.char[data-agent="designer"],.desk[data-agent="designer"],.roster-item[data-agent="designer"]{--role-color:#F472B6}
.char[data-agent="developer"],.desk[data-agent="developer"],.roster-item[data-agent="developer"]{--role-color:#F5C45A}
.char[data-agent="qa"],.desk[data-agent="qa"],.roster-item[data-agent="qa"]{--role-color:#46D8FF}
.char[data-agent="inspector"],.desk[data-agent="inspector"],.roster-item[data-agent="inspector"]{--role-color:#FB923C}
.char[data-agent="support"],.desk[data-agent="support"],.roster-item[data-agent="support"]{--role-color:#94A3B8}
.char[data-agent="writer"],.desk[data-agent="writer"],.roster-item[data-agent="writer"]{--role-color:#FBBF24}
.desk::after{content:'';position:absolute;inset:-4px;border-radius:10px;border:1px solid transparent;pointer-events:none;transition:border-color .2s ease,box-shadow .2s ease}
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after,
.stage:has(.char.active[data-agent="writer"]) .desk[data-agent="writer"]::after{border-color:var(--role-color);box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 18px color-mix(in srgb,var(--role-color) 35%,transparent)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);max-width:180px;padding:7px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.14);background:rgba(10,14,24,.92);color:var(--text);font-size:11px;line-height:1.2;box-shadow:0 10px 24px rgba(0,0,0,.28);white-space:nowrap}
.brief-grid{display:flex;flex-direction:column;gap:10px}
.brief-card{
padding:14px;
border-radius:var(--radius-md);
border:1px solid var(--line);
background:rgba(255,255,255,.035);
}
.brief-card.hero{background:linear-gradient(180deg,rgba(138,124,255,.16),rgba(255,255,255,.03));border-color:rgba(138,124,255,.28)}
.brief-card span{display:block;font-size:11px;color:var(--muted);margin-bottom:6px}
.brief-card strong{display:block;font-size:17px;line-height:1.2;letter-spacing:-.02em}
.brief-card p{margin:8px 0 0;color:#D6DDF0;font-size:12px;line-height:1.5}
#attentionCard[data-tone="warning"]{border-color:rgba(245,196,90,.32);background:rgba(245,196,90,.08)}
#attentionCard[data-tone="danger"]{border-color:rgba(255,107,122,.34);background:rgba(255,107,122,.08)}
.progress{height:8px;margin-top:12px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}
.bar{height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-2));transition:width .22s ease}
.activity-dock{padding:14px 16px 15px;display:flex;flex-direction:column;gap:10px}
.activity-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px}
.activity-head strong{display:block;font-size:14px;margin-top:5px}
.last-log{max-width:65%;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ticker{position:relative;overflow:hidden}
.tk-track{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding-bottom:1px}
.tk-track::-webkit-scrollbar{display:none}
.tk-item{
flex-shrink:0;
display:inline-flex;
align-items:center;
gap:7px;
min-height:30px;
padding:0 11px;
border-radius:999px;
border:1px solid var(--line);
background:rgba(255,255,255,.045);
color:#DCE3F3;
font-size:11px;
}
.tk-item.tk-ok{border-color:rgba(53,215,164,.26)}
.tk-item.tk-warn{border-color:rgba(245,196,90,.28)}
.tk-item.tk-err{border-color:rgba(255,107,122,.3)}
.tk-item .tk-agent{color:#C6BEFF;font-weight:650}
.ctx-menu{position:fixed;z-index:1000;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 18px 48px rgba(0,0,0,.5);padding:6px;min-width:180px;font-size:12px;color:var(--text)}
.ctx-menu-head{padding:7px 10px 8px;font-size:10px;color:var(--muted);border-bottom:1px solid var(--line);margin-bottom:4px}
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:10px;transition:background .12s ease}
.ctx-menu-item:hover{background:rgba(138,124,255,.16)}
.ctx-menu-item.danger:hover{background:rgba(255,107,122,.14);color:#FFC3CC}
.ctx-menu-divider{height:1px;background:var(--line);margin:4px}
body[data-edit-mode="true"] .ctx-menu{display:none!important}
body:not([data-edit-mode="true"]) .char{cursor:pointer}
.ctx-detail{position:fixed;z-index:1001;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 20px 56px rgba(0,0,0,.56);padding:18px;color:var(--text);min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
.ctx-detail h3{margin:0 0 10px;font-size:14px;color:var(--role-color,#A78BFA);letter-spacing:.02em}
.ctx-detail .cd-close{position:absolute;top:10px;right:12px;background:transparent;border:none;color:var(--muted);font-size:16px;cursor:pointer}
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.ctx-detail dt{color:var(--muted);font-weight:600;white-space:nowrap}
.ctx-detail dd{margin:0;color:var(--text);overflow-wrap:anywhere}
.ctx-detail .cd-logs{margin-top:10px;padding:8px 10px;background:rgba(255,255,255,.045);border-radius:12px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
.prop-panel{position:absolute;right:14px;top:14px;width:250px;background:rgba(10,14,24,.96);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 18px 48px rgba(0,0,0,.5);padding:14px;font-size:11px;color:var(--text);z-index:25;display:none}
.prop-panel.show{display:block}
.prop-panel h4{margin:0 0 10px;font-size:12px;color:#C6BEFF;letter-spacing:.04em}
.prop-panel .pp-row{margin-bottom:9px}
.prop-panel label{display:block;font-size:10px;color:var(--muted);margin-bottom:4px}
.prop-panel select,.prop-panel input{width:100%;background:rgba(255,255,255,.04);color:var(--text);border:1px solid var(--line);border-radius:10px;padding:6px 8px;font-size:11px}
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:5px}
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:3px}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:rgba(138,124,255,.7);box-shadow:0 0 0 2px rgba(138,124,255,.18)}
.prop-picker{position:fixed;inset:0;background:rgba(3,5,10,.68);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:rgba(10,14,24,.98);border:1px solid var(--line-strong);border-radius:20px;padding:16px;max-width:520px;max-height:80vh;overflow-y:auto;color:var(--text)}
.prop-picker-box h3{margin:0 0 12px;font-size:13px;color:#C6BEFF}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
.prop-pick{background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:14px;padding:7px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:rgba(138,124,255,.6)}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:var(--muted);margin-top:5px;word-break:break-all}
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(138,124,255,.18) 1px,transparent 1px),linear-gradient(90deg,rgba(138,124,255,.18) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(138,124,255,.45)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid rgba(138,124,255,.8);z-index:30}
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.72;outline:2px solid var(--warning)!important;z-index:40}
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.22);z-index:35}
body[data-edit-mode="true"] .char .shadow{display:none}
@media (max-width:1180px){
.workspace{grid-template-columns:220px minmax(560px,1fr)}
.mission-panel{display:none}
}
@media (max-width:900px){
body{overflow:auto}
.office-app{height:auto;min-height:100vh;grid-template-rows:auto auto auto auto;padding:14px}
.topbar{flex-wrap:wrap}
.topbar-center{order:3;width:100%;justify-content:flex-start}
.workspace{grid-template-columns:1fr}
.team-panel{display:none}
.mission-panel{display:block}
.office-shell{min-height:620px}
.mission-title{max-width:calc(100vw - 140px)}
}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}
}
</style></head>
<body>
<div class="office-app">
<header class="topbar">
<div class="brand-block">
<div class="eyebrow">ASTRA OFFICE</div>
<div class="h-title">Operations Floor</div>
</div>
<div class="topbar-center">
<div class="phase-pill" id="phasePill"><span class="phase-dot"></span><span id="status">대기 중</span></div>
<div class="agent-pill"><span class="agent-label">현재 담당</span><strong id="agent">Astra</strong></div>
</div>
<div class="topbar-actions">
<button id="editBtn" class="edit-btn" title="오피스 배치 편집 모드 토글">Customize</button>
</div>
</header>
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ 프랍</button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>삭제</button>
<button id="layerUpBtn" title="레이어 위로 (])">위로</button>
<button id="layerDownBtn" title="레이어 아래로 ([)">아래로</button>
<button id="saveBtn">저장</button>
<button id="resetBtn" title="기본 배치로 복귀">초기화</button>
<button id="cancelBtn" title="저장 안 하고 종료">취소</button>
</div>
<main class="workspace">
<aside class="side-panel team-panel">
<div class="panel-head">
<div>
<div class="panel-kicker">Team</div>
<h2>오늘의 라인업</h2>
</div>
<div class="count-badge" id="rosterCount">0</div>
</div>
<div id="rosterList" class="roster-list"></div>
</aside>
<section class="office-shell">
<div class="mission-strip">
<div>
<div class="panel-kicker">Current Mission</div>
<div class="mission-title" id="task">새 요청을 기다리고 있습니다.</div>
</div>
<div class="mission-step-wrap">
<span>현재 단계</span>
<strong id="step">대기 중</strong>
</div>
</div>
<div id="miniMap" class="mini-map" style="display:none;"></div>
<div class="office-stage-wrap">
<div class="office">
<div class="stage" id="stage">
<div class="wall-window w1"></div>
<div class="wall-window w2"></div>
</div>
<div id="propPanel" class="prop-panel"></div>
</div>
</div>
</section>
<aside class="side-panel mission-panel">
<div class="panel-head compact">
<div>
<div class="panel-kicker">Signal</div>
<h2>운영 브리프</h2>
</div>
</div>
<div class="brief-grid">
<section class="brief-card hero">
<span>현재 흐름</span>
<strong id="phaseLabel">대기 중</strong>
<p id="phaseNote">새로운 작업 요청을 기다리고 있습니다.</p>
</section>
<section class="brief-card">
<span>진행률</span>
<strong id="progressLabel">0%</strong>
<div class="progress"><div class="bar" id="bar"></div></div>
</section>
<section class="brief-card" id="attentionCard">
<span>주의 신호</span>
<strong id="attentionTitle">없음</strong>
<p id="attentionBody">현재 막힘 없이 진행 중입니다.</p>
</section>
</div>
</aside>
</main>
<footer class="activity-dock">
<div class="activity-head">
<div>
<div class="panel-kicker">Activity</div>
<strong>최근 실행</strong>
</div>
<div id="log" class="last-log">아직 기록된 활동이 없습니다.</div>
</div>
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
</footer>
</div>
<script>(function(){
const base='http://127.0.0.1:8765/assets/pixelOffice/derived'; const stage=document.getElementById('stage');
// ── 데이터 모델 ──
// stations: 책상 + 캐릭터 정의 배열 (let — 추가/제거 가능).
// key = 안정적 식별자 (DOM dataset.role 로도 사용). 사용자가 새로 만든 책상은 자동 생성.
// agentKey = 이 책상에 매핑된 에이전트 ID (ceo/planner/researcher/...). 없으면 idle.
// charRow = 사용할 sprite row (0~7). 캐릭터 외모/방향.
// deskSprite= 책상 PNG 이름 (desk-main / desk-boss / desk-dark-mirror 등).
// objs: 프랍 정의 배열 (let). user-add/remove.
const DEFAULT_STATIONS=[
{key:'ceo',agentKey:'ceo',label:'CEO',charRow:0,deskSprite:'desk-boss',deskX:304,deskY:84,deskW:136,seatX:331,seatY:115,face:'R',dock:[362,164],roam:[[320,196],[396,196]],boss:true},
{key:'planner',agentKey:'planner',label:'기획',charRow:1,deskSprite:'desk-main',deskX:60,deskY:228,deskW:112,seatX:64,seatY:264,face:'R',dock:[96,322],roam:[[154,350],[200,330]]},
{key:'researcher',agentKey:'researcher',label:'리서치',charRow:2,deskSprite:'desk-dark-mirror',deskX:236,deskY:214,deskW:112,seatX:284,seatY:248,face:'L',dock:[304,310],roam:[[322,340],[344,322]]},
{key:'designer',agentKey:'designer',label:'디자인',charRow:3,deskSprite:'desk-main',deskX:402,deskY:240,deskW:112,seatX:406,seatY:276,face:'R',dock:[438,334],roam:[[492,350],[520,326]]},
{key:'developer',agentKey:'developer',label:'개발',charRow:4,deskSprite:'desk-dark-mirror',deskX:56,deskY:410,deskW:112,seatX:104,seatY:442,face:'L',dock:[124,500],roam:[[150,534],[188,518]]},
{key:'qa',agentKey:'qa',label:'QA',charRow:5,deskSprite:'desk-main',deskX:232,deskY:394,deskW:112,seatX:236,seatY:430,face:'R',dock:[268,486],roam:[[320,520],[352,500]]},
{key:'inspector',agentKey:'inspector',label:'감리',charRow:6,deskSprite:'desk-dark-mirror',deskX:408,deskY:420,deskW:112,seatX:456,seatY:452,face:'L',dock:[476,506],roam:[[506,532],[540,502]]},
{key:'support',agentKey:'support',label:'지원',charRow:7,deskSprite:'desk-main',deskX:220,deskY:472,deskW:112,seatX:224,seatY:504,face:'R',dock:[256,548],roam:[[360,548],[484,540]]},
];
// 에이전트 ID alias — 같은 페르소나의 다양한 호칭을 같은 book agentKey 로. (writer→planner, editor→designer, secretary→support, business→inspector)
const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',business:'inspector'};
// 매핑 dropdown 에 보여줄 에이전트 후보. agentKey 가 unique 한 base set.
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
// 추가 가능한 책상 sprite 후보 (assets/pixelOffice/derived 에 있는 desk-* PNG).
const DESK_SPRITE_CHOICES=['desk-main','desk-boss','desk-dark-mirror','desk-main-mirror','desk-dark','desk-boss-mirror','desk-main-front','desk-dark-front','desk-boss-front','desk-partition'];
// 추가 가능한 프랍 sprite 후보.
const PROP_SPRITE_CHOICES=['board','plant-tall','bookshelf','plant-bushy','partition','cooler','filing','couch','rug','shelf','printer','monitor-blue','monitor-black','chair-blue','crt'];
const DEFAULT_PROPS=[
{name:'board',x:316,y:12,w:88},{name:'plant-tall',x:44,y:92,w:42},{name:'bookshelf',x:86,y:70,w:54},
{name:'plant-bushy',x:642,y:96,w:42},{name:'partition',x:520,y:208,w:72},{name:'cooler',x:640,y:248,w:38},
{name:'filing',x:620,y:330,w:42},{name:'couch',x:578,y:432,w:96},{name:'rug',x:560,y:510,w:126},
{name:'shelf',x:40,y:504,w:118},{name:'printer',x:520,y:520,w:58},{name:'monitor-blue',x:356,y:56,w:44},
];
let stations=[]; // mutable, 시작 시 default 또는 saved layout 로 채움.
let __nextDeskN=100; // user-add 책상 id 카운터 (default 와 충돌 회피 위해 큰 수에서 시작).
let __nextObjN=0;
const stationByKey={}; // 빠른 lookup. stations 변경 시 rebuild.
const __deskWrap={}; // role → desk DOM wrap.
const chars={}; // role → char DOM.
const anim={}; // role → animation state.
function png(name){return base+'/'+name+'.png'}
function _rebuildStationIndex(){
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
stations.forEach(st=>{ stationByKey[st.key]=st; });
}
// agentKey → station.key 로 라우팅. roleMap 의 동적 버전.
function findStationByAgent(agentId){
if(!agentId) return null;
const a = AGENT_ALIASES[agentId] || agentId;
for(const st of stations){ if(st.agentKey === a) return st; }
return null;
}
// 옛 코드 호환 — roleMap[x] 호출 패턴을 함수로.
const roleMap=new Proxy({},{get:(_,k)=>{ const s=findStationByAgent(k); return s?s.key:null; }});
function addImg(name,x,y,w){
const i=document.createElement('img');
i.src=png(name); i.className='obj';
i.dataset.objId='obj_'+(__nextObjN++); i.dataset.objName=name;
if(w!=null) i.dataset.objW=w;
i.style.left=x+'px'; i.style.top=y+'px';
if(w!=null) i.style.width=w+'px';
stage.appendChild(i); return i;
}
function addDesk(st){
const wrap=document.createElement('div');
wrap.className='desk '+(st.boss?'boss':'');
wrap.dataset.role=st.key;
if(st.agentKey) wrap.dataset.agent=st.agentKey;
wrap.style.left=st.deskX+'px'; wrap.style.top=st.deskY+'px'; wrap.style.width=st.deskW+'px';
const img=document.createElement('img'); img.src=png(st.deskSprite); img.style.width='100%';
wrap.appendChild(img);
const l=document.createElement('div'); l.className='label'; l.textContent=st.label;
wrap.appendChild(l);
stage.appendChild(wrap);
__deskWrap[st.key]=wrap;
return wrap;
}
function addChar(st){
const ch=document.createElement('div');
ch.className='char'; ch.dataset.role=st.key;
if(st.agentKey) ch.dataset.agent=st.agentKey;
ch.style.left=st.seatX+'px'; ch.style.top=st.seatY+'px';
ch.dataset.homeX=st.seatX; ch.dataset.homeY=st.seatY; ch.dataset.row=st.charRow;
ch.innerHTML='<img><div class="shadow"></div>';
stage.appendChild(ch);
chars[st.key]=ch;
anim[st.key]={row:st.charRow,frame:0,dir:0,mode:'sit',face:st.face,route:0};
const img=ch.querySelector('img');
if(st.face === 'U' || st.face === 'D'){
img.src=png('walk-r'+st.charRow+'-d'+_faceToWalkDir(st.face)+'-f0');
img.style.transform='none';
} else {
img.src=png('idle-r'+st.charRow+'-f0');
img.style.transform=st.face==='R'?'scaleX(-1)':'none';
}
return ch;
}
function buildStation(st){ addDesk(st); if(!st.noChar) addChar(st); }
function _renderDefaultStations(){
// default 8 stations + default props.
stations = DEFAULT_STATIONS.map(s=>Object.assign({},s));
_rebuildStationIndex();
stations.forEach(buildStation);
DEFAULT_PROPS.forEach(p=>addImg(p.name,p.x,p.y,p.w));
}
_renderDefaultStations();
// ── 4방향 dir 매핑 ──
// PNG 파일명 규약: walk-r<row>-d<DIR>-f<frame>.png
// 사용자의 sprite 컨벤션이 다르면 이 4 상수만 수정하면 됨.
// 기본값은 일반적인 RPG 4방향 시트 순서 (caliverse · RPGMaker 등).
const DIR_DOWN = 0; // 정면 (카메라/사용자 쪽)
const DIR_LEFT = 1;
const DIR_RIGHT = 2;
const DIR_UP = 3; // 뒤
// idle/work 의 정면(D) / 후면(U) sprite 는 별도로 없으므로 walk 의 같은 방향
// frame 0 (정지 포즈) 를 그대로 빌려쓴다.
function _faceToWalkDir(face){
if(face === 'D') return DIR_DOWN;
if(face === 'U') return DIR_UP;
if(face === 'L') return DIR_LEFT;
return DIR_RIGHT;
}
function setSprite(role,mode,frame=0,dir=0){
const ch=chars[role]; if(!ch) return;
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 반전은 *안* 한다.
img.src=png('walk-r'+a.row+'-d'+dir+'-f'+frame);
img.style.transform='none';
} else if(a.face === 'U' || a.face === 'D'){
// 위/아래 face 는 idle/work sprite 가 없으므로 walk 의 같은 방향 정지 포즈로.
img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
img.style.transform='none';
} else if(mode==='work'){
// 기존 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);
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
}
}
function move(role,x,y){
const ch=chars[role]; if(!ch) return; // 캐릭터 삭제된 자리면 무시.
const a=anim[role]; if(!a) return;
const cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
dx=x-cx,dy=y-cy;
// 주축 결정: 큰 쪽을 우선해 4방향 중 하나로 매핑. 동률이면 가로 우선.
let dir;
if(Math.abs(dx) >= Math.abs(dy)){
dir = dx >= 0 ? DIR_RIGHT : DIR_LEFT;
} else {
dir = dy >= 0 ? DIR_DOWN : DIR_UP;
}
// 도착 후 idle/sit 시 좌우 face도 마지막 가로 이동에 맞춰 갱신. UP/DOWN은
// 기존 face 유지 — 4방향 idle sprite가 없을 때 좌우만이라도 자연스럽게.
if(dir === DIR_LEFT) a.face = 'L';
if(dir === DIR_RIGHT) a.face = 'R';
setSprite(role,'walk',0,dir);
ch.style.left=x+'px';
ch.style.top=y+'px';
}
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},286)
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
// ── 책상 회피 path planner ──
// walkPath의 각 leg를 직선이 아닌 *책상을 우회하는* L자 또는 corridor 경로로
// 펴서 캐릭터가 책상을 가로지르지 않게. 책상이 회전됐을 때를 대비해 padding
// 충분히. 사용자 layout이 너무 빡빡해 모든 시도가 fail이면 직선 fallback.
function _deskRects(){
return stations.map(st=>{
const w=__deskWrap[st.key]; if(!w) return null;
const x=parseFloat(w.style.left), y=parseFloat(w.style.top);
const ww=parseFloat(w.style.width)||100;
const hh=w.offsetHeight||40;
const pad=20;
return {x:x-pad, y:y-pad, w:ww+pad*2, h:hh+pad*2};
}).filter(Boolean);
}
function _segIntersect(x1,y1,x2,y2,x3,y3,x4,y4){
const denom=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);
if(Math.abs(denom)<0.0001) return false;
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/denom;
const u=-((x1-x2)*(y1-y3)-(y1-y2)*(x1-x3))/denom;
return t>=0&&t<=1&&u>=0&&u<=1;
}
function _segHitsRect(x1,y1,x2,y2,r){
// 양 끝점이 rect 안이면 즉시 충돌
const inR=(x,y)=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h;
if(inR(x1,y1)||inR(x2,y2)) return true;
// 4변과 교차 검사
return _segIntersect(x1,y1,x2,y2,r.x,r.y,r.x+r.w,r.y)
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y,r.x+r.w,r.y+r.h)
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y+r.h,r.x,r.y+r.h)
|| _segIntersect(x1,y1,x2,y2,r.x,r.y+r.h,r.x,r.y);
}
function _pathClear(pts,rects){
for(let i=0;i<pts.length-1;i++){
const [a,b]=[pts[i],pts[i+1]];
for(const r of rects){ if(_segHitsRect(a[0],a[1],b[0],b[1],r)) return false; }
}
return true;
}
function _planPath(fromX,fromY,toX,toY){
const rects=_deskRects();
if(rects.length===0) return [[toX,toY]];
// 1) 직선
if(_pathClear([[fromX,fromY],[toX,toY]],rects)) return [[toX,toY]];
// 2) 가로 먼저 L
if(_pathClear([[fromX,fromY],[toX,fromY],[toX,toY]],rects)) return [[toX,fromY],[toX,toY]];
// 3) 세로 먼저 L
if(_pathClear([[fromX,fromY],[fromX,toY],[toX,toY]],rects)) return [[fromX,toY],[toX,toY]];
// 4) 사무실 corridor를 통과 — y 통로 후보 (윗·중간·아랫줄 책상 사이)
// stage 높이에 따라 자동 후보 생성: 0, h/4, h/2, 3h/4, h
const sh=stage.offsetHeight||600;
const ycands=[20,Math.round(sh*0.32),Math.round(sh*0.6),Math.round(sh*0.82),sh-20];
for(const cy of ycands){
const trial=[[fromX,fromY],[fromX,cy],[toX,cy],[toX,toY]];
if(_pathClear(trial,rects)) return [[fromX,cy],[toX,cy],[toX,toY]];
}
// 5) x 통로 후보
const sw=stage.offsetWidth||700;
const xcands=[20,Math.round(sw*0.32),Math.round(sw*0.6),Math.round(sw*0.82),sw-20];
for(const cx of xcands){
const trial=[[fromX,fromY],[cx,fromY],[cx,toY],[toX,toY]];
if(_pathClear(trial,rects)) return [[cx,fromY],[cx,toY],[toX,toY]];
}
// 최후의 fallback — 직선 (책상을 가로지를 수 있지만 적어도 멈추진 않음)
return [[toX,toY]];
}
function walkPath(role,points,done,route){
const a=anim[role],token=route??++a.route;
if(token!==a.route) return;
if(!points.length){ if(done) done(); return; }
const ch=chars[role];
const cx=parseFloat(ch.style.left), cy=parseFloat(ch.style.top);
const [pt,...rest]=points;
// 현재 위치 → 다음 waypoint를 책상 회피 경로로 펴기.
const planned=_planPath(cx,cy,pt[0],pt[1]);
// 첫 step 이동하고, 나머지 planned 점들 + 원본 rest를 큐로.
const next=planned[0];
move(role,next[0],next[1]);
const tail=planned.slice(1).concat(rest);
setTimeout(()=>walkPath(role,tail,done,token),1235);
}
function sendHome(role,mode='sit'){
const st=stationByKey[role],ch=chars[role];
if(!st || !ch) return; // 책상/캐릭터 부재 시 no-op.
const hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);
anim[role].route++;
if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}
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'));
},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)}
// ── 로그 → 말풍선 요약기 ──
// 감리(inspector) 는 검토 결과를 키워드만 보여주고, 일반 에이전트는 짧게 잘라서 표시.
// 같은 에이전트에서 너무 자주 뜨면 산만하니 throttle.
const _lastBubbleAt = {};
const BUBBLE_MIN_GAP_MS = 2200;
const _KW_STOP = new Set([
'은','는','이','가','을','를','의','에','도','과','와','으로','에서','부터','까지','마다','보다','처럼','같이','한테','한테서','한테',
'입니다','합니다','했습니다','됩니다','있습니다','없습니다','하였습니다','하다','하는','한','했','됨','됨.',
'그리고','하지만','또는','관련','관련된','대한','대해','그래서','이번','이걸','저걸','이거','저거',
'확인','진행','수행','완료','시작','종료','발생','처리','요청','응답',
'그리고','또한','그러나','즉','또는','및',
'입력','출력','메시지','파일','코드','라인','함수','클래스','변수','값','상태',
]);
function _extractKeywords(text, count){
const tokens = String(text).split(/[\s,\.\?!,。、:;\(\)\[\]"'·…\-]+/).filter(Boolean);
const seen = new Set(); const out = [];
// 길이 내림차순 — 의미있는 명사가 짧은 조사/어미보다 먼저 뽑히도록.
const ranked = tokens
.filter(t => t.length >= 2 && !_KW_STOP.has(t) && !/^[0-9]+$/.test(t))
.sort((a,b)=> b.length - a.length);
for(const t of ranked){
if(seen.has(t)) continue;
seen.add(t); out.push(t);
if(out.length >= count) break;
}
return out;
}
function _bubbleSummary(role, raw){
const s = String(raw||'').trim().replace(/[*\[\]`]/g,'');
if(!s) return '';
const st = stationByKey[role];
const isInspector = st && st.agentKey === 'inspector';
if(isInspector){
const kw = _extractKeywords(s, 3);
return kw.length ? kw.join(' · ') : (s.length > 12 ? s.slice(0,11)+'…' : s);
}
// 일반: 한 줄 + 16자 잘라.
const oneLine = s.split(/\r?\n/)[0];
return oneLine.length > 18 ? oneLine.slice(0,17)+'…' : oneLine;
}
function _bubbleFromLog(role, raw){
if(!role || !chars[role]) return;
const now = Date.now();
if((now - (_lastBubbleAt[role]||0)) < BUBBLE_MIN_GAP_MS) return;
const txt = _bubbleSummary(role, raw);
if(!txt) return;
_lastBubbleAt[role] = now;
bubble(role, txt);
}
// ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
// critical pivot(error/need_clarification/review_failed/approval/done 등)에서만
// 캐릭터 시뮬레이션 + 말풍선 강조. 사용자에게 정말 *알릴 만한 변화*에 시각
// 집중력 몰아주기.
const CRITICAL_STATUSES = new Set(['need_clarification','waiting_approval','error','done']);
const CRITICAL_BUBBLE_TYPES = new Set(['warning','error','success']);
const CRITICAL_TEXT_RE = /버그|오류|실패|승인|중단|완료|❌|🛑|✅|⚠️|보완|재작업|결재/;
function _isCriticalBubble(b){
if(!b) return false;
if(b.type && CRITICAL_BUBBLE_TYPES.has(b.type)) return true;
if(b.text && CRITICAL_TEXT_RE.test(b.text)) return true;
return false;
}
function routeBubble(b){
// 일반 status bubble은 무시 — 큐 적체 방지. 사용자에게 의미 있는 신호만 통과.
if(!_isCriticalBubble(b)) return;
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);
// 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
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 || 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 || '아직 기록된 활동이 없습니다.';
// 작업 중 (executing/reviewing) 상태에 활성 에이전트 머리 위 말풍선 — 로그 변할 때마다.
if(lastLog && lastLog !== _lastRenderedLog){
_lastRenderedLog = lastLog;
if(['executing','reviewing','planning','analyzing'].includes(s?.status||'')){
// 활성 role 추정: message prefix 또는 status default. 책상이 없으면 무시.
let r = null;
const mm2 = (s?.message||'').match(/^([a-z0-9_-]+)/i);
if(mm2) r = roleMap[mm2[1]];
if(!r && s?.status === 'reviewing') r = roleMap['inspector'];
if(!r) r = roleMap['ceo'];
_bubbleFromLog(r, lastLog);
}
}
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;
if(Array.isArray(stages) && stages.length > 0){
mm.style.display = 'flex';
mm.innerHTML = '';
const doneN = stages.filter(x=>x.status==='done').length;
stages.forEach((stg, i)=>{
const dot = document.createElement('div');
dot.className = 'mm-dot';
dot.dataset.status = stg.status || 'pending';
const label = document.createElement('span');
label.className = 'mm-label';
label.textContent = (i+1) + '. ' + (stg.label || '단계') + (stg.agent ? ' · ' + stg.agent : '');
dot.appendChild(label);
mm.appendChild(dot);
if(i < stages.length - 1){
const bar = document.createElement('div');
bar.className = 'mm-bar';
mm.appendChild(bar);
}
});
const counter = document.createElement('div');
counter.className = 'mm-counter';
counter.textContent = doneN + '/' + stages.length;
mm.appendChild(counter);
} else {
mm.style.display = 'none';
}
// 활성 캐릭터 결정. roleMap 은 agentKey → 실제 존재하는 station.key 로 lookup
// 하므로, 매핑된 책상이 없으면 null. 사용자가 default ceo 책상을 지워도 안전.
let role = null;
const m = (s?.message || '').match(/^([a-z0-9_-]+)/i);
if(m) role = roleMap[m[1]] || null;
if(['planning','analyzing','waiting_approval'].includes(st)) role = roleMap['ceo'] || role;
if(st === 'reviewing') role = roleMap['inspector'] || role;
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
activate(role);
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
const isPivot = CRITICAL_STATUSES.has(st);
const isTransition = st !== _prevStatus;
if(isTransition){
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
Object.keys(chars).forEach(k => {
if(k !== role && anim[k].mode === 'work') sendHome(k, 'sit');
});
// pivot 상태 진입은 walk + 말풍선 시뮬레이션 트리거.
if(isPivot && role){
sendHome(role, st === 'executing' || st === 'reviewing' ? 'work' : 'sit');
} else if(role){
// 일반 상태는 *정적 모드만* — 캐릭터를 그 자리에서 mode만 바꿈, 걷지 않음.
const ch = chars[role];
if(ch){
const cx = parseFloat(ch.style.left), cy = parseFloat(ch.style.top);
const st_ = stationByKey[role];
const home = st_ ? Math.abs(cx - st_.seatX) < 1 && Math.abs(cy - st_.seatY) < 1 : false;
if(home){
// 이미 자리에 있으면 mode만 work/sit으로 토글 (걷기 없음).
setSprite(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
} else {
// 자리를 벗어나 있으면 한 번만 복귀 — 그 이후 같은 status가 연속 와도 다시 보내지 않음.
sendHome(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
}
}
}
if(st === 'done'){
// 완료 — 모두 자리 복귀 후 단체 sit.
Object.keys(chars).forEach(k => sendHome(k, 'work'));
const ceoRole = roleMap['ceo']; if(ceoRole) bubble(ceoRole, '완료!');
setTimeout(() => Object.keys(chars).forEach(k => sendHome(k, 'sit')), 1800);
}
}
_prevStatus = st;
}
// D. 캐릭터 컨텍스트 메뉴 — 편집 모드 X일 때만. abort/세부보기/말풍선 정리.
let _lastState = null;
function _closeCtxMenu(){
document.querySelectorAll('.ctx-menu, .ctx-detail').forEach(el => el.remove());
}
document.addEventListener('click', e=>{
// 메뉴 외부 클릭 → 닫기
if(!e.target.closest('.ctx-menu') && !e.target.closest('.ctx-detail') && !e.target.closest('.char')){
_closeCtxMenu();
}
});
stage.addEventListener('click', e=>{
if(_editMode) return; // 편집 모드에선 드래그용 mousedown이 우선
const ch = e.target.closest('.char');
if(!ch) return;
const role = ch.dataset.role;
if(!role) return;
e.stopPropagation();
_closeCtxMenu();
const menu = document.createElement('div');
menu.className = 'ctx-menu';
menu.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
const head = document.createElement('div');
head.className = 'ctx-menu-head';
const st = stationByKey[role] || {label:role};
head.innerHTML = '<span class="cmh-role">'+ (st.label||role) +'</span><br><span style="color:#D7DBEA;font-size:10px;">' + role + '</span>';
menu.appendChild(head);
// 옵션
const addItem = (label, danger, onClick)=>{
const it = document.createElement('div');
it.className = 'ctx-menu-item' + (danger?' danger':'');
it.textContent = label;
it.onclick = (ev)=>{ ev.stopPropagation(); onClick(); _closeCtxMenu(); };
menu.appendChild(it);
};
addItem('📋 현재 작업 보기', false, ()=>{
_showDetail(role, _lastState);
});
addItem('💬 말풍선 보내기', false, ()=>{
const msg = window.prompt(role + '에게 보낼 메시지', '');
if(msg) bubble(role, msg);
});
const sep = document.createElement('div'); sep.className='ctx-menu-divider'; menu.appendChild(sep);
addItem('🛑 현재 작업 중단', true, ()=>{
try{ vscode.postMessage({type: 'pixelOfficeCommand', cmd: 'abort', role}); }catch{}
});
// 위치 — 클릭한 캐릭터 위
document.body.appendChild(menu);
const r = ch.getBoundingClientRect();
const mw = menu.offsetWidth, mh = menu.offsetHeight;
let mx = r.left + r.width/2 - mw/2, my = r.top - mh - 6;
if(my < 8) my = r.bottom + 6;
mx = Math.max(8, Math.min(window.innerWidth - mw - 8, mx));
menu.style.left = mx + 'px';
menu.style.top = my + 'px';
});
function _showDetail(role, state){
_closeCtxMenu();
const overlay = document.createElement('div');
overlay.className = 'ctx-detail';
const ch = chars[role];
if(ch) overlay.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
const st = stationByKey[role] || {label:role};
const close = document.createElement('button'); close.className='cd-close'; close.textContent='✕'; close.onclick=_closeCtxMenu;
overlay.appendChild(close);
const h = document.createElement('h3'); h.textContent = (st.label||role) + ' · ' + role;
overlay.appendChild(h);
const dl = document.createElement('dl');
const add = (k,v)=>{ if(v==null||v==='') return; const dt=document.createElement('dt');dt.textContent=k;const dd=document.createElement('dd');dd.textContent=v;dl.appendChild(dt);dl.appendChild(dd); };
add('상태', state?.status);
add('단계', state?.currentStep);
add('다음', state?.nextStep);
add('메모', state?.message);
if(state?.requirementContract){
const c = state.requirementContract;
add('목표', c.goal);
add('맥락', c.context);
add('형식', c.format);
if(Array.isArray(c.criteria) && c.criteria.length) add('기준', c.criteria.join(' · '));
if(Array.isArray(c.openQuestions) && c.openQuestions.length) add('미해결 질문', c.openQuestions.join(' / '));
}
overlay.appendChild(dl);
if(Array.isArray(state?.recentLogs) && state.recentLogs.length){
const logs = document.createElement('div'); logs.className='cd-logs';
logs.innerHTML = state.recentLogs.map(l=>'<div>'+ (l||'').replace(/[<>]/g,'') +'</div>').join('');
overlay.appendChild(logs);
}
document.body.appendChild(overlay);
const ow=overlay.offsetWidth, oh=overlay.offsetHeight;
overlay.style.left = Math.max(20, (window.innerWidth-ow)/2) + 'px';
overlay.style.top = Math.max(40, (window.innerHeight-oh)/2) + 'px';
}
// E. Activity ticker state — 최근 N개 행동만 ring buffer로. 너무 길어지면
// 자동으로 가장 오래된 것 drop.
const _tickerItems = [];
const TICKER_MAX = 12;
function _classifyTickerItem(text){
if(/❌|fail|error|⚠️/i.test(text)) return 'tk-err';
if(/✅|ok|created|edited|completed/i.test(text)) return 'tk-ok';
if(/⚠|warn|hollow/i.test(text)) return 'tk-warn';
return '';
}
function _renderTicker(){
const wrap = document.getElementById('ticker');
const track = document.getElementById('tickerTrack');
if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
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;
}
// ─────────────── Dual-mode message handler (refactor #D) ───────────────
// 옛 pixelOfficeUpdate/Activity 와 새 officeSnapshot 둘 다 listen. 첫 officeSnapshot
// 이 도착하면 _seenOfficeSnapshot = true 가 되고 옛 message 는 무시됨 → 안전한 점진 전환.
let _seenOfficeSnapshot = false;
// OfficeSnapshot → 옛 AgentWorkState 형태로 변환 후 기존 apply() 재사용.
// 정보 손실 최소화: roster[activeId] 에서 status/step/log 가져옴.
function _phaseToStatus(phase){
if(phase === 'awaiting-approval') return 'waiting_approval';
if(phase === 'reporting') return 'done';
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();
function _roleCategoryToCharRow(role){
switch(role){
case 'ceo': return 0;
case 'planner': return 1;
case 'researcher': return 2;
case 'designer': return 3;
case 'developer': return 4;
case 'qa': return 5;
case 'inspector': return 6;
case 'support': return 7;
case 'writer': return 1;
default: return 0;
}
}
function _autoCreateDeskForAgent(r){
// 기존 _addNewDesk 와 같은 메커니즘이지만 agentKey/label/charRow/sprite 를 roster 정보에 맞춰 채움.
const id = 'desk_'+(__nextDeskN++);
// 위치: 현재 station 수에 따라 격자 배치. 너무 빽빽하면 겹치지만 사용자가 드래그로 정리 가능.
const slotN = stations.length;
const cols = 6;
const baseX = 36 + (slotN % cols) * 112;
const baseY = 28 + Math.floor(slotN / cols) * 95;
const isBoss = r.roleCategory === 'ceo';
const st = {
key: id,
agentKey: r.agentId,
label: r.agentName || r.agentId,
charRow: _roleCategoryToCharRow(r.roleCategory),
deskSprite: isBoss ? 'desk-boss' : 'desk-main',
face: 'R',
boss: isBoss,
deskX: baseX, deskY: baseY, deskW: isBoss ? 136 : 112,
seatX: baseX + 4, seatY: baseY + 36,
dock: [baseX + 32, baseY + 80],
roam: [[baseX - 20, baseY + 120],[baseX + 60, baseY + 100]],
};
stations.push(st);
_rebuildStationIndex();
buildStation(st);
}
function _ensureRosterDesks(roster){
if(!Array.isArray(roster)) return;
for(const r of roster){
if(!r || !r.agentId) continue;
if(_autoDeskedFor.has(r.agentId)) continue; // 이미 한번 처리됨 (생성했든 매핑됐든)
// 직접 매핑된 station 있는가?
const direct = stations.find(s => s.agentKey === r.agentId);
if(direct){ _autoDeskedFor.add(r.agentId); continue; }
// alias 로 잡히는가? (writer→planner 등 옛 이름)
const aliased = AGENT_ALIASES[r.agentId];
if(aliased && stations.find(s => s.agentKey === aliased)){
_autoDeskedFor.add(r.agentId);
continue;
}
// 아무 데도 없음 — 자동 생성.
_autoCreateDeskForAgent(r);
_autoDeskedFor.add(r.agentId);
}
}
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 = {
agentId: snap.activeAgentId || (active && active.agentId) || 'main',
agentName: (active && active.agentName) || 'Agent',
status: (active && active.status) || _phaseToStatus(snap.phase),
currentTask: snap.task && snap.task.goal,
currentStep: active && active.currentStep,
// apply() 의 message regex 가 첫 토큰을 agentId 로 추출 — activeAgentId 그대로 넘김.
message: snap.activeAgentId || '',
recentLogs: (active && active.lastLog) ? [active.lastLog] : [],
progress: snap.pipeline ? (snap.pipeline.index / Math.max(1, snap.pipeline.stages.length)) : 0,
pipelineStages: snap.pipeline && snap.pipeline.stages,
needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined,
awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined,
requirementContract: snap.task,
updatedAt: snap.updatedAt,
};
apply(synthetic);
// 풍선 시드 — routeBubble 의 critical filter 그대로 통과.
if(Array.isArray(snap.newBubbles)){
snap.newBubbles.forEach(b => routeBubble({ agentId: b.agentId, text: b.text, type: b.type }));
}
// Activity — snapshot 이 full ring buffer 를 통째로 보내므로 ticker 를 매번 재구성.
if(Array.isArray(snap.activity)){
_tickerItems.length = 0;
const tail = snap.activity.slice(-TICKER_MAX);
for(const it of tail){
_tickerItems.push({ agentId: it.agentId, text: it.text });
}
_renderTicker();
// 가장 최근 activity 항목 한 개에 대해 머리 위 말풍선 (옛 동작 흉내).
const last = tail[tail.length - 1];
if(last){
const r = roleMap[last.agentId];
if(r) _bubbleFromLog(r, last.text);
}
}
}
window.addEventListener('message',e=>{
const d = e.data;
if(!d) return;
if(d.type === 'officeSnapshot'){
_seenOfficeSnapshot = true;
applyFromSnapshot(d.value);
return;
}
// 옛 path — 새 snapshot 을 받기 시작했다면 무시 (백엔드가 양쪽 다 emit 중인 마이그레이션 기간).
if(_seenOfficeSnapshot) return;
if(d.type === 'pixelOfficeUpdate'){
const v = d.value;
if(!v) return;
if(v.state) apply(v.state);
if(Array.isArray(v.bubbles)) v.bubbles.forEach(routeBubble);
} else if(d.type === 'pixelOfficeActivity'){
const v = d.value;
if(!v || !Array.isArray(v.items)) return;
for(const it of v.items){
_tickerItems.push(it);
if(_tickerItems.length > TICKER_MAX) _tickerItems.shift();
// 작업중 캐릭터 머리 위 말풍선 — 감리는 키워드, 그 외는 짧은 한 줄.
const r = roleMap[it.agentId];
if(r) _bubbleFromLog(r, it.text);
}
_renderTicker();
}
});
try{vscode.postMessage({type:'getPixelOfficeState'})}catch{}
// ─────────────── Layout Editor ───────────────
// 사용자가 ✏️ 편집 버튼을 누르면 편집 모드 진입. 책상/캐릭터/소품을 드래그로
// 위치 조정 가능. 4px snap. 저장 시 백엔드 workspace state에 영구 저장. 다음
// 패널 오픈 때 자동 복원. 디폴트로 복귀 버튼도 제공.
let _editMode=false, _drag=null, _dragDX=0, _dragDY=0, _snapshotBeforeEdit=null, _selected=null;
function _snapshotLayout(){
// 새 schema v2: cells 가 단순 좌표 패치가 아니라 stations 전체 정의를 담는다.
// (책상 추가/제거가 가능하니 default 와의 delta 로는 표현 불가.) v1 호환을 위해
// _restoreLayout 이 양쪽 모두 처리.
return {
schema: 2,
cells: stations.map(st=>{
const ch = chars[st.key];
return {
roleKey: st.key,
agentKey: st.agentKey || '',
label: st.label || '',
charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main',
face: st.face || 'R',
boss: !!st.boss,
noChar: !!st.noChar,
dock: st.dock,
roam: st.roam,
deskX: parseFloat(__deskWrap[st.key].style.left),
deskY: parseFloat(__deskWrap[st.key].style.top),
deskW: parseFloat(__deskWrap[st.key].style.width),
deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
seatX: ch ? parseFloat(ch.style.left) : (st.seatX ?? 0),
seatY: ch ? parseFloat(ch.style.top) : (st.seatY ?? 0),
charRot: ch ? parseFloat(ch.dataset.rot || '0') : 0,
charZ: ch ? parseFloat(ch.dataset.z || '0') : 0,
};
}),
objs: Array.from(stage.querySelectorAll('img.obj')).map(el=>({
id: el.dataset.objId,
name: el.dataset.objName,
x: parseFloat(el.style.left),
y: parseFloat(el.style.top),
w: el.dataset.objW ? parseFloat(el.dataset.objW) : undefined,
rot: parseFloat(el.dataset.rot || '0'),
z: parseFloat(el.dataset.z || '0'),
})),
};
}
// stage 에 그려진 모든 desk/char/obj DOM 제거 (벽 창문 제외). 레이아웃 리빌드 직전에 호출.
function _clearStage(){
Array.from(stage.querySelectorAll('.desk,.char,img.obj')).forEach(el=>el.remove());
Object.keys(__deskWrap).forEach(k=>delete __deskWrap[k]);
Object.keys(chars).forEach(k=>delete chars[k]);
Object.keys(anim).forEach(k=>delete anim[k]);
stations = [];
// refactor #G-full: layout reset 시 auto-desk 결정 기록도 초기화 →
// 다음 snapshot 도착 시 roster 기반으로 다시 평가.
_autoDeskedFor.clear();
}
function _applyRot(el, rot){
if(!el) return;
const r = typeof rot==='number' ? rot : 0;
el.dataset.rot = String(r);
el.style.transform = r === 0 ? '' : ('rotate('+r+'deg)');
}
function _applyZ(el, z){
if(!el) return;
if(typeof z !== 'number') return;
el.dataset.z = String(z);
el.style.zIndex = z === 0 ? '' : String(z);
}
// v2 schema 또는 cell 에 desk 정의 필드가 있으면 stage 를 통째로 재구축.
// v1 (옛 포맷) 이면 좌표만 패치하는 in-place 갱신.
function _isV2Snap(snap){
if(!snap || !Array.isArray(snap.cells)) return false;
if(snap.schema === 2) return true;
return snap.cells.some(c => c && (typeof c.deskSprite === 'string' || typeof c.agentKey === 'string' || typeof c.charRow === 'number'));
}
function _restoreLayout(snap){
if(!snap) return;
if(_isV2Snap(snap)){
_clearStage();
(snap.cells||[]).forEach(c=>{
const st = {
key: c.roleKey,
agentKey: c.agentKey || '',
label: c.label || c.roleKey,
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
deskSprite: c.deskSprite || 'desk-main',
face: c.face || 'R',
boss: !!c.boss,
noChar: !!c.noChar,
dock: Array.isArray(c.dock) ? c.dock : [c.deskX+32, c.deskY+80],
roam: Array.isArray(c.roam) ? c.roam : [[c.deskX, c.deskY+120],[c.deskX+60, c.deskY+100]],
deskX: c.deskX, deskY: c.deskY, deskW: c.deskW || 112,
seatX: c.seatX, seatY: c.seatY,
};
stations.push(st);
const m = String(st.key).match(/^desk_(\d+)$/);
if(m){ const n = parseInt(m[1],10); if(n >= __nextDeskN) __nextDeskN = n + 1; }
});
_rebuildStationIndex();
stations.forEach(buildStation);
(snap.cells||[]).forEach(c=>{
const wrap=__deskWrap[c.roleKey], ch=chars[c.roleKey];
if(wrap){ _applyRot(wrap, c.deskRot); _applyZ(wrap, c.deskZ); }
if(ch){ _applyRot(ch, c.charRot); _applyZ(ch, c.charZ); }
});
(snap.objs||[]).forEach(o=>{
const el = addImg(o.name, o.x, o.y, o.w);
if(o.id){ el.dataset.objId = o.id; }
_applyRot(el, o.rot); _applyZ(el, o.z);
});
return;
}
// v1 — 옛 포맷, in-place patch.
(snap.cells||[]).forEach(c=>{
const wrap=__deskWrap[c.roleKey];
if(wrap){
if(typeof c.deskX==='number') wrap.style.left=c.deskX+'px';
if(typeof c.deskY==='number') wrap.style.top=c.deskY+'px';
if(typeof c.deskW==='number') wrap.style.width=c.deskW+'px';
_applyRot(wrap, c.deskRot);
_applyZ(wrap, c.deskZ);
}
const ch=chars[c.roleKey];
if(ch){
if(typeof c.seatX==='number'){ch.style.left=c.seatX+'px';ch.dataset.homeX=c.seatX;}
if(typeof c.seatY==='number'){ch.style.top=c.seatY+'px';ch.dataset.homeY=c.seatY;}
_applyRot(ch, c.charRot);
_applyZ(ch, c.charZ);
}
const st=stationByKey[c.roleKey];
if(st){
if(typeof c.deskX==='number') st.deskX=c.deskX;
if(typeof c.deskY==='number') st.deskY=c.deskY;
if(typeof c.seatX==='number') st.seatX=c.seatX;
if(typeof c.seatY==='number') st.seatY=c.seatY;
}
});
(snap.objs||[]).forEach(o=>{
let el=null;
if(o.id) el=stage.querySelector('img.obj[data-obj-id="'+o.id+'"]');
if(!el && o.name){
// id 없으면 name으로 첫번째 매칭 — legacy 데이터 호환.
el=stage.querySelector('img.obj[data-obj-name="'+o.name+'"]');
}
if(!el) return;
if(typeof o.x==='number') el.style.left=o.x+'px';
if(typeof o.y==='number') el.style.top=o.y+'px';
if(typeof o.w==='number'){ el.style.width=o.w+'px'; el.dataset.objW=String(o.w); }
_applyRot(el, o.rot);
_applyZ(el, o.z);
});
}
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?'편집 종료':'Customize';
if(_editMode){
_snapshotBeforeEdit = _snapshotLayout();
} else {
_snapshotBeforeEdit = null;
// 편집 종료 시 selected 강조 해제.
if(_selected){ _selected.classList.remove('selected'); _selected = null; }
}
_onSelectionChanged();
}
// ── 선택 변화 시 호출 — 속성 패널 / 삭제 버튼 상태 sync ──
function _onSelectionChanged(){
const panel = document.getElementById('propPanel');
const delBtn = document.getElementById('deleteSelBtn');
if(!_editMode || !_selected){
panel.classList.remove('show'); panel.innerHTML='';
if(delBtn) delBtn.disabled = true;
return;
}
// 캐릭터를 선택해도 그 책상의 속성을 편집한다 (캐릭터와 책상은 한 쌍).
let targetForProps = _selected;
if(targetForProps.classList.contains('char')){
const role = Object.keys(chars).find(k=>chars[k]===targetForProps);
if(role && __deskWrap[role]) targetForProps = __deskWrap[role];
}
if(targetForProps.classList.contains('desk')){
if(delBtn) delBtn.disabled = false;
_renderDeskProps(targetForProps);
} else if(_selected.classList.contains('obj')){
if(delBtn) delBtn.disabled = false;
_renderObjProps(_selected);
} else {
panel.classList.remove('show'); panel.innerHTML='';
if(delBtn) delBtn.disabled = true;
}
}
function _renderDeskProps(deskEl){
const role = deskEl.dataset.role;
const st = stationByKey[role]; if(!st) return;
const panel = document.getElementById('propPanel');
panel.classList.add('show');
// 에이전트 매핑 dropdown.
const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
// 책상 sprite picker.
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+s+'</option>').join('');
// charRow 썸네일 picker (idle-r<n>-f0.png).
let thumbs='';
for(let r=0;r<8;r++){
thumbs += '<div class="pp-thumb'+(r===st.charRow?' active':'')+'" data-charrow="'+r+'" title="row '+r+'"><img src="'+png('idle-r'+r+'-f0')+'"></div>';
}
const hasChar = !!chars[role];
panel.innerHTML =
'<h4>책상 속성</h4>'+
'<div class="pp-row"><label>라벨</label><input id="ppLabel" value="'+(st.label||'').replace(/"/g,'&quot;')+'"></div>'+
'<div class="pp-row"><label>에이전트 매핑</label><select id="ppAgent">'+agentOpts+'</select></div>'+
'<div class="pp-row"><label>책상 sprite</label><select id="ppDesk">'+deskOpts+'</select></div>'+
(hasChar ? '' : '<div class="pp-row"><button id="ppAddChar" style="width:100%;padding:6px;background:rgba(16,185,129,.22);border:1px solid rgba(16,185,129,.55);color:#F1F4FB;border-radius:4px;cursor:pointer;font-size:11px">+ 이 책상에 캐릭터 추가</button></div>')+
'<div class="pp-row"><label>착석 캐릭터 (row 0~7)</label><div class="pp-thumbs" id="ppThumbs">'+thumbs+'</div></div>'+
'<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace">'+
'<option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option>'+
'<option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option>'+
'<option value="U"'+(st.face==='U'?' selected':'')+'>↑ Up (뒷모습)</option>'+
'<option value="D"'+(st.face==='D'?' selected':'')+'>↓ Down (정면)</option>'+
'</select></div>';
// 핸들러
panel.querySelector('#ppLabel').oninput = (ev)=>{
st.label = ev.target.value;
const lbl = deskEl.querySelector('.label'); if(lbl) lbl.textContent = st.label;
};
panel.querySelector('#ppAgent').onchange = (ev)=>{
st.agentKey = ev.target.value || '';
// CSS data-role 색깔은 agentKey 기준 — 매핑 변경 시 swap.
if(st.agentKey){ deskEl.dataset.agent = st.agentKey; if(chars[role]) chars[role].dataset.agent = st.agentKey; }
else { delete deskEl.dataset.agent; if(chars[role]) delete chars[role].dataset.agent; }
};
panel.querySelector('#ppDesk').onchange = (ev)=>{
st.deskSprite = ev.target.value;
const img = deskEl.querySelector('img'); if(img) img.src = png(st.deskSprite);
};
panel.querySelectorAll('.pp-thumb').forEach(t=>{
t.onclick = ()=>{
const r = parseInt(t.dataset.charrow,10);
st.charRow = r;
if(anim[role]){ anim[role].row = r; }
const ch = chars[role]; if(ch){ ch.dataset.row = r; const img=ch.querySelector('img'); if(img) img.src = png('idle-r'+r+'-f0'); }
panel.querySelectorAll('.pp-thumb').forEach(x=>x.classList.toggle('active', x===t));
};
});
panel.querySelector('#ppFace').onchange = (ev)=>{
st.face = ev.target.value;
if(anim[role]) anim[role].face = st.face;
// 현재 mode 기준 sprite 즉시 다시 그리기 — U/D 도 한 번에 반영.
const a = anim[role]; if(a) setSprite(role, a.mode || 'sit', 0, 0);
};
const addCharBtn = panel.querySelector('#ppAddChar');
if(addCharBtn){
addCharBtn.onclick = ()=>{
st.noChar = false;
addChar(st);
_renderDeskProps(deskEl); // 패널 재렌더 — "캐릭터 추가" 버튼 제거.
};
}
}
function _renderObjProps(el){
const panel = document.getElementById('propPanel');
panel.classList.add('show');
const name = el.dataset.objName || '';
const w = el.dataset.objW || '';
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+s+'</option>').join('');
panel.innerHTML =
'<h4>프랍 속성</h4>'+
'<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
'<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
panel.querySelector('#ppObjName').onchange = (ev)=>{
el.dataset.objName = ev.target.value;
el.src = png(ev.target.value);
};
panel.querySelector('#ppObjW').oninput = (ev)=>{
const v = ev.target.value.trim();
if(v === ''){ el.style.removeProperty('width'); delete el.dataset.objW; }
else { el.style.width = parseFloat(v)+'px'; el.dataset.objW = String(parseFloat(v)); }
};
}
// ── 신규 책상 추가 ──
function _addNewDesk(){
const id = 'desk_'+(__nextDeskN++);
// 기본 위치: stage 중앙 근처, 다른 책상과 안 겹치게 살짝 오프셋.
const baseX = 280 + ((__nextDeskN%5)*16);
const baseY = 260 + ((__nextDeskN%5)*16);
const st = {
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'desk-main', face: 'R',
boss: false,
deskX: baseX, deskY: baseY, deskW: 112,
seatX: baseX+4, seatY: baseY+36,
dock: [baseX+32, baseY+80],
roam: [[baseX-20, baseY+120],[baseX+60, baseY+100]],
};
stations.push(st); _rebuildStationIndex();
buildStation(st);
// 새 책상을 자동 선택.
if(_selected) _selected.classList.remove('selected');
_selected = __deskWrap[id]; _selected.classList.add('selected');
_onSelectionChanged();
}
// ── 신규 프랍 추가 — sprite picker 모달 ──
function _openPropPicker(){
const overlay = document.createElement('div');
overlay.className = 'prop-picker';
const box = document.createElement('div');
box.className = 'prop-picker-box';
box.innerHTML = '<h3>프랍 추가 — sprite 선택</h3>'+
'<div class="prop-picker-grid">'+
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+n+'</div></div>').join('')+
'</div>';
overlay.appendChild(box);
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
box.querySelectorAll('.prop-pick').forEach(p=>{
p.onclick = ()=>{
const name = p.dataset.name;
// stage 중앙 근처에 배치.
const x = 300 + ((__nextObjN%4)*12);
const y = 280 + ((__nextObjN%4)*12);
const el = addImg(name, x, y);
overlay.remove();
// 자동 선택.
if(_selected) _selected.classList.remove('selected');
_selected = el; _selected.classList.add('selected');
_onSelectionChanged();
};
});
document.body.appendChild(overlay);
}
// ── 선택 항목 삭제 ──
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
// · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
// · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
// · 프랍 선택 → 프랍 삭제.
function _deleteSelected(){
if(!_editMode || !_selected) return;
if(_selected.classList.contains('char')){
// VS Code webview 는 window.confirm() 을 지원하지 않아 (silently false 반환)
// 이전 코드에서 if(!confirm(...)) return 패턴이 즉시 return 되어 삭제가 안 됐다.
// 편집 세션은 Cancel 버튼으로 되돌릴 수 있으니 확인 다이얼로그 없이 바로 삭제.
let role = Object.keys(chars).find(k=>chars[k]===_selected);
if(!role) role = _selected.dataset.role || '';
const st = role ? stationByKey[role] : null;
_selected.remove();
if(role){ delete chars[role]; delete anim[role]; if(st) st.noChar = true; }
_selected = null;
_onSelectionChanged();
return;
}
if(_selected.classList.contains('desk')){
const role = _selected.dataset.role;
_selected.remove();
if(chars[role]) chars[role].remove();
delete __deskWrap[role]; delete chars[role]; delete anim[role];
const idx = stations.findIndex(s=>s.key===role);
if(idx>=0) stations.splice(idx,1);
_rebuildStationIndex();
_selected = null;
_onSelectionChanged();
return;
}
if(_selected.classList.contains('obj')){
_selected.remove();
_selected = null;
_onSelectionChanged();
return;
}
}
function _findDraggable(el){
while(el && el !== stage){
if(el.classList){
if(el.classList.contains('obj')) return el;
if(el.classList.contains('char')) return el;
if(el.classList.contains('desk')) return el;
}
el = el.parentElement;
}
return null;
}
stage.addEventListener('mousedown', e=>{
if(!_editMode) return;
const target = _findDraggable(e.target);
// 빈 공간 클릭 → 선택 해제.
if(!target){
if(_selected){ _selected.classList.remove('selected'); _selected=null; _onSelectionChanged(); }
return;
}
e.preventDefault();
// 선택 표시 갱신 — 한 번에 하나만 selected. 회전 키가 이 객체에 적용됨.
if(_selected && _selected !== target) _selected.classList.remove('selected');
_selected = target;
target.classList.add('selected');
_onSelectionChanged();
_drag = target;
const rect = stage.getBoundingClientRect();
const tx = parseFloat(target.style.left)||0;
const ty = parseFloat(target.style.top)||0;
_dragDX = e.clientX - rect.left - tx;
_dragDY = e.clientY - rect.top - ty;
target.classList.add('dragging');
});
// 키보드 — 편집 모드에서 선택된 객체에 적용.
// R : 시계방향 90도 회전
// Shift+R : 반시계방향 90도 회전
// ] : 레이어 위로 (z-index +1)
// [ : 레이어 아래로 (z-index -1)
function _bringLayer(el, delta){
const cur = parseFloat(el.dataset.z || '0');
const next = Math.max(-99, Math.min(99, cur + delta));
el.dataset.z = String(next);
el.style.zIndex = String(next);
}
document.addEventListener('keydown', e=>{
if(!_editMode || !_selected) return;
const tag = (e.target && e.target.tagName) || '';
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
const key = e.key.toLowerCase();
if(key === 'r'){
e.preventDefault();
const delta = e.shiftKey ? -90 : 90;
const cur = parseFloat(_selected.dataset.rot || '0');
const next = ((cur + delta) % 360 + 360) % 360;
_selected.dataset.rot = String(next);
_selected.style.transform = next === 0 ? '' : ('rotate(' + next + 'deg)');
return;
}
if(key === ']'){ e.preventDefault(); _bringLayer(_selected, +1); return; }
if(key === '['){ e.preventDefault(); _bringLayer(_selected, -1); return; }
});
document.addEventListener('mousemove', e=>{
if(!_editMode || !_drag) return;
const rect = stage.getBoundingClientRect();
let x = e.clientX - rect.left - _dragDX;
let y = e.clientY - rect.top - _dragDY;
// 4px 격자 snap
x = Math.round(x/4)*4;
y = Math.round(y/4)*4;
// stage 경계 안에 가두기
const sw = stage.offsetWidth, sh = stage.offsetHeight;
const tw = _drag.offsetWidth || 0, th = _drag.offsetHeight || 0;
x = Math.max(0, Math.min(sw - tw, x));
y = Math.max(0, Math.min(sh - th, y));
_drag.style.left = x+'px';
_drag.style.top = y+'px';
});
document.addEventListener('mouseup', ()=>{
if(_drag){ _drag.classList.remove('dragging'); }
if(_editMode && _drag && _drag.classList){
if(_drag.classList.contains('desk')){
// 책상만 새 좌표 저장. 캐릭터는 *그대로 둔다* (분리).
const role = _drag.dataset.role;
const st = stationByKey[role];
if(st){
st.deskX = parseFloat(_drag.style.left);
st.deskY = parseFloat(_drag.style.top);
}
} else if(_drag.classList.contains('char')){
// 캐릭터를 옮긴 경우 — 그 자리를 새 home으로.
const role = Object.keys(chars).find(k=>chars[k]===_drag);
if(role){
const st = stationByKey[role];
const nx = parseFloat(_drag.style.left), ny = parseFloat(_drag.style.top);
if(st){ st.seatX = nx; st.seatY = ny; }
_drag.dataset.homeX = nx;
_drag.dataset.homeY = ny;
}
}
}
_drag = null;
});
document.getElementById('editBtn').addEventListener('click', ()=>{
_setEdit(!_editMode);
});
document.getElementById('layerUpBtn').addEventListener('click', ()=>{
if(_selected) _bringLayer(_selected, +1);
});
document.getElementById('layerDownBtn').addEventListener('click', ()=>{
if(_selected) _bringLayer(_selected, -1);
});
document.getElementById('saveBtn').addEventListener('click', ()=>{
const layout = _snapshotLayout();
try{ vscode.postMessage({type:'savePixelOfficeLayout', value: layout}); }catch{}
_setEdit(false);
});
document.getElementById('cancelBtn').addEventListener('click', ()=>{
// 편집 시작 시 찍어둔 스냅샷으로 되돌리기.
if(_snapshotBeforeEdit) _restoreLayout(_snapshotBeforeEdit);
_setEdit(false);
});
document.getElementById('resetBtn').addEventListener('click', ()=>{
// window.confirm 은 VS Code webview 에서 동작 안 함 — 그냥 reset 트리거.
// 사용자 안전망: 편집모드 외에서 reset 누르면 reload 되니, 저장된 layout 만 날아가고 직전 편집 분은 영향 없음.
try{ vscode.postMessage({type:'resetPixelOfficeLayout'}); }catch{}
});
document.getElementById('addDeskBtn').addEventListener('click', _addNewDesk);
document.getElementById('addPropBtn').addEventListener('click', _openPropPicker);
document.getElementById('deleteSelBtn').addEventListener('click', _deleteSelected);
// Delete / Backspace 키도 같은 동작 — 단 입력 필드 포커스 시 무시.
document.addEventListener('keydown', e=>{
if(!_editMode) return;
const tag = (e.target && e.target.tagName) || '';
if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if(e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); _deleteSelected(); }
});
// 저장된 layout 로드 요청 + 응답 처리.
window.addEventListener('message', e=>{
const d = e.data;
if(!d || typeof d !== 'object') return;
if(d.type === 'pixelOfficeLayoutLoaded'){
if(d.value) _restoreLayout(d.value);
}
if(d.type === 'pixelOfficeLayoutSaved'){
if(d.value && d.value.reset){
// 디폴트로 리셋된 경우 — 페이지를 재로딩해서 코드 기본값으로 복귀.
location.reload();
}
}
});
try{ vscode.postMessage({type:'getPixelOfficeLayout'}); }catch{}
})();</script><script>window.addEventListener("load",()=>window.dispatchEvent(new MessageEvent("message",{data:{type:"officeSnapshot",value:{phase:"executing",activeAgentId:"developer",roster:[{agentId:"ceo",agentName:"대표",roleCategory:"ceo",status:"idle",lastActivityAt:1},{agentId:"planner",agentName:"도윤",roleCategory:"planner",status:"idle",lastActivityAt:1},{agentId:"researcher",agentName:"유진",roleCategory:"researcher",status:"idle",lastActivityAt:1},{agentId:"designer",agentName:"다온",roleCategory:"designer",status:"idle",lastActivityAt:1},{agentId:"developer",agentName:"코다리",roleCategory:"developer",status:"executing",currentStep:"Astra Office 리디자인",lastLog:"레이아웃과 비주얼 시스템을 정리 중입니다.",lastActivityAt:2},{agentId:"qa",agentName:"재훈",roleCategory:"qa",status:"idle",lastActivityAt:1},{agentId:"inspector",agentName:"민지",roleCategory:"inspector",status:"idle",lastActivityAt:1}],task:{goal:"Astra Office를 상품성 있는 운영실로 재설계"},pipeline:{index:2,stages:[{label:"의도 분석",status:"done"},{label:"비주얼 설계",status:"done"},{label:"구현",agentId:"developer",status:"active"},{label:"검수",agentId:"qa",status:"pending"}]},activity:[{ts:1,agentId:"planner",text:"구조 재설계 브리프 확정"},{ts:2,agentId:"designer",text:"비주얼 토큰과 레이아웃 정리"},{ts:3,agentId:"developer",text:"상태 패널과 스테이지 구현 완료"}],newBubbles:[],updatedAt:Date.now()}}})));</script></body></html>