ai-control-center 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,1757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main — entry point for the AI Office Visualizer (Pixel Art RPG Edition).
|
|
3
|
+
* Restored from 3bfc3f7 and adapted to current hub-client event-emitter API.
|
|
4
|
+
* Integrates new modules: day-night, cost-overlay, progress-bars,
|
|
5
|
+
* achievements-ui, sound-effects, chat-bubbles, replay, ui.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Character, IDLE_BEHAVIORS, pickTimeAwareBehavior } from './character.js';
|
|
9
|
+
import { getEffects } from './effects.js';
|
|
10
|
+
import { Engine } from './engine.js';
|
|
11
|
+
import { TicketManager } from './feature-ticket.js';
|
|
12
|
+
import { HubClient } from './hub-client.js';
|
|
13
|
+
import { OfficeLayout } from './office-layout.js';
|
|
14
|
+
import { drawStaminaBar, initStamina, updateStamina } from './stamina-system.js';
|
|
15
|
+
|
|
16
|
+
// ── New modules ─────────────────────────────────────────────────────────────
|
|
17
|
+
import { DayNightCycle } from './day-night.js';
|
|
18
|
+
import { CostOverlay } from './cost-overlay.js';
|
|
19
|
+
import { ProgressBarSystem } from './progress-bars.js';
|
|
20
|
+
import { AchievementsUI } from './achievements-ui.js';
|
|
21
|
+
import { SoundEffects } from './sound-effects.js';
|
|
22
|
+
import { ChatBubbleSystem } from './chat-bubbles.js';
|
|
23
|
+
import { PipelineReplay } from './replay.js';
|
|
24
|
+
|
|
25
|
+
// ── State ───────────────────────────────────────────────────────────────────
|
|
26
|
+
let engine, layout, hubClient, effects;
|
|
27
|
+
let dayNight, costOverlay, progressBars, achievementsUI, soundFx, chatBubbles, replay;
|
|
28
|
+
let projectConfigs = []; // from hub config
|
|
29
|
+
let characters = new Map(); // key: "project:role", value: Character
|
|
30
|
+
let projectStates = []; // latest state from hub
|
|
31
|
+
let activityLog = []; // recent activity entries
|
|
32
|
+
let prevStageMap = new Map(); // project → last stage
|
|
33
|
+
let prevFeatureMap = new Map(); // project → last feature (Bug 2)
|
|
34
|
+
let rejectionCountMap = new Map(); // project → consecutive rejection count
|
|
35
|
+
let clickedPMProject = null; // for PM interaction menu
|
|
36
|
+
let firstLoadDone = false; // suppress fireworks on initial state replay
|
|
37
|
+
let projectSeenOnce = new Set(); // track projects that have had at least one state update processed
|
|
38
|
+
let ticketManager = new TicketManager();
|
|
39
|
+
let pendingHandoffs = []; // Phase 3: active handoff animations
|
|
40
|
+
|
|
41
|
+
// Phase 3: maps pipeline stage → which character carries the ticket to whom
|
|
42
|
+
const HANDOFF_MAP = {
|
|
43
|
+
architecture: { from: 'pm', to: 'arch' },
|
|
44
|
+
implementation: { from: 'arch', to: 'coder' },
|
|
45
|
+
review: { from: 'coder', to: 'pm' }, // coder submits for review to PM
|
|
46
|
+
rejected: { from: 'pm', to: 'coder' }, // PM rejects back to coder
|
|
47
|
+
approved: { from: 'pm', to: 'deployer' }, // PM approves, hand to deployer
|
|
48
|
+
deploying: { from: 'deployer', to: 'deployer' }, // deployer stays
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Estimated durations per pipeline stage (seconds of visual progress)
|
|
52
|
+
const STAGE_DURATIONS = {
|
|
53
|
+
spec: 30, architecture: 45, implementation: 60,
|
|
54
|
+
review: 20, rejected: 15, fixing: 40, deploying: 15,
|
|
55
|
+
approved: 5, deployed: 5, inbox: 3,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── Persistent shipped packages (survive page refresh) ─────────────────────
|
|
59
|
+
function loadShippedPackages() {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(localStorage.getItem('office_shipped_packages') || '{}');
|
|
62
|
+
} catch { return {}; }
|
|
63
|
+
}
|
|
64
|
+
function saveShippedPackage(projectName, featureName) {
|
|
65
|
+
const pkgs = loadShippedPackages();
|
|
66
|
+
if (!pkgs[projectName]) pkgs[projectName] = [];
|
|
67
|
+
if (!pkgs[projectName].includes(featureName)) {
|
|
68
|
+
pkgs[projectName].push(featureName);
|
|
69
|
+
if (pkgs[projectName].length > 20) pkgs[projectName].shift();
|
|
70
|
+
localStorage.setItem('office_shipped_packages', JSON.stringify(pkgs));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Initialize ──────────────────────────────────────────────────────────────
|
|
75
|
+
function init() {
|
|
76
|
+
const canvas = document.getElementById('office-canvas');
|
|
77
|
+
engine = new Engine(canvas);
|
|
78
|
+
layout = new OfficeLayout();
|
|
79
|
+
|
|
80
|
+
effects = getEffects();
|
|
81
|
+
|
|
82
|
+
// ── Initialize new modules ──────────────────────────────────────────────
|
|
83
|
+
dayNight = new DayNightCycle(canvas);
|
|
84
|
+
costOverlay = new CostOverlay(document.getElementById('canvas-container'));
|
|
85
|
+
progressBars = new ProgressBarSystem();
|
|
86
|
+
achievementsUI = new AchievementsUI(document.getElementById('canvas-container'));
|
|
87
|
+
soundFx = new SoundEffects();
|
|
88
|
+
chatBubbles = new ChatBubbleSystem();
|
|
89
|
+
replay = new PipelineReplay(engine);
|
|
90
|
+
|
|
91
|
+
// Register render layers (order matters: layout → characters → effects → idle AI)
|
|
92
|
+
engine.onRender((ctx, dt) => {
|
|
93
|
+
layout.render(ctx, dt);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
engine.onRender((ctx, dt) => {
|
|
97
|
+
updateIdleBehaviors(dt);
|
|
98
|
+
updateWorkVisits(dt);
|
|
99
|
+
updateHandoffs(dt);
|
|
100
|
+
for (const char of characters.values()) {
|
|
101
|
+
char.update(dt);
|
|
102
|
+
char.render(ctx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Chat bubbles (render on canvas in world space)
|
|
106
|
+
chatBubbles.update(dt);
|
|
107
|
+
// Build agents map for chat bubble rendering
|
|
108
|
+
const agentsMap = {};
|
|
109
|
+
for (const [key, char] of characters) {
|
|
110
|
+
agentsMap[key] = char;
|
|
111
|
+
}
|
|
112
|
+
chatBubbles.render(ctx, agentsMap);
|
|
113
|
+
|
|
114
|
+
// Feature tickets (render on top of characters)
|
|
115
|
+
ticketManager.update(dt);
|
|
116
|
+
ticketManager.render(ctx);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
engine.onRender((ctx, dt) => {
|
|
120
|
+
effects.update(dt);
|
|
121
|
+
effects.render(ctx);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── Post-render overlays (screen space) ─────────────────────────────────
|
|
125
|
+
engine.onPostRender((ctx, dt) => {
|
|
126
|
+
// Day/night cycle overlay
|
|
127
|
+
dayNight.update(dt);
|
|
128
|
+
dayNight.applyLighting(ctx, engine.width, engine.height);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Click handling
|
|
132
|
+
engine.onClick((wx, wy, e) => {
|
|
133
|
+
// Check character clicks first
|
|
134
|
+
for (const char of characters.values()) {
|
|
135
|
+
if (char.hitTest(wx, wy)) {
|
|
136
|
+
showCharPopup(char, e.clientX, e.clientY);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Bug 4: Check warehouse package clicks
|
|
142
|
+
const pkg = layout.hitTestPackage(wx, wy);
|
|
143
|
+
if (pkg) {
|
|
144
|
+
showPackagePopup(pkg, e.clientX, e.clientY);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check team zone clicks
|
|
149
|
+
const zone = layout.hitTest(wx, wy);
|
|
150
|
+
if (zone) {
|
|
151
|
+
showTeamPanel(zone.project, zone.config);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Click on empty space — hide popups
|
|
156
|
+
hidePopups();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── Hub WebSocket (event-emitter API) ────────────────────────────────────
|
|
160
|
+
hubClient = new HubClient();
|
|
161
|
+
|
|
162
|
+
hubClient.on('connected', () => {
|
|
163
|
+
console.log('[Office] Hub connected');
|
|
164
|
+
document.getElementById('connection-status')?.classList.replace('offline', 'online');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
hubClient.on('disconnected', () => {
|
|
168
|
+
console.log('[Office] Hub disconnected');
|
|
169
|
+
document.getElementById('connection-status')?.classList.replace('online', 'offline');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Hub mode: multi-project state from hub server ──────────────────────
|
|
173
|
+
// Hub broadcasts: { type: 'state', projects: { "Name": {name,url,color,icon,status,online}, ... }, summary }
|
|
174
|
+
hubClient.on('state', (msg) => {
|
|
175
|
+
const projects = msg?.projects || {};
|
|
176
|
+
const projectList = Object.values(projects);
|
|
177
|
+
if (projectList.length === 0) return;
|
|
178
|
+
console.log(`[Office] Hub state received: ${projectList.map(p => `${p.name}=${p.status?.stage||'?'}`).join(', ')}`);
|
|
179
|
+
|
|
180
|
+
// Extract configs from hub state (first time or when projects change)
|
|
181
|
+
const configs = projectList.map(p => ({
|
|
182
|
+
name: p.name,
|
|
183
|
+
color: p.color || '#4A90D9',
|
|
184
|
+
icon: p.icon || '',
|
|
185
|
+
url: p.url || '',
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// Update config if project list changed
|
|
189
|
+
const configNames = configs.map(c => c.name).sort().join(',');
|
|
190
|
+
const currentNames = projectConfigs.map(c => c.name).sort().join(',');
|
|
191
|
+
if (configNames !== currentNames) {
|
|
192
|
+
onConfigUpdate(configs);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Convert hub state to array format for onStateUpdate
|
|
196
|
+
const wrapped = projectList.map(p => ({
|
|
197
|
+
name: p.name,
|
|
198
|
+
online: p.online !== false,
|
|
199
|
+
status: {
|
|
200
|
+
stage: p.status?.stage || '',
|
|
201
|
+
current_feature: p.status?.current_feature || '',
|
|
202
|
+
_bannedModels: p.status?._bannedModels || [],
|
|
203
|
+
history: p.status?.history || [],
|
|
204
|
+
rejection_reason: p.status?.rejection_reason || '',
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
onStateUpdate(wrapped);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── Standalone mode: single-project status from individual web server ──
|
|
211
|
+
hubClient.on('status', (msg) => {
|
|
212
|
+
const data = msg?.data || msg;
|
|
213
|
+
const status = data?.status || data;
|
|
214
|
+
if (!status) return;
|
|
215
|
+
console.log(`[Office] Standalone status: stage=${status.stage}, feature=${status.current_feature || '?'}`);
|
|
216
|
+
|
|
217
|
+
// Use known project name from config, or extract from status
|
|
218
|
+
const projectName = status.project || status.projectName || projectConfigs[0]?.name || 'Default';
|
|
219
|
+
|
|
220
|
+
// Auto-create project config if we haven't received one yet
|
|
221
|
+
if (projectConfigs.length === 0) {
|
|
222
|
+
onConfigUpdate([{ name: projectName, color: '#4A90D9', icon: '' }]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Server sends single-project status object — wrap it as array for our multi-project renderer
|
|
226
|
+
const wrapped = [{
|
|
227
|
+
name: projectName,
|
|
228
|
+
online: true,
|
|
229
|
+
status: {
|
|
230
|
+
stage: status.stage || '',
|
|
231
|
+
current_feature: status.current_feature || status.currentFeature || '',
|
|
232
|
+
_bannedModels: status._bannedModels || status.bannedModels || [],
|
|
233
|
+
history: status.history || [],
|
|
234
|
+
rejection_reason: status.rejection_reason || '',
|
|
235
|
+
},
|
|
236
|
+
}];
|
|
237
|
+
onStateUpdate(wrapped);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Handle pipeline events (user actions, deploy, etc.)
|
|
241
|
+
hubClient.on('event', (msg) => {
|
|
242
|
+
const data = msg?.data || msg;
|
|
243
|
+
if (!data) return;
|
|
244
|
+
const projectName = data.project || data.projectName || projectConfigs[0]?.name || '';
|
|
245
|
+
const event = data.event || data.type || '';
|
|
246
|
+
onHubEvent(projectName, event, data.data || data);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Handle log entries
|
|
250
|
+
hubClient.on('log', (msg) => {
|
|
251
|
+
const data = msg?.data || msg;
|
|
252
|
+
if (data?.message) {
|
|
253
|
+
addActivity(data.project || 'System', data.message);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── Connect to hub first, fallback to local WebSocket ──────────────────
|
|
258
|
+
connectToHub();
|
|
259
|
+
|
|
260
|
+
// UI buttons
|
|
261
|
+
document.getElementById('btn-submit')?.addEventListener('click', () => {
|
|
262
|
+
document.getElementById('submit-modal')?.classList.remove('hidden');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
document.getElementById('submit-go')?.addEventListener('click', submitFeature);
|
|
266
|
+
|
|
267
|
+
// PM menu item clicks
|
|
268
|
+
document.querySelectorAll('.pm-menu-item').forEach(item => {
|
|
269
|
+
item.addEventListener('click', () => {
|
|
270
|
+
const action = item.dataset.action;
|
|
271
|
+
handlePMAction(action);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Initialize slash command bar
|
|
276
|
+
initCommandBar();
|
|
277
|
+
|
|
278
|
+
// Start engine
|
|
279
|
+
engine.start();
|
|
280
|
+
console.log('[Office] AI Operations Center started (Pixel Art RPG Edition)');
|
|
281
|
+
|
|
282
|
+
// Show demo characters if no hub connection after 3s
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
if (projectConfigs.length === 0) {
|
|
285
|
+
loadDemoMode();
|
|
286
|
+
}
|
|
287
|
+
}, 3000);
|
|
288
|
+
|
|
289
|
+
// Fetch initial state (hub mode will override via WebSocket)
|
|
290
|
+
fetchInitialState();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Hub / Standalone Connection ──────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
let hubBaseUrl = null; // set when hub is detected
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Try connecting to hub server first (port 3850 by convention).
|
|
299
|
+
* If hub is available, use it for multi-project state.
|
|
300
|
+
* Otherwise fall back to the local project's WebSocket.
|
|
301
|
+
*/
|
|
302
|
+
async function connectToHub() {
|
|
303
|
+
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
304
|
+
|
|
305
|
+
// Try hub on same host, default port 3850
|
|
306
|
+
const hubPort = 3850;
|
|
307
|
+
const hubWsUrl = `${wsProtocol}//${location.hostname}:${hubPort}/ws`;
|
|
308
|
+
const hubHttpUrl = `${location.protocol}//${location.hostname}:${hubPort}`;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
// Quick check if hub is running
|
|
312
|
+
const res = await fetch(`${hubHttpUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
|
|
313
|
+
if (res.ok) {
|
|
314
|
+
const health = await res.json();
|
|
315
|
+
if (health.hub === 'ok') {
|
|
316
|
+
console.log(`[Office] Hub detected at port ${hubPort} — multi-project mode (${health.projectCount} projects)`);
|
|
317
|
+
hubBaseUrl = hubHttpUrl;
|
|
318
|
+
hubClient.connect(hubWsUrl);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// Hub not available
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Fallback: connect to local project's WebSocket
|
|
327
|
+
console.log('[Office] No hub detected — single-project mode');
|
|
328
|
+
hubClient.connect(`${wsProtocol}//${location.host}/ws`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Fetch initial state from REST API (for single-project mode bootstrap) */
|
|
332
|
+
async function fetchInitialState() {
|
|
333
|
+
// If hub is connected, it will push state via WebSocket — skip REST
|
|
334
|
+
if (hubBaseUrl) {
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch(`${hubBaseUrl}/api/projects`);
|
|
337
|
+
const data = await res.json();
|
|
338
|
+
if (data.projects?.length) {
|
|
339
|
+
const configs = data.projects.map(p => ({
|
|
340
|
+
name: p.name,
|
|
341
|
+
color: p.color || '#4A90D9',
|
|
342
|
+
icon: p.icon || '',
|
|
343
|
+
url: p.url || '',
|
|
344
|
+
}));
|
|
345
|
+
onConfigUpdate(configs);
|
|
346
|
+
|
|
347
|
+
const wrapped = data.projects.map(p => ({
|
|
348
|
+
name: p.name,
|
|
349
|
+
online: p.online !== false,
|
|
350
|
+
status: {
|
|
351
|
+
stage: p.status?.stage || '',
|
|
352
|
+
current_feature: p.status?.current_feature || '',
|
|
353
|
+
_bannedModels: p.status?._bannedModels || [],
|
|
354
|
+
history: p.status?.history || [],
|
|
355
|
+
rejection_reason: p.status?.rejection_reason || '',
|
|
356
|
+
},
|
|
357
|
+
}));
|
|
358
|
+
onStateUpdate(wrapped);
|
|
359
|
+
}
|
|
360
|
+
} catch { /* hub will push state via WS */ }
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Single-project mode: fetch from local API
|
|
365
|
+
try {
|
|
366
|
+
const infoRes = await fetch('/api/info');
|
|
367
|
+
const info = await infoRes.json();
|
|
368
|
+
const projectName = info.name || 'Unknown Project';
|
|
369
|
+
|
|
370
|
+
if (projectConfigs.length === 0) {
|
|
371
|
+
onConfigUpdate([{ name: projectName, color: info.color || '#4A90D9', icon: info.icon || '' }]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const statusRes = await fetch('/api/status');
|
|
375
|
+
const status = await statusRes.json();
|
|
376
|
+
if (status) {
|
|
377
|
+
onStateUpdate([{
|
|
378
|
+
name: projectName,
|
|
379
|
+
online: true,
|
|
380
|
+
status: {
|
|
381
|
+
stage: status.stage || '',
|
|
382
|
+
current_feature: status.current_feature || '',
|
|
383
|
+
_bannedModels: status._bannedModels || [],
|
|
384
|
+
history: status.history || [],
|
|
385
|
+
rejection_reason: status.rejection_reason || '',
|
|
386
|
+
},
|
|
387
|
+
}]);
|
|
388
|
+
}
|
|
389
|
+
} catch { /* will use demo mode after 3s timeout */ }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Idle Behavior System ─────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function updateIdleBehaviors(_dt) {
|
|
395
|
+
for (const char of characters.values()) {
|
|
396
|
+
if (!char.wantsIdleAction()) continue;
|
|
397
|
+
|
|
398
|
+
// Pick an idle behavior with time-of-day awareness
|
|
399
|
+
const behavior = pickTimeAwareBehavior();
|
|
400
|
+
const idlePositions = layout.getIdlePositions(char.project);
|
|
401
|
+
|
|
402
|
+
let targetPos = null;
|
|
403
|
+
let gossipPartner = null;
|
|
404
|
+
|
|
405
|
+
switch (behavior) {
|
|
406
|
+
case 'wander': {
|
|
407
|
+
// Wander to a random spot near home
|
|
408
|
+
const wanderRange = 80;
|
|
409
|
+
targetPos = {
|
|
410
|
+
x: char.homeX + (Math.random() - 0.5) * wanderRange * 2,
|
|
411
|
+
y: char.homeY + (Math.random() - 0.5) * wanderRange * 2,
|
|
412
|
+
};
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'gossip': {
|
|
416
|
+
// Find another idle character from the same project to gossip with
|
|
417
|
+
gossipPartner = findGossipPartner(char);
|
|
418
|
+
if (!gossipPartner) {
|
|
419
|
+
// No partner available — wander instead
|
|
420
|
+
targetPos = {
|
|
421
|
+
x: char.homeX + (Math.random() - 0.5) * 60,
|
|
422
|
+
y: char.homeY + (Math.random() - 0.5) * 60,
|
|
423
|
+
};
|
|
424
|
+
} else {
|
|
425
|
+
// Meet near each other with a 16px gap so they don't overlap
|
|
426
|
+
const midX = (char.x + gossipPartner.x) / 2;
|
|
427
|
+
const midY = (char.y + gossipPartner.y) / 2;
|
|
428
|
+
const gdx = gossipPartner.x - char.x;
|
|
429
|
+
const gdy = gossipPartner.y - char.y;
|
|
430
|
+
const glen = Math.sqrt(gdx * gdx + gdy * gdy) || 1;
|
|
431
|
+
const GOSSIP_GAP = 16;
|
|
432
|
+
targetPos = {
|
|
433
|
+
x: midX - (gdx / glen) * GOSSIP_GAP,
|
|
434
|
+
y: midY - (gdy / glen) * GOSSIP_GAP,
|
|
435
|
+
};
|
|
436
|
+
gossipPartner.startIdleBehavior('gossip', {
|
|
437
|
+
x: midX + (gdx / glen) * GOSSIP_GAP,
|
|
438
|
+
y: midY + (gdy / glen) * GOSSIP_GAP,
|
|
439
|
+
}, char);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case 'gym':
|
|
444
|
+
targetPos = idlePositions.gym;
|
|
445
|
+
break;
|
|
446
|
+
case 'breakRoom':
|
|
447
|
+
targetPos = idlePositions.breakRoom;
|
|
448
|
+
break;
|
|
449
|
+
case 'coffee':
|
|
450
|
+
targetPos = idlePositions.coffee;
|
|
451
|
+
break;
|
|
452
|
+
case 'poke':
|
|
453
|
+
case 'highfive': {
|
|
454
|
+
// Find a partner for social interaction
|
|
455
|
+
gossipPartner = findGossipPartner(char);
|
|
456
|
+
if (!gossipPartner) {
|
|
457
|
+
targetPos = { x: char.homeX + (Math.random() - 0.5) * 60, y: char.homeY + (Math.random() - 0.5) * 60 };
|
|
458
|
+
} else {
|
|
459
|
+
const midX = (char.x + gossipPartner.x) / 2;
|
|
460
|
+
const midY = (char.y + gossipPartner.y) / 2;
|
|
461
|
+
const gdx = gossipPartner.x - char.x;
|
|
462
|
+
const gdy = gossipPartner.y - char.y;
|
|
463
|
+
const glen = Math.sqrt(gdx * gdx + gdy * gdy) || 1;
|
|
464
|
+
const GAP = 14;
|
|
465
|
+
targetPos = { x: midX - (gdx / glen) * GAP, y: midY - (gdy / glen) * GAP };
|
|
466
|
+
gossipPartner.startIdleBehavior(behavior, { x: midX + (gdx / glen) * GAP, y: midY + (gdy / glen) * GAP }, char);
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case 'phone':
|
|
471
|
+
// Stay in place, check phone
|
|
472
|
+
targetPos = { x: char.x, y: char.y };
|
|
473
|
+
break;
|
|
474
|
+
case 'stretch':
|
|
475
|
+
// Stay in place, stretch
|
|
476
|
+
targetPos = { x: char.x, y: char.y };
|
|
477
|
+
break;
|
|
478
|
+
case 'meeting': {
|
|
479
|
+
// Two idle characters from the same project meet at break room
|
|
480
|
+
gossipPartner = findGossipPartner(char);
|
|
481
|
+
if (!gossipPartner) {
|
|
482
|
+
// No partner — just wander instead
|
|
483
|
+
targetPos = {
|
|
484
|
+
x: char.homeX + (Math.random() - 0.5) * 60,
|
|
485
|
+
y: char.homeY + (Math.random() - 0.5) * 60,
|
|
486
|
+
};
|
|
487
|
+
} else {
|
|
488
|
+
// Meet at break room
|
|
489
|
+
const meetPos = idlePositions.breakRoom || idlePositions.lounge1;
|
|
490
|
+
if (meetPos) {
|
|
491
|
+
const GAP = 16;
|
|
492
|
+
targetPos = { x: meetPos.x - GAP, y: meetPos.y };
|
|
493
|
+
gossipPartner.startIdleBehavior('meeting', { x: meetPos.x + GAP, y: meetPos.y }, char);
|
|
494
|
+
} else {
|
|
495
|
+
targetPos = { x: char.homeX, y: char.homeY };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case 'sleep':
|
|
501
|
+
// Sleep at their desk
|
|
502
|
+
targetPos = { x: char.homeX, y: char.homeY };
|
|
503
|
+
break;
|
|
504
|
+
default:
|
|
505
|
+
targetPos = idlePositions.lounge1;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
char.startIdleBehavior(behavior, targetPos, gossipPartner);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function findGossipPartner(char) {
|
|
513
|
+
// Find an idle character from the same project who isn't already gossiping
|
|
514
|
+
for (const other of characters.values()) {
|
|
515
|
+
if (other === char) continue;
|
|
516
|
+
if (other.project !== char.project) continue;
|
|
517
|
+
if (other.isWorking) continue;
|
|
518
|
+
if (other.idleBehavior === 'gossip') continue; // already gossiping
|
|
519
|
+
if (other.idleBehavior === 'meeting') continue; // in a meeting
|
|
520
|
+
if (other.idleBehavior === 'sleep') continue; // don't wake them up
|
|
521
|
+
if (other.idleBehavior === 'phone') continue; // on their phone
|
|
522
|
+
if (other.idleBehavior === 'stretch') continue; // stretching
|
|
523
|
+
return other;
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Work Visit System (feature discussions while working) ───────────────────
|
|
529
|
+
// DISABLED: Working characters must stay at their desk and focus.
|
|
530
|
+
let workVisitTimer = 0;
|
|
531
|
+
|
|
532
|
+
function updateWorkVisits(dt) {
|
|
533
|
+
// No-op: working characters should not leave their station
|
|
534
|
+
workVisitTimer += dt;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── Phase 3: Handoff Animation System ────────────────────────────────────────
|
|
538
|
+
const HANDOFF_TIMEOUT = 8; // seconds — if source doesn't arrive, force-complete
|
|
539
|
+
|
|
540
|
+
function completeHandoff(ho) {
|
|
541
|
+
// Drop the ticket at the target position
|
|
542
|
+
ho.ticket.detach();
|
|
543
|
+
ho.ticket.setRole(ho.targetRole);
|
|
544
|
+
ho.ticket.sendTo(ho.targetPos, ho.stayDuration);
|
|
545
|
+
|
|
546
|
+
// Release source character and send them home
|
|
547
|
+
ho.sourceChar.isHandingOff = false;
|
|
548
|
+
ho.sourceChar.idleTimer = 4 + Math.random() * 6;
|
|
549
|
+
ho.sourceChar.moveTo(ho.sourceChar.homeX, ho.sourceChar.homeY);
|
|
550
|
+
|
|
551
|
+
// Target character reacts
|
|
552
|
+
if (ho.targetChar && ho.targetChar !== ho.sourceChar) {
|
|
553
|
+
ho.targetChar.react('working', '\u{1F4CB} Got it!', 2);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function updateHandoffs(dt) {
|
|
558
|
+
for (let i = pendingHandoffs.length - 1; i >= 0; i--) {
|
|
559
|
+
const ho = pendingHandoffs[i];
|
|
560
|
+
ho.elapsed = (ho.elapsed || 0) + dt;
|
|
561
|
+
|
|
562
|
+
const dx = ho.targetPos.x - ho.sourceChar.x;
|
|
563
|
+
const dy = ho.targetPos.y - ho.sourceChar.y;
|
|
564
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
565
|
+
|
|
566
|
+
if (dist < 12 || ho.elapsed > HANDOFF_TIMEOUT) {
|
|
567
|
+
completeHandoff(ho);
|
|
568
|
+
pendingHandoffs.splice(i, 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Demo Mode (when hub isn't running) ──────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
function loadDemoMode() {
|
|
576
|
+
console.log('[Office] No hub detected — loading demo mode');
|
|
577
|
+
const demoConfigs = [
|
|
578
|
+
{ name: 'XConnector', color: '#4A90D9', icon: '' },
|
|
579
|
+
{ name: 'PickleBall', color: '#27AE60', icon: '' },
|
|
580
|
+
];
|
|
581
|
+
onConfigUpdate(demoConfigs);
|
|
582
|
+
|
|
583
|
+
// Simulate pipeline activity for XConnector
|
|
584
|
+
const demoStages = ['idle', 'spec', 'architecture', 'implementation', 'review', 'approved', 'deploying', 'deployed'];
|
|
585
|
+
let stageIdx = 0;
|
|
586
|
+
const demoFeature = 'Auto-Sync Dashboard';
|
|
587
|
+
|
|
588
|
+
setInterval(() => {
|
|
589
|
+
stageIdx = (stageIdx + 1) % demoStages.length;
|
|
590
|
+
const stage = demoStages[stageIdx];
|
|
591
|
+
|
|
592
|
+
// Update character states
|
|
593
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
594
|
+
const key = `XConnector:${role}`;
|
|
595
|
+
const char = characters.get(key);
|
|
596
|
+
if (char) {
|
|
597
|
+
const wasWorking = char.isWorking;
|
|
598
|
+
char.setState(stage);
|
|
599
|
+
if (!wasWorking && char.isWorking) {
|
|
600
|
+
char.moveTo(char.homeX, char.homeY);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Update status board and whiteboard
|
|
606
|
+
layout.setProjectState('XConnector', demoFeature, stage, stage === 'deploying');
|
|
607
|
+
|
|
608
|
+
// Add shipped package when deployed
|
|
609
|
+
if (stage === 'deployed') {
|
|
610
|
+
layout.addShippedPackage('XConnector', demoFeature);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
addActivity('XConnector', stageToMessage(stage, demoFeature));
|
|
614
|
+
|
|
615
|
+
// Sound effects for demo
|
|
616
|
+
if (soundFx) {
|
|
617
|
+
if (stage === 'spec' || stage === 'architecture' || stage === 'implementation') soundFx.playTaskStart();
|
|
618
|
+
if (stage === 'deployed') soundFx.playTaskComplete();
|
|
619
|
+
}
|
|
620
|
+
}, 4000);
|
|
621
|
+
|
|
622
|
+
// PickleBall team is idle — they'll use the idle behavior system naturally
|
|
623
|
+
layout.setProjectState('PickleBall', '', 'idle', false);
|
|
624
|
+
|
|
625
|
+
document.getElementById('connection-status')?.classList.replace('offline', 'online');
|
|
626
|
+
addActivity('System', 'Demo mode active — hub not connected');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── Hub State Updates ───────────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
function onConfigUpdate(configs) {
|
|
632
|
+
projectConfigs = configs || [];
|
|
633
|
+
layout.setupTeams(projectConfigs);
|
|
634
|
+
createCharacters();
|
|
635
|
+
updateProjectDropdown();
|
|
636
|
+
|
|
637
|
+
document.getElementById('project-count').textContent =
|
|
638
|
+
`${projectConfigs.length} PROJECT${projectConfigs.length !== 1 ? 'S' : ''}`;
|
|
639
|
+
|
|
640
|
+
document.getElementById('connection-status')?.classList.replace('offline', 'online');
|
|
641
|
+
|
|
642
|
+
// Restore shipped packages from localStorage
|
|
643
|
+
const savedPkgs = loadShippedPackages();
|
|
644
|
+
for (const [projName, features] of Object.entries(savedPkgs)) {
|
|
645
|
+
for (const feat of features) {
|
|
646
|
+
layout.addShippedPackage(projName, feat);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Bug 2 fix: re-apply current pipeline state immediately after characters are created
|
|
651
|
+
if (projectStates.length > 0) {
|
|
652
|
+
prevStageMap.clear();
|
|
653
|
+
rejectionCountMap.clear();
|
|
654
|
+
onStateUpdate(projectStates);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function onStateUpdate(projects) {
|
|
659
|
+
projectStates = projects || [];
|
|
660
|
+
|
|
661
|
+
// Debug: log state updates to browser console
|
|
662
|
+
for (const p of (projects || [])) {
|
|
663
|
+
const s = p.status?.stage || '';
|
|
664
|
+
const prev = prevStageMap.get(p.name);
|
|
665
|
+
if (s !== prev) {
|
|
666
|
+
console.log(`[Office] Stage change: ${p.name} ${prev || '(none)'} → ${s}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Snapshot which projects have been seen before this update cycle.
|
|
671
|
+
// Celebrations are only allowed for projects already in the set.
|
|
672
|
+
const alreadySeen = new Set(projectSeenOnce);
|
|
673
|
+
|
|
674
|
+
for (const p of projectStates) {
|
|
675
|
+
const stage = p.status?.stage || '';
|
|
676
|
+
const name = p.name;
|
|
677
|
+
const feature = p.status?.current_feature || '';
|
|
678
|
+
const isDeploying = stage === 'deploying';
|
|
679
|
+
|
|
680
|
+
// Update status board, whiteboard, and conveyor state
|
|
681
|
+
layout.setProjectState(name, feature, stage, isDeploying);
|
|
682
|
+
|
|
683
|
+
// ── Only update character states when stage CHANGES (Bug 1 fix) ──────────
|
|
684
|
+
const prevStage = prevStageMap.has(name) ? prevStageMap.get(name) : undefined;
|
|
685
|
+
if (stage !== prevStage) {
|
|
686
|
+
prevStageMap.set(name, stage);
|
|
687
|
+
|
|
688
|
+
// Cancel any pending handoffs for this project (stage moved on)
|
|
689
|
+
for (let hi = pendingHandoffs.length - 1; hi >= 0; hi--) {
|
|
690
|
+
if (pendingHandoffs[hi].projectName === name) {
|
|
691
|
+
completeHandoff(pendingHandoffs[hi]);
|
|
692
|
+
pendingHandoffs.splice(hi, 1);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Track rejection count for escalating reactions
|
|
697
|
+
if (stage === 'rejected') {
|
|
698
|
+
rejectionCountMap.set(name, (rejectionCountMap.get(name) || 0) + 1);
|
|
699
|
+
} else if (stage === 'approved' || stage === 'deployed') {
|
|
700
|
+
rejectionCountMap.set(name, 0);
|
|
701
|
+
}
|
|
702
|
+
const rejCount = rejectionCountMap.get(name) || 0;
|
|
703
|
+
|
|
704
|
+
// Wake ALL characters when transitioning from idle to active stage
|
|
705
|
+
const idleStages = new Set(['', 'idle', 'inbox']);
|
|
706
|
+
const isNewTask = idleStages.has(prevStage || '') && !idleStages.has(stage);
|
|
707
|
+
|
|
708
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
709
|
+
const key = `${name}:${role}`;
|
|
710
|
+
const char = characters.get(key);
|
|
711
|
+
if (char) {
|
|
712
|
+
// Track current feature for work discussions vs idle gossip
|
|
713
|
+
char.currentFeature = feature || '';
|
|
714
|
+
|
|
715
|
+
// Wake sleeping/idle chars and send them home when new task starts
|
|
716
|
+
if (isNewTask) {
|
|
717
|
+
char.idleBehavior = null;
|
|
718
|
+
char.gossipTarget = null;
|
|
719
|
+
char.gossipLine = '';
|
|
720
|
+
char.moveTo(char.homeX, char.homeY);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const wasWorking = char.isWorking;
|
|
724
|
+
char.isHandingOff = false; // clear handoff flag on stage change
|
|
725
|
+
char.rejectionCount = rejCount;
|
|
726
|
+
char.setState(stage);
|
|
727
|
+
|
|
728
|
+
// Set task progress duration for progress bar
|
|
729
|
+
char.taskProgress = 0;
|
|
730
|
+
char.taskDuration = STAGE_DURATIONS[stage] || 0;
|
|
731
|
+
|
|
732
|
+
// When approved, always send deployer back to their dock to stand by
|
|
733
|
+
if (stage === 'approved' && char.role === 'deployer') {
|
|
734
|
+
char.moveTo(char.homeX, char.homeY);
|
|
735
|
+
char.workBubble = '📦 Package ready!';
|
|
736
|
+
char.workBubbleDuration = 3;
|
|
737
|
+
char.workBubbleTimer = 2;
|
|
738
|
+
} else if (!wasWorking && char.isWorking) {
|
|
739
|
+
char.moveTo(char.homeX, char.homeY);
|
|
740
|
+
// Show "going to work" bubble so user knows who's starting
|
|
741
|
+
const workLabels = {
|
|
742
|
+
working: '📝 On it!',
|
|
743
|
+
designing: '📐 Designing...',
|
|
744
|
+
reviewing: '🔍 Reviewing...',
|
|
745
|
+
deploying: '🚀 Deploying!',
|
|
746
|
+
fighting_bug: '🐛 Fixing bugs!',
|
|
747
|
+
celebrating: '🎉 Done!',
|
|
748
|
+
waiting_input: '⏳ Waiting...',
|
|
749
|
+
};
|
|
750
|
+
const label = workLabels[char.state] || '⚡ Working...';
|
|
751
|
+
char.workBubble = label;
|
|
752
|
+
char.workBubbleDuration = 4;
|
|
753
|
+
char.workBubbleTimer = 0;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── Progress bars for pipeline stages ──────────────────────────────────
|
|
759
|
+
if (progressBars) {
|
|
760
|
+
progressBars.handlePipelineEvent({
|
|
761
|
+
type: 'stage-start',
|
|
762
|
+
stage,
|
|
763
|
+
project: name,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── Sound effects on stage change ──────────────────────────────────────
|
|
768
|
+
if (soundFx && firstLoadDone && alreadySeen.has(name)) {
|
|
769
|
+
if (stage === 'spec' || stage === 'architecture' || stage === 'implementation') {
|
|
770
|
+
soundFx.playTaskStart();
|
|
771
|
+
} else if (stage === 'deployed') {
|
|
772
|
+
soundFx.playTaskComplete();
|
|
773
|
+
} else if (stage === 'rejected') {
|
|
774
|
+
soundFx.playError();
|
|
775
|
+
} else if (stage === 'approved') {
|
|
776
|
+
soundFx.playNotification();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Feature Ticket management ──────────────────────────────────────────
|
|
781
|
+
const stageToRole = {
|
|
782
|
+
inbox: 'pm', spec: 'pm', spec_complete: 'pm',
|
|
783
|
+
architecture: 'arch', arch_complete: 'arch',
|
|
784
|
+
implementation: 'coder', implementation_complete: 'coder',
|
|
785
|
+
review: 'pm', review_complete: 'pm',
|
|
786
|
+
rejected: 'coder', fixing: 'coder',
|
|
787
|
+
approved: 'deployer', deploying: 'deployer',
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
if (isNewTask && feature) {
|
|
791
|
+
// Create a new ticket at PM's desk — but only if one doesn't already exist
|
|
792
|
+
const existingTicket = ticketManager.getByProject(name);
|
|
793
|
+
if (!existingTicket) {
|
|
794
|
+
const pmPos = layout.getCharacterPositions(name).pm;
|
|
795
|
+
const projColor = projectConfigs.find(c => c.name === name)?.color || '#4A90D9';
|
|
796
|
+
if (pmPos) {
|
|
797
|
+
ticketManager.createTicket(feature, name, pmPos, projColor);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Move existing ticket to the role's station for the current stage
|
|
803
|
+
const ticket = ticketManager.getByProject(name);
|
|
804
|
+
if (ticket) {
|
|
805
|
+
const targetRole = stageToRole[stage];
|
|
806
|
+
if (targetRole) {
|
|
807
|
+
const positions = layout.getCharacterPositions(name);
|
|
808
|
+
const targetPos = positions[targetRole];
|
|
809
|
+
if (targetPos) {
|
|
810
|
+
const handoff = HANDOFF_MAP[stage];
|
|
811
|
+
const stayDuration = STAGE_DURATIONS[stage] || 10;
|
|
812
|
+
|
|
813
|
+
// Phase 3: Try handoff animation if a mapping exists
|
|
814
|
+
if (handoff && firstLoadDone && alreadySeen.has(name)) {
|
|
815
|
+
const sourceChar = characters.get(`${name}:${handoff.from}`);
|
|
816
|
+
const targetChar = characters.get(`${name}:${handoff.to}`);
|
|
817
|
+
|
|
818
|
+
// Hand-carry: source just finished their stage, so temporarily release them for handoff
|
|
819
|
+
if (sourceChar && handoff.from !== handoff.to) {
|
|
820
|
+
// Release source from working state and mark as handing off
|
|
821
|
+
sourceChar.isWorking = false;
|
|
822
|
+
sourceChar.isAtDesk = false;
|
|
823
|
+
sourceChar.isHandingOff = true; // prevents idle system from hijacking
|
|
824
|
+
sourceChar.idleBehavior = null;
|
|
825
|
+
sourceChar.gossipTarget = null;
|
|
826
|
+
sourceChar.gossipLine = '';
|
|
827
|
+
sourceChar.state = 'handing_off';
|
|
828
|
+
|
|
829
|
+
// Show delivery bubble on source
|
|
830
|
+
const roleLabels = { pm: 'PM', arch: 'Architect', coder: 'Coder', deployer: 'Deployer' };
|
|
831
|
+
sourceChar.workBubble = `📋→ ${roleLabels[handoff.to] || handoff.to}`;
|
|
832
|
+
sourceChar.workBubbleDuration = 5;
|
|
833
|
+
sourceChar.workBubbleTimer = 0;
|
|
834
|
+
|
|
835
|
+
// Attach ticket to source character
|
|
836
|
+
ticket.attachTo(sourceChar);
|
|
837
|
+
|
|
838
|
+
// Source character walks to the target desk
|
|
839
|
+
sourceChar.moveTo(targetPos.x, targetPos.y);
|
|
840
|
+
|
|
841
|
+
// Track this pending handoff
|
|
842
|
+
pendingHandoffs.push({
|
|
843
|
+
projectName: name,
|
|
844
|
+
sourceChar,
|
|
845
|
+
targetChar,
|
|
846
|
+
ticket,
|
|
847
|
+
targetPos,
|
|
848
|
+
targetRole,
|
|
849
|
+
stayDuration,
|
|
850
|
+
elapsed: 0,
|
|
851
|
+
});
|
|
852
|
+
} else {
|
|
853
|
+
// Source is busy or same role — fall back to direct send
|
|
854
|
+
ticket.setRole(targetRole);
|
|
855
|
+
ticket.sendTo(targetPos, stayDuration);
|
|
856
|
+
}
|
|
857
|
+
} else {
|
|
858
|
+
// No handoff mapping or initial page load — direct send
|
|
859
|
+
ticket.setRole(targetRole);
|
|
860
|
+
ticket.sendTo(targetPos, stayDuration);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (stage === 'deploying') {
|
|
865
|
+
ticket.state = 'on_conveyor';
|
|
866
|
+
} else if (stage === 'deployed') {
|
|
867
|
+
ticket.delivered = true;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (stage) addActivity(name, stageToMessage(stage, feature));
|
|
872
|
+
|
|
873
|
+
// ── Achievements on milestones ─────────────────────────────────────────
|
|
874
|
+
if (achievementsUI && firstLoadDone && alreadySeen.has(name)) {
|
|
875
|
+
if (stage === 'deployed') {
|
|
876
|
+
achievementsUI.showUnlockAnimation({
|
|
877
|
+
id: 'first_deploy',
|
|
878
|
+
name: 'First Deploy!',
|
|
879
|
+
description: `Shipped: ${feature}`,
|
|
880
|
+
icon: '🚀',
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ── Deployed: play deploying animation first, then celebrate ───────────
|
|
886
|
+
if (stage === 'deployed' && feature) {
|
|
887
|
+
if (firstLoadDone && alreadySeen.has(name)) {
|
|
888
|
+
// Live: inject a client-side "deploying" phase (10s) before celebration
|
|
889
|
+
const deployerChar = characters.get(`${name}:deployer`);
|
|
890
|
+
if (deployerChar) {
|
|
891
|
+
deployerChar.state = 'deploying';
|
|
892
|
+
deployerChar.isWorking = true;
|
|
893
|
+
deployerChar.taskProgress = 0;
|
|
894
|
+
deployerChar.taskDuration = 10;
|
|
895
|
+
deployerChar.workBubble = '🚀 Launching to prod!';
|
|
896
|
+
deployerChar.workBubbleDuration = 9;
|
|
897
|
+
deployerChar.moveTo(deployerChar.homeX, deployerChar.homeY);
|
|
898
|
+
}
|
|
899
|
+
// Run conveyor belt for the deploy duration
|
|
900
|
+
layout.setProjectState(name, feature, 'deploying', true);
|
|
901
|
+
// Start the truck driving animation (dock → conveyor → warehouse)
|
|
902
|
+
layout.startTruckAnimation(name);
|
|
903
|
+
|
|
904
|
+
const capturedName = name;
|
|
905
|
+
const capturedFeature = feature;
|
|
906
|
+
setTimeout(() => {
|
|
907
|
+
// Celebration phase — all characters react
|
|
908
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
909
|
+
const char = characters.get(`${capturedName}:${role}`);
|
|
910
|
+
if (char) {
|
|
911
|
+
const msg = role === 'deployer' ? '🚀 SHIPPED!' :
|
|
912
|
+
role === 'pm' ? '🎉 Amazing!' :
|
|
913
|
+
role === 'arch' ? '🏆 Nailed it!' : '🥳 We did it!';
|
|
914
|
+
char.react('celebrating', msg, 5);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Fireworks + banner
|
|
918
|
+
if (effects) {
|
|
919
|
+
const origin = layout.getTeamOrigin(capturedName);
|
|
920
|
+
if (origin) effects.launchFireworks(origin.x + 160, origin.y + 80, 22);
|
|
921
|
+
effects.showBanner('🎉 SHIPPED!', `${capturedName}: ${capturedFeature.slice(0, 35)}`, 4.5);
|
|
922
|
+
}
|
|
923
|
+
layout.addShippedPackage(capturedName, capturedFeature);
|
|
924
|
+
saveShippedPackage(capturedName, capturedFeature);
|
|
925
|
+
// Mark ticket as delivered
|
|
926
|
+
const t = ticketManager.getByProject(capturedName);
|
|
927
|
+
if (t) t.delivered = true;
|
|
928
|
+
// Sound
|
|
929
|
+
if (soundFx) soundFx.playAchievement();
|
|
930
|
+
// Reset status board after celebration
|
|
931
|
+
setTimeout(() => {
|
|
932
|
+
layout.setProjectState(capturedName, '', 'idle', false);
|
|
933
|
+
prevStageMap.set(capturedName, 'idle');
|
|
934
|
+
}, 8000);
|
|
935
|
+
}, 10000);
|
|
936
|
+
} else {
|
|
937
|
+
// Page-load replay: silently add to warehouse, no animation
|
|
938
|
+
layout.addShippedPackage(name, feature);
|
|
939
|
+
saveShippedPackage(name, feature);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ── Always: idle sleep check — only after 8+ seconds of idle ───────────
|
|
945
|
+
if (!stage || stage === '' || stage === 'inbox') {
|
|
946
|
+
if (!feature) {
|
|
947
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
948
|
+
const key = `${name}:${role}`;
|
|
949
|
+
const char = characters.get(key);
|
|
950
|
+
if (char && char.state === 'idle' && !char.idleBehavior && char.stateTime > 8) {
|
|
951
|
+
char.state = 'sleeping';
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── Always: rate-limited character indicators ─────────────────────────────
|
|
958
|
+
const banned = p.status?._bannedModels || [];
|
|
959
|
+
for (const ban of banned) {
|
|
960
|
+
const model = ban.model?.toLowerCase() || '';
|
|
961
|
+
let role = null;
|
|
962
|
+
if (model.includes('gemini')) role = 'pm';
|
|
963
|
+
else if (model.includes('claude')) role = 'arch';
|
|
964
|
+
else if (model.includes('sonnet') || model.includes('opus')) role = 'coder';
|
|
965
|
+
|
|
966
|
+
if (role) {
|
|
967
|
+
const key = `${name}:${role}`;
|
|
968
|
+
const char = characters.get(key);
|
|
969
|
+
if (char && (char.state === 'idle' || char.state === 'sleeping')) {
|
|
970
|
+
char.state = 'rate_limited';
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ── Always: new feature arrives → recall idle chars home ─────────────────
|
|
976
|
+
const prevFeature = prevFeatureMap.get(name) || '';
|
|
977
|
+
if (feature && feature !== prevFeature) {
|
|
978
|
+
prevFeatureMap.set(name, feature);
|
|
979
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
980
|
+
const key = `${name}:${role}`;
|
|
981
|
+
const char = characters.get(key);
|
|
982
|
+
if (char && !char.isWorking) {
|
|
983
|
+
char.idleBehavior = null;
|
|
984
|
+
char.gossipTarget = null;
|
|
985
|
+
char.gossipLine = '';
|
|
986
|
+
char.workBubble = '';
|
|
987
|
+
char.moveTo(char.homeX, char.homeY);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
} // end for (const p of projectStates)
|
|
992
|
+
|
|
993
|
+
// Mark all current projects as seen for future celebration gating
|
|
994
|
+
for (const p of projectStates) {
|
|
995
|
+
projectSeenOnce.add(p.name);
|
|
996
|
+
}
|
|
997
|
+
firstLoadDone = true;
|
|
998
|
+
|
|
999
|
+
// Update project count
|
|
1000
|
+
const online = projectStates.filter(p => p.online).length;
|
|
1001
|
+
document.getElementById('project-count').textContent =
|
|
1002
|
+
`${online}/${projectStates.length} ONLINE`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ── Real-time event reactions (pushed instantly from Telegram/actions) ────────
|
|
1006
|
+
function onHubEvent(projectName, event, data) {
|
|
1007
|
+
// Map event → per-role reactions { role: [state, bubbleText, durationSec] }
|
|
1008
|
+
const REACTIONS = {
|
|
1009
|
+
// User actions
|
|
1010
|
+
'user_action:approve': {
|
|
1011
|
+
pm: ['celebrating', '✅ APPROVED!', 4],
|
|
1012
|
+
arch: ['celebrating', '🎉 Let\'s go!', 4],
|
|
1013
|
+
coder: ['celebrating', '🙌 YES!!!', 4],
|
|
1014
|
+
deployer: ['waiting_input', '🚛 Ready!', 4],
|
|
1015
|
+
},
|
|
1016
|
+
'user_action:reject': {
|
|
1017
|
+
pm: ['frustrated', '❌ REJECTED', 3],
|
|
1018
|
+
arch: ['idle', '😬 Oops...', 2],
|
|
1019
|
+
coder: ['fighting_bug', '😭 Again?!', 5],
|
|
1020
|
+
deployer: ['idle', '⏳ Waiting', 2],
|
|
1021
|
+
},
|
|
1022
|
+
'user_action:deploy_confirm': {
|
|
1023
|
+
deployer: ['deploying', '🚀 DEPLOYING!', 6],
|
|
1024
|
+
coder: ['celebrating', '🙏 Finally!', 4],
|
|
1025
|
+
},
|
|
1026
|
+
'user_action:feature_submitted': {
|
|
1027
|
+
pm: ['working', `📋 NEW TASK!`, 5],
|
|
1028
|
+
arch: ['idle', '👀 Incoming', 3],
|
|
1029
|
+
coder: ['idle', '☕ BRB...', 2],
|
|
1030
|
+
},
|
|
1031
|
+
// Pipeline events
|
|
1032
|
+
'feature_approved': {
|
|
1033
|
+
pm: ['celebrating', '✅ APPROVED!', 4],
|
|
1034
|
+
arch: ['celebrating', '🎉 Let\'s go!', 4],
|
|
1035
|
+
coder: ['celebrating', '🙌 YES!!!', 4],
|
|
1036
|
+
},
|
|
1037
|
+
'feature_rejected': {
|
|
1038
|
+
pm: ['frustrated', '❌ REJECTED', 3],
|
|
1039
|
+
coder: ['fighting_bug','😭 Fix time!', 5],
|
|
1040
|
+
},
|
|
1041
|
+
'deploy_success': {
|
|
1042
|
+
deployer: ['celebrating', '🚀 SHIPPED!', 5],
|
|
1043
|
+
pm: ['celebrating', '🎉 Amazing!', 4],
|
|
1044
|
+
coder: ['celebrating', '🥳 We did it!',4],
|
|
1045
|
+
},
|
|
1046
|
+
'deploy_failed': {
|
|
1047
|
+
deployer: ['frustrated', '💥 FAILED!', 4],
|
|
1048
|
+
pm: ['frustrated', '😱 Oh no!', 3],
|
|
1049
|
+
},
|
|
1050
|
+
'feature_created': {
|
|
1051
|
+
pm: ['working', '📋 NEW TASK!', 5],
|
|
1052
|
+
},
|
|
1053
|
+
'review_complete': {
|
|
1054
|
+
pm: ['waiting_input','🔍 Review done', 4],
|
|
1055
|
+
},
|
|
1056
|
+
'autopilot_toggled': {
|
|
1057
|
+
pm: ['celebrating', data?.mode === 'auto' ? '🤖 AUTO ON!' : '👋 MANUAL', 3],
|
|
1058
|
+
},
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// Support "user_action:<action>" composite key
|
|
1062
|
+
let key = event;
|
|
1063
|
+
if (event === 'user_action' && data?.action) key = `user_action:${data.action}`;
|
|
1064
|
+
|
|
1065
|
+
const reactionMap = REACTIONS[key];
|
|
1066
|
+
if (!reactionMap) return;
|
|
1067
|
+
|
|
1068
|
+
// Apply reactions only to chars in this specific project
|
|
1069
|
+
for (const [role, [state, bubble, dur]] of Object.entries(reactionMap)) {
|
|
1070
|
+
const char = characters.get(`${projectName}:${role}`);
|
|
1071
|
+
if (char) char.react(state, bubble, dur);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Sound effects for events
|
|
1075
|
+
if (soundFx) {
|
|
1076
|
+
if (key.includes('approve') || key === 'feature_approved') soundFx.playNotification();
|
|
1077
|
+
if (key.includes('reject') || key === 'feature_rejected') soundFx.playError();
|
|
1078
|
+
if (key === 'deploy_success') soundFx.playAchievement();
|
|
1079
|
+
if (key === 'deploy_failed') soundFx.playError();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Show a brief notification banner
|
|
1083
|
+
if (effects) {
|
|
1084
|
+
const labels = {
|
|
1085
|
+
'user_action:approve': '✅ User approved!',
|
|
1086
|
+
'user_action:reject': '❌ User rejected',
|
|
1087
|
+
'user_action:deploy_confirm': '🚀 Deploying...',
|
|
1088
|
+
'user_action:feature_submitted':'📋 New task submitted',
|
|
1089
|
+
'feature_approved': '✅ Approved',
|
|
1090
|
+
'feature_rejected': '❌ Rejected — fixing...',
|
|
1091
|
+
'deploy_success': '🎉 Deployed successfully!',
|
|
1092
|
+
'deploy_failed': '💥 Deploy failed',
|
|
1093
|
+
};
|
|
1094
|
+
const label = labels[key];
|
|
1095
|
+
if (label) effects.showBanner(label, `${projectName}${data?.username ? ' (by @' + data.username + ')' : ''}`, 3);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
addActivity(projectName, `[${event}]${data?.username ? ' @' + data.username : ''}`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function stageToMessage(stage, feature) {
|
|
1102
|
+
const msgs = {
|
|
1103
|
+
idle: 'All agents idle',
|
|
1104
|
+
spec: 'PM writing spec...',
|
|
1105
|
+
spec_complete: 'Spec complete!',
|
|
1106
|
+
architecture: 'Architect designing...',
|
|
1107
|
+
arch_complete: 'Architecture ready!',
|
|
1108
|
+
implementation: 'Coder implementing...',
|
|
1109
|
+
implementation_complete: 'Implementation done!',
|
|
1110
|
+
review: 'Code review in progress...',
|
|
1111
|
+
review_complete: 'Review complete',
|
|
1112
|
+
approved: 'Approved!',
|
|
1113
|
+
deploying: 'Deploying...',
|
|
1114
|
+
deployed: 'Deployed successfully!',
|
|
1115
|
+
rejected: 'Rejected - fixing bugs...',
|
|
1116
|
+
};
|
|
1117
|
+
const suffix = feature ? ` [${feature}]` : '';
|
|
1118
|
+
return (msgs[stage] || `Stage: ${stage}`) + suffix;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ── Character Creation ──────────────────────────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
function createCharacters() {
|
|
1124
|
+
characters.clear();
|
|
1125
|
+
|
|
1126
|
+
// Default team colors if config doesn't supply one
|
|
1127
|
+
const DEFAULT_TEAM_COLORS = ['#4A90D9', '#27AE60', '#E8A020', '#D04040', '#8050D0'];
|
|
1128
|
+
|
|
1129
|
+
projectConfigs.forEach((config, projIdx) => {
|
|
1130
|
+
const positions = layout.getCharacterPositions(config.name);
|
|
1131
|
+
const teamColor = config.color || DEFAULT_TEAM_COLORS[projIdx % DEFAULT_TEAM_COLORS.length];
|
|
1132
|
+
|
|
1133
|
+
for (const role of ['pm', 'arch', 'coder', 'deployer']) {
|
|
1134
|
+
const pos = positions[role] || { x: 0, y: 0 };
|
|
1135
|
+
const char = new Character(role, config.name, pos.x, pos.y);
|
|
1136
|
+
char.setHome(pos.x, pos.y);
|
|
1137
|
+
char.projectColor = teamColor;
|
|
1138
|
+
char.projectIndex = projIdx;
|
|
1139
|
+
characters.set(`${config.name}:${role}`, char);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ── UI Interactions ─────────────────────────────────────────────────────────
|
|
1145
|
+
|
|
1146
|
+
function showCharPopup(char, screenX, screenY) {
|
|
1147
|
+
const popup = document.getElementById('char-popup');
|
|
1148
|
+
if (!popup) return;
|
|
1149
|
+
|
|
1150
|
+
const info = char.getInfo();
|
|
1151
|
+
const projectState = projectStates.find(p => p.name === char.project);
|
|
1152
|
+
const projConfig = projectConfigs.find(c => c.name === char.project);
|
|
1153
|
+
const projUrl = projConfig?.url || '';
|
|
1154
|
+
|
|
1155
|
+
document.getElementById('popup-title').textContent = `${info.role} (${info.ai})`;
|
|
1156
|
+
document.getElementById('popup-project').textContent = `${projConfig?.icon || ''} ${info.project}`.trim();
|
|
1157
|
+
document.getElementById('popup-status').textContent = formatState(info.state);
|
|
1158
|
+
document.getElementById('popup-model').textContent = info.ai;
|
|
1159
|
+
document.getElementById('popup-task').textContent = projectState?.status?.current_feature || '—';
|
|
1160
|
+
document.getElementById('popup-duration').textContent = formatDuration(char.stateTime);
|
|
1161
|
+
|
|
1162
|
+
// Extra details
|
|
1163
|
+
const stage = projectState?.status?.stage || '';
|
|
1164
|
+
const extra = document.getElementById('popup-extra');
|
|
1165
|
+
if (extra) {
|
|
1166
|
+
const parts = [];
|
|
1167
|
+
if (stage) parts.push(`Stage: ${stage}`);
|
|
1168
|
+
if (info.idleBehavior) parts.push(`Idle: ${info.idleBehavior}`);
|
|
1169
|
+
if (char.workBubble) parts.push(`Thinking: ${char.workBubble}`);
|
|
1170
|
+
const rejReason = projectState?.status?.rejection_reason;
|
|
1171
|
+
if (rejReason && info.state === 'fighting_bug') parts.push(`Last reject: ${rejReason.slice(0, 40)}`);
|
|
1172
|
+
extra.textContent = parts.join(' · ');
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Show role-specific menu
|
|
1176
|
+
const allMenus = ['popup-pm-menu', 'popup-arch-menu', 'popup-coder-menu', 'popup-deployer-menu'];
|
|
1177
|
+
allMenus.forEach(id => document.getElementById(id)?.classList.add('hidden'));
|
|
1178
|
+
|
|
1179
|
+
const menuMap = { pm: 'popup-pm-menu', arch: 'popup-arch-menu', coder: 'popup-coder-menu', deployer: 'popup-deployer-menu' };
|
|
1180
|
+
const menuId = menuMap[char.role];
|
|
1181
|
+
if (menuId) document.getElementById(menuId)?.classList.remove('hidden');
|
|
1182
|
+
|
|
1183
|
+
if (char.role === 'pm') {
|
|
1184
|
+
clickedPMProject = char.project;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ── Pipeline action menu (stage-aware, shown for all roles) ──
|
|
1188
|
+
const pipelineMenu = document.getElementById('popup-pipeline-menu');
|
|
1189
|
+
if (pipelineMenu) {
|
|
1190
|
+
pipelineMenu.classList.remove('hidden');
|
|
1191
|
+
const currentStage = projectState?.status?.stage || 'idle';
|
|
1192
|
+
|
|
1193
|
+
// Define which actions are allowed per stage
|
|
1194
|
+
const stageActions = {
|
|
1195
|
+
idle: ['implement', 'cleanup', 'reset'],
|
|
1196
|
+
inbox: ['implement', 'cleanup', 'reset'],
|
|
1197
|
+
spec: ['cleanup', 'reset'],
|
|
1198
|
+
spec_complete: ['cleanup', 'reset'],
|
|
1199
|
+
architecture: ['cleanup', 'reset'],
|
|
1200
|
+
arch_complete: ['implement', 'cleanup', 'reset'],
|
|
1201
|
+
implementation: ['cleanup', 'reset'],
|
|
1202
|
+
implementation_complete: ['review', 'approve', 'reject', 'cleanup', 'reset'],
|
|
1203
|
+
review: ['cleanup', 'reset'],
|
|
1204
|
+
review_complete: ['approve', 'reject', 'fix', 'cleanup', 'reset'],
|
|
1205
|
+
approved: ['deploy', 'cleanup', 'reset'],
|
|
1206
|
+
deploying: ['cleanup', 'reset'],
|
|
1207
|
+
deployed: ['cleanup', 'reset'],
|
|
1208
|
+
rejected: ['fix', 'review', 'cleanup', 'reset'],
|
|
1209
|
+
fix: ['cleanup', 'reset'],
|
|
1210
|
+
};
|
|
1211
|
+
const allowed = new Set(stageActions[currentStage] || ['cleanup', 'reset']);
|
|
1212
|
+
|
|
1213
|
+
pipelineMenu.querySelectorAll('.btn-action').forEach(btn => {
|
|
1214
|
+
const action = btn.dataset.pipeline;
|
|
1215
|
+
if (allowed.has(action)) {
|
|
1216
|
+
btn.classList.remove('pipeline-hidden');
|
|
1217
|
+
} else {
|
|
1218
|
+
btn.classList.add('pipeline-hidden');
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
// Wire click handlers
|
|
1223
|
+
const projName = char.project;
|
|
1224
|
+
pipelineMenu.querySelectorAll('.btn-action').forEach(btn => {
|
|
1225
|
+
const originalLabel = btn.textContent;
|
|
1226
|
+
btn.onclick = async () => {
|
|
1227
|
+
const action = btn.dataset.pipeline;
|
|
1228
|
+
btn.disabled = true;
|
|
1229
|
+
btn.textContent = '⏳...';
|
|
1230
|
+
try {
|
|
1231
|
+
await sendAction(projName, action);
|
|
1232
|
+
addActivity(projName, `⚡ ${action.toUpperCase()} triggered from office UI`);
|
|
1233
|
+
hidePopups();
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
addActivity(projName, `❌ ${action} failed: ${e.message}`);
|
|
1236
|
+
} finally {
|
|
1237
|
+
btn.disabled = false;
|
|
1238
|
+
btn.textContent = originalLabel;
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Wire role-specific buttons
|
|
1245
|
+
const openLogs = () => { if (projUrl) window.open(projUrl, '_blank'); else showFeaturesPanel(char.project); };
|
|
1246
|
+
const openDocs = () => showFeaturesPanel(char.project);
|
|
1247
|
+
|
|
1248
|
+
// Architect
|
|
1249
|
+
const archDocs = document.getElementById('popup-arch-docs');
|
|
1250
|
+
const archLogs = document.getElementById('popup-arch-logs');
|
|
1251
|
+
if (archDocs) archDocs.onclick = openDocs;
|
|
1252
|
+
if (archLogs) archLogs.onclick = openLogs;
|
|
1253
|
+
|
|
1254
|
+
// Coder
|
|
1255
|
+
const coderReview = document.getElementById('popup-coder-review');
|
|
1256
|
+
const coderLogs = document.getElementById('popup-coder-logs');
|
|
1257
|
+
if (coderReview) coderReview.onclick = async () => {
|
|
1258
|
+
try {
|
|
1259
|
+
await sendAction(char.project, 'review');
|
|
1260
|
+
addActivity(char.project, '🔍 Review triggered from coder menu');
|
|
1261
|
+
hidePopups();
|
|
1262
|
+
} catch (e) { addActivity(char.project, `❌ Review failed: ${e.message}`); }
|
|
1263
|
+
};
|
|
1264
|
+
if (coderLogs) coderLogs.onclick = openLogs;
|
|
1265
|
+
|
|
1266
|
+
// Deployer
|
|
1267
|
+
const deployerDeploy = document.getElementById('popup-deployer-deploy');
|
|
1268
|
+
const deployerLogs = document.getElementById('popup-deployer-logs');
|
|
1269
|
+
if (deployerDeploy) deployerDeploy.onclick = async () => {
|
|
1270
|
+
try {
|
|
1271
|
+
await sendAction(char.project, 'deploy');
|
|
1272
|
+
addActivity(char.project, '🚀 Deploy triggered from deployer menu');
|
|
1273
|
+
hidePopups();
|
|
1274
|
+
} catch (e) { addActivity(char.project, `❌ Deploy failed: ${e.message}`); }
|
|
1275
|
+
};
|
|
1276
|
+
if (deployerLogs) deployerLogs.onclick = openLogs;
|
|
1277
|
+
|
|
1278
|
+
// Position popup
|
|
1279
|
+
const maxX = window.innerWidth - 300;
|
|
1280
|
+
const maxY = window.innerHeight - 300;
|
|
1281
|
+
popup.style.left = `${Math.min(screenX + 10, maxX)}px`;
|
|
1282
|
+
popup.style.top = `${Math.min(screenY - 10, maxY)}px`;
|
|
1283
|
+
popup.classList.remove('hidden');
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Send a pipeline action via REST API (routes through hub if available) */
|
|
1287
|
+
async function sendAction(project, action) {
|
|
1288
|
+
// Hub mode: route through hub proxy to avoid cross-origin issues
|
|
1289
|
+
if (hubBaseUrl) {
|
|
1290
|
+
const res = await fetch(`${hubBaseUrl}/api/projects/${encodeURIComponent(project)}/action/${encodeURIComponent(action)}`, {
|
|
1291
|
+
method: 'POST',
|
|
1292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1293
|
+
body: JSON.stringify({ action }),
|
|
1294
|
+
});
|
|
1295
|
+
if (!res.ok) {
|
|
1296
|
+
const err = await res.json().catch(() => ({}));
|
|
1297
|
+
throw new Error(err.error || `HTTP ${res.status}`);
|
|
1298
|
+
}
|
|
1299
|
+
return res.json();
|
|
1300
|
+
}
|
|
1301
|
+
// Standalone mode: send to local API
|
|
1302
|
+
const res = await fetch(`/api/${action}`, {
|
|
1303
|
+
method: 'POST',
|
|
1304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1305
|
+
body: JSON.stringify({ project, action }),
|
|
1306
|
+
});
|
|
1307
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1308
|
+
return res.json();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function handlePMAction(action) {
|
|
1312
|
+
hidePopups();
|
|
1313
|
+
|
|
1314
|
+
if (action === 'bug' || action === 'feature') {
|
|
1315
|
+
// Open submit modal pre-filled
|
|
1316
|
+
const modal = document.getElementById('submit-modal');
|
|
1317
|
+
const typeSelect = document.getElementById('submit-type');
|
|
1318
|
+
const projectSelect = document.getElementById('submit-project');
|
|
1319
|
+
|
|
1320
|
+
if (typeSelect) typeSelect.value = action === 'bug' ? 'bug' : 'feature';
|
|
1321
|
+
if (projectSelect && clickedPMProject) {
|
|
1322
|
+
for (const opt of projectSelect.options) {
|
|
1323
|
+
if (opt.value === clickedPMProject) {
|
|
1324
|
+
opt.selected = true;
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
modal?.classList.remove('hidden');
|
|
1330
|
+
} else if (action === 'status') {
|
|
1331
|
+
const state = projectStates.find(p => p.name === clickedPMProject);
|
|
1332
|
+
if (state) {
|
|
1333
|
+
const stage = state.status?.stage || 'idle';
|
|
1334
|
+
const feature = state.status?.current_feature || 'none';
|
|
1335
|
+
addActivity(clickedPMProject, `Status check: ${stage} | Feature: ${feature}`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Bug 4: Show package info popup
|
|
1341
|
+
function showPackagePopup(pkgInfo, screenX, screenY) {
|
|
1342
|
+
const panel = document.getElementById('features-panel');
|
|
1343
|
+
if (!panel) return;
|
|
1344
|
+
|
|
1345
|
+
const { project, pkg, index, total } = pkgInfo;
|
|
1346
|
+
const ts = new Date(pkg.time);
|
|
1347
|
+
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1348
|
+
|
|
1349
|
+
document.getElementById('fp-title').textContent = `📦 ${pkg.name}`;
|
|
1350
|
+
document.getElementById('fp-body').innerHTML =
|
|
1351
|
+
`<div class="fp-row"><span class="label">Project:</span> <span>${project}</span></div>` +
|
|
1352
|
+
`<div class="fp-row"><span class="label">Feature:</span> <span>${pkg.name}</span></div>` +
|
|
1353
|
+
`<div class="fp-row"><span class="label">Shipped:</span> <span>${timeStr}</span></div>` +
|
|
1354
|
+
`<div class="fp-row"><span class="label">Package #:</span> <span>${index + 1} of ${total}</span></div>` +
|
|
1355
|
+
`<div class="fp-row"><span class="label">Status:</span> <span style="color:#40d060">✅ DEPLOYED</span></div>`;
|
|
1356
|
+
|
|
1357
|
+
const maxX = window.innerWidth - 280;
|
|
1358
|
+
panel.style.left = `${Math.min(screenX + 10, maxX)}px`;
|
|
1359
|
+
panel.style.top = `${Math.min(screenY - 10, window.innerHeight - 220)}px`;
|
|
1360
|
+
panel.classList.remove('hidden');
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Bug 6: Show features history panel
|
|
1364
|
+
function showFeaturesPanel(projectName) {
|
|
1365
|
+
const panel = document.getElementById('features-panel');
|
|
1366
|
+
if (!panel) return;
|
|
1367
|
+
|
|
1368
|
+
const state = projectStates.find(p => p.name === projectName);
|
|
1369
|
+
const history = state?.status?.history || [];
|
|
1370
|
+
const current = state?.status?.current_feature || '';
|
|
1371
|
+
const stage = state?.status?.stage || 'idle';
|
|
1372
|
+
|
|
1373
|
+
document.getElementById('fp-title').textContent = `📋 ${projectName} — Features`;
|
|
1374
|
+
|
|
1375
|
+
const rows = [];
|
|
1376
|
+
if (current) {
|
|
1377
|
+
rows.push(`<div class="fp-row fp-current"><span class="label">🔄 Active:</span> <span>${current} [${stage}]</span></div>`);
|
|
1378
|
+
}
|
|
1379
|
+
if (history.length) {
|
|
1380
|
+
rows.push(`<div class="fp-row"><span class="label">History (${history.length}):</span></div>`);
|
|
1381
|
+
for (const h of [...history].reverse().slice(0, 12)) {
|
|
1382
|
+
const icon = h.stage === 'deployed' ? '✅' : h.stage === 'rejected' ? '❌' : '⏳';
|
|
1383
|
+
rows.push(`<div class="fp-row">${icon} <span style="font-size:9px">${h.id || h}</span></div>`);
|
|
1384
|
+
}
|
|
1385
|
+
} else {
|
|
1386
|
+
rows.push(`<div class="fp-row">No history yet.</div>`);
|
|
1387
|
+
}
|
|
1388
|
+
document.getElementById('fp-body').innerHTML = rows.join('');
|
|
1389
|
+
|
|
1390
|
+
panel.style.left = '50%';
|
|
1391
|
+
panel.style.top = '80px';
|
|
1392
|
+
panel.style.transform = 'translateX(-50%)';
|
|
1393
|
+
panel.classList.remove('hidden');
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function formatState(state) {
|
|
1397
|
+
const stateNames = {
|
|
1398
|
+
sleeping: 'Sleeping',
|
|
1399
|
+
walking: 'Walking',
|
|
1400
|
+
working: 'Working',
|
|
1401
|
+
thinking: 'Thinking',
|
|
1402
|
+
reviewing: 'Reviewing code',
|
|
1403
|
+
designing: 'Designing architecture',
|
|
1404
|
+
fighting_bug: 'Fighting bugs!',
|
|
1405
|
+
celebrating: 'Celebrating!',
|
|
1406
|
+
frustrated: 'Frustrated',
|
|
1407
|
+
rate_limited: 'Rate limited',
|
|
1408
|
+
deploying: 'Deploying',
|
|
1409
|
+
waiting_input: 'Waiting for input',
|
|
1410
|
+
handing_off: 'Handing off work',
|
|
1411
|
+
coffee_break: 'Coffee break',
|
|
1412
|
+
};
|
|
1413
|
+
return stateNames[state] || state;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function formatDuration(seconds) {
|
|
1417
|
+
if (seconds < 60) return `${Math.floor(seconds)}s`;
|
|
1418
|
+
const m = Math.floor(seconds / 60);
|
|
1419
|
+
const s = Math.floor(seconds % 60);
|
|
1420
|
+
return `${m}m ${s}s`;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function showTeamPanel(projectName, _config) {
|
|
1424
|
+
const state = projectStates.find(p => p.name === projectName);
|
|
1425
|
+
const stage = state?.status?.stage || 'idle';
|
|
1426
|
+
const feature = state?.status?.current_feature || 'none';
|
|
1427
|
+
addActivity(projectName, `Team overview: ${formatState(stage)} | ${feature}`);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function hidePopups() {
|
|
1431
|
+
document.getElementById('char-popup')?.classList.add('hidden');
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function updateProjectDropdown() {
|
|
1435
|
+
const select = document.getElementById('submit-project');
|
|
1436
|
+
if (!select) return;
|
|
1437
|
+
select.innerHTML = '';
|
|
1438
|
+
for (const config of projectConfigs) {
|
|
1439
|
+
const opt = document.createElement('option');
|
|
1440
|
+
opt.value = config.name;
|
|
1441
|
+
opt.textContent = `${config.icon || ''} ${config.name}`.trim();
|
|
1442
|
+
select.appendChild(opt);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function submitFeature() {
|
|
1447
|
+
const project = document.getElementById('submit-project')?.value;
|
|
1448
|
+
const type = document.getElementById('submit-type')?.value;
|
|
1449
|
+
const mode = document.getElementById('submit-mode')?.value;
|
|
1450
|
+
const desc = document.getElementById('submit-desc')?.value?.trim();
|
|
1451
|
+
|
|
1452
|
+
if (!project || !desc) return;
|
|
1453
|
+
|
|
1454
|
+
try {
|
|
1455
|
+
// Hub mode: route to the specific project via hub
|
|
1456
|
+
let featureUrl = '/api/feature';
|
|
1457
|
+
if (hubBaseUrl) {
|
|
1458
|
+
featureUrl = `${hubBaseUrl}/api/projects/${encodeURIComponent(project)}/feature`;
|
|
1459
|
+
}
|
|
1460
|
+
const res = await fetch(featureUrl, {
|
|
1461
|
+
method: 'POST',
|
|
1462
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1463
|
+
body: JSON.stringify({ description: desc, mode, type }),
|
|
1464
|
+
});
|
|
1465
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1466
|
+
addActivity(project, `Submitted ${type}: ${desc.slice(0, 60)}...`);
|
|
1467
|
+
document.getElementById('submit-modal')?.classList.add('hidden');
|
|
1468
|
+
document.getElementById('submit-desc').value = '';
|
|
1469
|
+
if (soundFx) soundFx.playNotification();
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
addActivity(project, `Submit failed: ${e.message}`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ── Activity Feed ───────────────────────────────────────────────────────────
|
|
1476
|
+
|
|
1477
|
+
function addActivity(project, message) {
|
|
1478
|
+
const now = new Date();
|
|
1479
|
+
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
|
1480
|
+
|
|
1481
|
+
activityLog.unshift({ time, project, message });
|
|
1482
|
+
if (activityLog.length > 50) activityLog.length = 50;
|
|
1483
|
+
|
|
1484
|
+
const container = document.getElementById('feed-entries');
|
|
1485
|
+
if (!container) return;
|
|
1486
|
+
|
|
1487
|
+
const entry = document.createElement('div');
|
|
1488
|
+
entry.className = 'feed-entry';
|
|
1489
|
+
entry.innerHTML = `<span class="time">[${time}]</span> <span class="project">${project}:</span> <span class="msg">${message}</span>`;
|
|
1490
|
+
container.insertBefore(entry, container.firstChild);
|
|
1491
|
+
|
|
1492
|
+
while (container.children.length > 30) {
|
|
1493
|
+
container.removeChild(container.lastChild);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// ── Slash Command System ────────────────────────────────────────────────────
|
|
1498
|
+
|
|
1499
|
+
const SLASH_COMMANDS = [
|
|
1500
|
+
{ cmd: '/status', icon: '\u{1F4CA}', desc: 'Show pipeline status' },
|
|
1501
|
+
{ cmd: '/feature', icon: '\u{2728}', desc: 'Submit a new feature' },
|
|
1502
|
+
{ cmd: '/approve', icon: '\u{2705}', desc: 'Approve current feature' },
|
|
1503
|
+
{ cmd: '/reject', icon: '\u{274C}', desc: 'Reject current feature' },
|
|
1504
|
+
{ cmd: '/deploy', icon: '\u{1F680}', desc: 'Deploy approved feature' },
|
|
1505
|
+
{ cmd: '/reset', icon: '\u{1F504}', desc: 'Reset pipeline to idle' },
|
|
1506
|
+
{ cmd: '/review', icon: '\u{1F50D}', desc: 'Trigger code review' },
|
|
1507
|
+
{ cmd: '/health', icon: '\u{1F49A}', desc: 'Check system health' },
|
|
1508
|
+
{ cmd: '/costs', icon: '\u{1F4B0}', desc: 'Show token costs' },
|
|
1509
|
+
{ cmd: '/help', icon: '\u{2753}', desc: 'Show all commands' },
|
|
1510
|
+
];
|
|
1511
|
+
|
|
1512
|
+
let cmdSelectedIndex = 0;
|
|
1513
|
+
|
|
1514
|
+
function initCommandBar() {
|
|
1515
|
+
const bar = document.getElementById('command-bar');
|
|
1516
|
+
const input = document.getElementById('command-input');
|
|
1517
|
+
const dropdown = document.getElementById('command-dropdown');
|
|
1518
|
+
if (!bar || !input || !dropdown) return;
|
|
1519
|
+
|
|
1520
|
+
// Global "/" key opens command bar (only when not already in a text input)
|
|
1521
|
+
document.addEventListener('keydown', (e) => {
|
|
1522
|
+
if (e.key === '/' && !isInTextInput(e.target)) {
|
|
1523
|
+
e.preventDefault();
|
|
1524
|
+
showCommandBar();
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
// Input events
|
|
1529
|
+
input.addEventListener('input', () => {
|
|
1530
|
+
const val = input.value;
|
|
1531
|
+
if (val.startsWith('/')) {
|
|
1532
|
+
const filtered = filterCommands(val);
|
|
1533
|
+
renderDropdown(filtered);
|
|
1534
|
+
} else {
|
|
1535
|
+
hideDropdown();
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
input.addEventListener('keydown', (e) => {
|
|
1540
|
+
const items = dropdown.querySelectorAll('.command-dropdown-item');
|
|
1541
|
+
|
|
1542
|
+
if (e.key === 'Escape') {
|
|
1543
|
+
e.preventDefault();
|
|
1544
|
+
hideCommandBar();
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (e.key === 'ArrowDown') {
|
|
1549
|
+
e.preventDefault();
|
|
1550
|
+
cmdSelectedIndex = Math.min(cmdSelectedIndex + 1, items.length - 1);
|
|
1551
|
+
updateActiveItem(items);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (e.key === 'ArrowUp') {
|
|
1556
|
+
e.preventDefault();
|
|
1557
|
+
cmdSelectedIndex = Math.max(cmdSelectedIndex - 1, 0);
|
|
1558
|
+
updateActiveItem(items);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (e.key === 'Enter') {
|
|
1563
|
+
e.preventDefault();
|
|
1564
|
+
const activeItem = items[cmdSelectedIndex];
|
|
1565
|
+
if (activeItem) {
|
|
1566
|
+
executeSlashCommand(activeItem.dataset.cmd);
|
|
1567
|
+
} else {
|
|
1568
|
+
// Try to match typed text directly
|
|
1569
|
+
const val = input.value.trim().toLowerCase();
|
|
1570
|
+
const match = SLASH_COMMANDS.find(c => c.cmd === val);
|
|
1571
|
+
if (match) executeSlashCommand(match.cmd);
|
|
1572
|
+
}
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (e.key === 'Tab') {
|
|
1577
|
+
e.preventDefault();
|
|
1578
|
+
const activeItem = items[cmdSelectedIndex];
|
|
1579
|
+
if (activeItem) {
|
|
1580
|
+
input.value = activeItem.dataset.cmd;
|
|
1581
|
+
const filtered = filterCommands(input.value);
|
|
1582
|
+
renderDropdown(filtered);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
// Click outside closes command bar
|
|
1588
|
+
document.addEventListener('mousedown', (e) => {
|
|
1589
|
+
if (bar.style.display !== 'none' && !bar.contains(e.target)) {
|
|
1590
|
+
hideCommandBar();
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function isInTextInput(el) {
|
|
1596
|
+
const tag = el.tagName?.toLowerCase();
|
|
1597
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function showCommandBar() {
|
|
1601
|
+
const bar = document.getElementById('command-bar');
|
|
1602
|
+
const input = document.getElementById('command-input');
|
|
1603
|
+
bar.style.display = 'block';
|
|
1604
|
+
input.value = '/';
|
|
1605
|
+
input.focus();
|
|
1606
|
+
// Set cursor to end
|
|
1607
|
+
input.setSelectionRange(1, 1);
|
|
1608
|
+
const filtered = filterCommands('/');
|
|
1609
|
+
renderDropdown(filtered);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function hideCommandBar() {
|
|
1613
|
+
const bar = document.getElementById('command-bar');
|
|
1614
|
+
const input = document.getElementById('command-input');
|
|
1615
|
+
bar.style.display = 'none';
|
|
1616
|
+
input.value = '';
|
|
1617
|
+
hideDropdown();
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function filterCommands(text) {
|
|
1621
|
+
const query = text.slice(1).toLowerCase(); // remove leading "/"
|
|
1622
|
+
if (!query) return SLASH_COMMANDS;
|
|
1623
|
+
return SLASH_COMMANDS.filter(c =>
|
|
1624
|
+
c.cmd.slice(1).includes(query) || c.desc.toLowerCase().includes(query)
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function renderDropdown(commands) {
|
|
1629
|
+
const dropdown = document.getElementById('command-dropdown');
|
|
1630
|
+
if (commands.length === 0) {
|
|
1631
|
+
dropdown.innerHTML = '<div class="command-dropdown-hint">NO MATCHING COMMANDS</div>';
|
|
1632
|
+
dropdown.classList.add('visible');
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
cmdSelectedIndex = 0;
|
|
1637
|
+
dropdown.innerHTML = commands.map((c, i) =>
|
|
1638
|
+
`<div class="command-dropdown-item${i === 0 ? ' active' : ''}" data-cmd="${c.cmd}">
|
|
1639
|
+
<span class="cmd-icon">${c.icon}</span>
|
|
1640
|
+
<div class="cmd-info">
|
|
1641
|
+
<span class="cmd-name">${c.cmd}</span>
|
|
1642
|
+
<span class="cmd-desc">${c.desc}</span>
|
|
1643
|
+
</div>
|
|
1644
|
+
</div>`
|
|
1645
|
+
).join('') + '<div class="command-dropdown-hint">ENTER to select / ESC to close</div>';
|
|
1646
|
+
|
|
1647
|
+
dropdown.classList.add('visible');
|
|
1648
|
+
|
|
1649
|
+
// Click handlers for dropdown items
|
|
1650
|
+
dropdown.querySelectorAll('.command-dropdown-item').forEach(item => {
|
|
1651
|
+
item.addEventListener('click', () => {
|
|
1652
|
+
executeSlashCommand(item.dataset.cmd);
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function hideDropdown() {
|
|
1658
|
+
const dropdown = document.getElementById('command-dropdown');
|
|
1659
|
+
dropdown.classList.remove('visible');
|
|
1660
|
+
dropdown.innerHTML = '';
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function updateActiveItem(items) {
|
|
1664
|
+
items.forEach((item, i) => {
|
|
1665
|
+
item.classList.toggle('active', i === cmdSelectedIndex);
|
|
1666
|
+
});
|
|
1667
|
+
// Scroll active item into view
|
|
1668
|
+
const active = items[cmdSelectedIndex];
|
|
1669
|
+
if (active) active.scrollIntoView({ block: 'nearest' });
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function executeSlashCommand(cmd) {
|
|
1673
|
+
hideCommandBar();
|
|
1674
|
+
|
|
1675
|
+
// Determine which project to target (first available or clicked PM project)
|
|
1676
|
+
const project = clickedPMProject || projectConfigs[0]?.name || 'default';
|
|
1677
|
+
|
|
1678
|
+
switch (cmd) {
|
|
1679
|
+
case '/feature': {
|
|
1680
|
+
// Open the submit modal for feature input
|
|
1681
|
+
const modal = document.getElementById('submit-modal');
|
|
1682
|
+
const typeSelect = document.getElementById('submit-type');
|
|
1683
|
+
if (typeSelect) typeSelect.value = 'feature';
|
|
1684
|
+
modal?.classList.remove('hidden');
|
|
1685
|
+
// Focus the description field
|
|
1686
|
+
setTimeout(() => document.getElementById('submit-desc')?.focus(), 100);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
case '/help': {
|
|
1691
|
+
// Show all commands in activity feed
|
|
1692
|
+
addActivity('System', '--- SLASH COMMANDS ---');
|
|
1693
|
+
for (const c of SLASH_COMMANDS) {
|
|
1694
|
+
addActivity('System', `${c.icon} ${c.cmd} - ${c.desc}`);
|
|
1695
|
+
}
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
case '/status': {
|
|
1700
|
+
// Show status for all projects
|
|
1701
|
+
if (projectStates.length === 0) {
|
|
1702
|
+
addActivity('System', 'No projects connected');
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
for (const state of projectStates) {
|
|
1706
|
+
const stage = state.status?.stage || 'idle';
|
|
1707
|
+
const feature = state.status?.current_feature || 'none';
|
|
1708
|
+
addActivity(state.name, `Status: ${stage} | Feature: ${feature}`);
|
|
1709
|
+
}
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
case '/health': {
|
|
1714
|
+
// Fetch health from API
|
|
1715
|
+
try {
|
|
1716
|
+
const url = hubBaseUrl ? `${hubBaseUrl}/api/health` : '/api/health';
|
|
1717
|
+
const res = await fetch(url);
|
|
1718
|
+
const data = await res.json();
|
|
1719
|
+
addActivity('System', `Health: ${JSON.stringify(data).slice(0, 120)}`);
|
|
1720
|
+
} catch (e) {
|
|
1721
|
+
addActivity('System', `Health check failed: ${e.message}`);
|
|
1722
|
+
}
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
case '/costs': {
|
|
1727
|
+
// Fetch costs from API
|
|
1728
|
+
try {
|
|
1729
|
+
const url = hubBaseUrl ? `${hubBaseUrl}/api/costs` : '/api/costs';
|
|
1730
|
+
const res = await fetch(url);
|
|
1731
|
+
const data = await res.json();
|
|
1732
|
+
const total = data.totalCost != null ? `$${data.totalCost.toFixed(4)}` : JSON.stringify(data).slice(0, 100);
|
|
1733
|
+
addActivity('System', `Token costs: ${total}`);
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
addActivity('System', `Costs fetch failed: ${e.message}`);
|
|
1736
|
+
}
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// Simple action commands: approve, reject, deploy, reset, review
|
|
1741
|
+
default: {
|
|
1742
|
+
const action = cmd.slice(1); // remove "/"
|
|
1743
|
+
try {
|
|
1744
|
+
addActivity(project, `${cmd} triggered from command bar`);
|
|
1745
|
+
await sendAction(project, action);
|
|
1746
|
+
addActivity(project, `${cmd} completed`);
|
|
1747
|
+
if (soundFx) soundFx.playNotification();
|
|
1748
|
+
} catch (e) {
|
|
1749
|
+
addActivity(project, `${cmd} failed: ${e.message}`);
|
|
1750
|
+
}
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// ── Boot ────────────────────────────────────────────────────────────────────
|
|
1757
|
+
document.addEventListener('DOMContentLoaded', init);
|