coworking-agents 0.7.0 → 0.9.0
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/CHANGELOG.md +20 -0
- package/README.md +7 -1
- package/docs/achievements.png +0 -0
- package/docs/building.png +0 -0
- package/docs/camera.png +0 -0
- package/docs/christmas.png +0 -0
- package/docs/customise.png +0 -0
- package/docs/game-room.png +0 -0
- package/docs/halloween.png +0 -0
- package/docs/house-wall.png +0 -0
- package/docs/mobile.png +0 -0
- package/docs/office-day.png +0 -0
- package/docs/office-night.png +0 -0
- package/docs/panel.png +0 -0
- package/docs/paper.png +0 -0
- package/docs/servers.png +0 -0
- package/docs/talk.png +0 -0
- package/docs/top-bar.png +0 -0
- package/docs/trophy-room.png +0 -0
- package/docs/usage.png +0 -0
- package/docs/work-site.png +0 -0
- package/package.json +1 -1
- package/public/index.html +4 -0
- package/public/js/alerts.js +1 -0
- package/public/js/archive.js +61 -0
- package/public/js/i18n.js +31 -4
- package/public/js/panel.js +2 -0
- package/public/js/report.js +25 -3
- package/public/js/scene.js +26 -16
- package/public/js/talkdesk.js +106 -0
- package/public/js/tasks.js +42 -5
- package/public/js/timelapse.js +97 -0
- package/public/js/topbar.js +3 -1
- package/public/js/watches.js +74 -0
- package/public/phone/phone.css +17 -0
- package/public/phone/phone.js +64 -12
- package/public/phone/phone.webmanifest +0 -1
- package/public/style.css +72 -0
- package/src/collect.js +6 -2
- package/src/demo.js +29 -2
- package/src/pace.js +32 -0
- package/src/phone.js +34 -11
- package/src/progress.js +9 -2
- package/src/said.js +96 -1
- package/src/server.js +29 -3
- package/src/sources/codex.js +1 -1
- package/src/update.js +2 -2
- package/src/usage.js +16 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — 2026-10-14
|
|
4
|
+
- Tell me when… (☰ → ⏰, or the agent's panel): a notice when an agent finishes (once), when a limit passes a level you pick (again after it resets), or when any agent edits a file whose path has some text; in the office, the bell and as a system notification. Kept with your progress
|
|
5
|
+
- Past conversations (☰ → 🗄): every Claude Code and Codex conversation on this computer from the last 60 days, open or closed, found by name, folder or what was said (last 30 days), and read as a chat with its helpers and a search inside it. Read only; off in private mode
|
|
6
|
+
- A new release shows up in an office left open within about 10 minutes (it checked once an hour), and the releases board, the admin's sign and the notice follow as soon as the office knows
|
|
7
|
+
|
|
8
|
+
## 0.8.0 — 2026-10-14
|
|
9
|
+
- The office wall: frames, screens and windows spread evenly between the light switch and the whiteboard (same gap between each), a quiet wallpaper pattern and a chair rail with wainscot instead of plain stripes; sunlight comes in only through real windows
|
|
10
|
+
- Everyone in the office: click the agent count under the office name for every agent with its state and the helpers working for it; an agent opens its panel, a helper opens its conversation
|
|
11
|
+
- Top bar: under the office name, how many agents are in the office, with "+N 🤖" while helpers (subagents) work (hover for who); the bar keeps its size; the phone shows the same in its header and on each agent's card
|
|
12
|
+
- Conversation (computer and phone): every agent's helpers (Claude subagents, Codex child threads) listed above the chat, with a dot on the ones still working; pick one to read its own conversation, from the request it got to its answer, with the same search
|
|
13
|
+
- Conversation in the side panel: click an agent, then *Conversation* for the whole conversation as a chat, a search inside it, and a box to write to it when it finished its turn
|
|
14
|
+
- Tasks as a board (Tasks → *Board*): queued, running, waiting on you, done; drag a card to retry, conclude or cancel it
|
|
15
|
+
- When you spend (Costs and limits): output tokens per weekday and hour over the last 28 days, with the peak; and under each limit, at the measured pace of the last hour, when it would run out or that it lasts until it resets
|
|
16
|
+
- Day timelapse (☰ → 🎞): a picture of the office every 5 minutes while it is open, drawn without names, turned into a ~20 s GIF made in the browser; kept only in this browser for 3 days
|
|
17
|
+
- Fix: a stalled Read or Edit no longer shows as waiting for your approval in the modes that never ask for them (auto, accept edits, don't ask, bypass), nor a quick tool waiting on a command running beside it
|
|
18
|
+
- Phone app updates faster: the live link pings every 5 s (a quiet office no longer looks offline and falls back to polling), reopens at once when the app comes back from the background, and new messages reach the open conversation with each change
|
|
19
|
+
- Phone app: an agent's whole conversation as a chat (its own tab next to the summary), with a search inside it that also reaches older messages, and the message box at the bottom. Only what you wrote and what it answered; no tool output, reminders or command echoes; off in private mode
|
|
20
|
+
- Phone home-screen icon opens with its code: the code link is the page itself (no redirect) and the manifest has no fixed start, so the icon keeps the link it was added from (iOS gives a home-screen app its own cookies)
|
|
21
|
+
- Phone app keeps working when the Wi-Fi hands the computer a new address: the link uses the computer's own .local name (Bonjour), and the phone server moves to the new address by itself (same port and code). The address link stays shown for phones that don't resolve the name
|
|
22
|
+
|
|
3
23
|
## 0.7.0 — 2026-10-13
|
|
4
24
|
- Phone app, live: the office pushes every change as it happens (the 3-second poll only runs while the stream is down)
|
|
5
25
|
- Phone app: tap an agent to see it in full: what it is asking or doing now, your last request, its last answer, what it did, the files it touched, helpers, model, context, cost (texts hidden in private mode)
|
package/README.md
CHANGED
|
@@ -224,6 +224,12 @@ The office works in any browser. If you prefer an app:
|
|
|
224
224
|
- **Office events and mood:** confetti for tests passing, a PR opened or a week under the cost goal; rain when tests or a task fail; the room turns grey-blue when many agents are stuck on you and warm when everything flows.
|
|
225
225
|
- **Files touched most** (Repositories): what agent sessions edited most this week.
|
|
226
226
|
- **🖥 Tray icon** (Personalise or `--menubar`): how many are working, who needs you (red), a small bar with the fullest token limit (`●2 ▰▰▱▱▱ 41%`), today's spend, the limits, who is working on what and tasks asking permission; clicking opens the office on that agent or task, and it notifies you when nobody has the office open (outside quiet hours). macOS uses JavaScript for Automation, Windows uses PowerShell and Windows Forms, Linux uses `zenity` (icon and tooltip) or `notify-send` (notices only) — all part of the system or one package away. It reads a private status file the office writes and goes away when the office closes.
|
|
227
|
+
- **💬 Conversation** (click an agent): the whole conversation as a chat, a search inside it, and a box to write to it once it finished its turn. Its helpers (subagents) are listed above the chat; pick one to read its own conversation. Only what you wrote and what it answered; off in private mode.
|
|
228
|
+
- **▦ Task board** (Tasks → Board): queued, running, waiting on you and done; drag a card to retry, conclude or cancel it.
|
|
229
|
+
- **When you spend** (Costs and limits): a weekday × hour map of output tokens over the last 28 days, and under each limit when it would run out at the pace of the last hour.
|
|
230
|
+
- **⏰ Tell me when…:** a notice when an agent finishes (also a button in its panel), when a limit passes a level you pick, or when any agent edits a file you name.
|
|
231
|
+
- **🗄 Past conversations:** every Claude Code and Codex conversation from the last 60 days, open or closed, found by name, folder or what was said, and read as a chat with its helpers. Read only.
|
|
232
|
+
- **🎞 Day timelapse:** a picture of the office every 5 minutes (no names), turned into a ~20 s GIF in your browser. The pictures stay in this browser for 3 days.
|
|
227
233
|
- **📺 TV mode** (or `?tv=1`): full screen, only the office and big counters; Esc leaves.
|
|
228
234
|
- **Waiting on you today** in the daily report: measured time the agents waited on your answers and tasks stood blocked on you.
|
|
229
235
|
- **Sounds and do not disturb** (Personalise): each sound on or off, and a daily quiet window that mutes sounds and system notifications.
|
|
@@ -233,7 +239,7 @@ The office works in any browser. If you prefer an app:
|
|
|
233
239
|
`npx coworking-agents --phone` also serves a small app for your phone on the same Wi-Fi, in the office's pixel look, updated live as things change. Scan the QR code printed in the terminal or shown in ☰ → 📱 Phone, then add it to the home screen.
|
|
234
240
|
|
|
235
241
|
- **Now:** who needs you and every agent with what it is doing.
|
|
236
|
-
- **Tap an agent** to see it in full: what it asks or does right now, your last request, its last answer, what it did, the files it touched, its helpers, model, context and cost. When it finished its turn you can **write to it**; the text is typed into its terminal, like the office does (not into the Claude app or VS Code).
|
|
242
|
+
- **Tap an agent** to see it in full: what it asks or does right now, your last request, its last answer, what it did, the files it touched, its helpers, model, context and cost. The **Conversation** tab shows the whole conversation as a chat, with a search inside it (only what you wrote and what it answered, never tool output). When it finished its turn you can **write to it**; the text is typed into its terminal, like the office does (not into the Claude app or VS Code).
|
|
237
243
|
- **Tasks:** answer permission questions (*Allow* / *Deny*), reply, conclude, retry or cancel, **see the changes** (summary and coloured diff) and start a new task in a folder already in use.
|
|
238
244
|
- **Office:** a picture of the office drawn without names. **Usage:** today's and the month's spend and the limits.
|
|
239
245
|
|
package/docs/achievements.png
CHANGED
|
Binary file
|
package/docs/building.png
CHANGED
|
Binary file
|
package/docs/camera.png
CHANGED
|
Binary file
|
package/docs/christmas.png
CHANGED
|
Binary file
|
package/docs/customise.png
CHANGED
|
Binary file
|
package/docs/game-room.png
CHANGED
|
Binary file
|
package/docs/halloween.png
CHANGED
|
Binary file
|
package/docs/house-wall.png
CHANGED
|
Binary file
|
package/docs/mobile.png
CHANGED
|
Binary file
|
package/docs/office-day.png
CHANGED
|
Binary file
|
package/docs/office-night.png
CHANGED
|
Binary file
|
package/docs/panel.png
CHANGED
|
Binary file
|
package/docs/paper.png
CHANGED
|
Binary file
|
package/docs/servers.png
CHANGED
|
Binary file
|
package/docs/talk.png
CHANGED
|
Binary file
|
package/docs/top-bar.png
CHANGED
|
Binary file
|
package/docs/trophy-room.png
CHANGED
|
Binary file
|
package/docs/usage.png
CHANGED
|
Binary file
|
package/docs/work-site.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coworking-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A pixel-art coworking office for your AI coding agents (Claude Code, Codex, …): see who's working, who needs you, and who's editing the same checkout.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"coworking-agents": "bin/coworking-agents.js"
|
package/public/index.html
CHANGED
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
<script src="js/views.js"></script>
|
|
83
83
|
<script src="js/fun2.js"></script>
|
|
84
84
|
<script src="js/tasks.js"></script>
|
|
85
|
+
<script src="js/talkdesk.js"></script>
|
|
86
|
+
<script src="js/timelapse.js"></script>
|
|
87
|
+
<script src="js/archive.js"></script>
|
|
88
|
+
<script src="js/watches.js"></script>
|
|
85
89
|
<script src="js/main.js"></script>
|
|
86
90
|
</body>
|
|
87
91
|
</html>
|
package/public/js/alerts.js
CHANGED
|
@@ -101,6 +101,7 @@ function checkAlerts() {
|
|
|
101
101
|
if (up && up.latest && !alerted.has('upd:' + up.latest)) {
|
|
102
102
|
alerted.add('upd:' + up.latest);
|
|
103
103
|
toast(`<b>${esc(A.update(up.latest))}</b>${esc(A.updateHow)} <code>npx coworking-agents@latest</code>`, 'ok sticky');
|
|
104
|
+
if (typeof loadVersion === 'function') loadVersion(); // the releases board, the admin's sign and the notice follow at once
|
|
104
105
|
}
|
|
105
106
|
firstAlertPass = false;
|
|
106
107
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ---------------- the conversation archive ----------------
|
|
3
|
+
// Every conversation on disk, open or closed (the last 60 days): find one by name, folder or what was said,
|
|
4
|
+
// then read it as a chat, with its helpers and a search inside it. Nothing here writes anything.
|
|
5
|
+
let arcList = null, arcQ = '', arcOpen = null, arcTalk = null, arcTalkQ = '', arcSub = '', arcTimer = 0;
|
|
6
|
+
async function arcLoad() {
|
|
7
|
+
const r = await api('api/archive?q=' + encodeURIComponent(arcQ));
|
|
8
|
+
arcList = r; if (reportTab === 'archive' && !arcOpen) arcDrawList();
|
|
9
|
+
}
|
|
10
|
+
async function arcLoadTalk(toTop) {
|
|
11
|
+
const o = arcOpen; if (!o) return;
|
|
12
|
+
const r = await api(`api/archive-talk?agent=${encodeURIComponent(o.agent)}&id=${encodeURIComponent(o.id)}&q=${encodeURIComponent(arcTalkQ)}&sub=${encodeURIComponent(arcSub)}`);
|
|
13
|
+
if (arcOpen !== o) return;
|
|
14
|
+
if (r.helpers) o.helpers = r.helpers;
|
|
15
|
+
arcTalk = r; const list = document.getElementById('arc-chat'); if (list) list.innerHTML = arcChatHtml();
|
|
16
|
+
const hb = document.getElementById('arc-helpers'); if (hb) hb.innerHTML = arcHelpersHtml();
|
|
17
|
+
const box = reportEl.querySelector('.report-body'); if (box && !toTop && !arcTalkQ) box.scrollTop = box.scrollHeight;
|
|
18
|
+
}
|
|
19
|
+
const arcWhen = ms => new Date(ms).toLocaleString(lang === 'pt' ? 'pt-BR' : 'en', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
20
|
+
function arcChatHtml() {
|
|
21
|
+
const C = T.chat, A = T.archive, r = arcTalk, q = arcTalkQ.toLowerCase(), o = arcOpen, h = (o.helpers || []).find(x => x.id === arcSub);
|
|
22
|
+
if (!r) return `<p class="note">…</p>`;
|
|
23
|
+
if (!r.ok) return `<p class="note">⚠ ${esc(C.fail[r.reason] || r.reason || '?')}</p>`;
|
|
24
|
+
if (!r.items.length) return `<p class="note">${esc(q ? C.none : C.empty)}</p>`;
|
|
25
|
+
return (q ? `<p class="note">${esc(C.found(r.found))}</p>` : r.cut || r.total > r.items.length ? `<p class="note">${esc(C.cut)}</p>` : '')
|
|
26
|
+
+ r.items.map(m => `<div class="bubble ${m.role === 'user' ? 'me' : 'it'}"><p>${deskMd(deskMark(m.text, q))}</p><small>${m.role === 'user' ? esc(h ? A.mainAgent : C.you) : esc(h ? h.type : o.agent === 'codex' ? 'Codex' : 'Claude')}${m.at ? ' · ' + esc(arcWhen(m.at)) : ''}</small></div>`).join('');
|
|
27
|
+
}
|
|
28
|
+
function arcHelpersHtml() {
|
|
29
|
+
const C = T.chat, hs = (arcOpen && arcOpen.helpers) || []; if (!hs.length) return '';
|
|
30
|
+
return `<div class="phelpers"><button class="${arcSub ? '' : 'on'}" data-arc-sub="">${esc(C.main)}</button>${hs.map(h => `<button class="${h.id === arcSub ? 'on' : ''}" data-arc-sub="${esc(h.id)}" title="${esc(h.what)}">${esc(h.type)}</button>`).join('')}</div>`;
|
|
31
|
+
}
|
|
32
|
+
function arcDrawList() {
|
|
33
|
+
const A = T.archive, body = reportEl.querySelector('.report-body'), r = arcList, live = new Set(data ? data.people.map(p => p.id) : []);
|
|
34
|
+
const row = x => `<li><button class="arc-row" data-arc-open="${esc(x.agent)}|${esc(x.id)}"><i class="ailogo" data-ai="${esc(x.agent)}"></i><span class="arc-txt"><b>${esc(x.title || A.untitled)}</b><small>${x.cwd ? esc(x.cwd) + ' · ' : ''}${esc(arcWhen(x.at))}${live.has(x.id) ? ' · ● ' + esc(A.open) : ''}</small>${x.first ? `<small class="arc-first">${esc(x.first)}</small>` : ''}</span></button></li>`;
|
|
35
|
+
const focus = document.activeElement && document.activeElement.matches('[data-arc-q]');
|
|
36
|
+
body.innerHTML = tabsHtml() + `<h2>🗄 ${esc(A.title)}</h2><p class="sub">${esc(A.sub)}</p>
|
|
37
|
+
<input type="search" class="ptalk-q" data-arc-q placeholder="${esc(A.search)}" value="${esc(arcQ)}">
|
|
38
|
+
${!r ? `<p class="note">…</p>` : !r.ok ? `<p class="note">⚠ ${esc(T.chat.fail[r.reason] || r.reason)}</p>` : `
|
|
39
|
+
<h3>${esc(A.list(r.items.length, r.total))}</h3>${r.items.length ? `<ul class="arc-list">${r.items.map(row).join('')}</ul>` : `<p class="note">${esc(A.none)}</p>`}
|
|
40
|
+
${r.hits && r.hits.length ? `<h3>${esc(A.said(r.hits.length))}</h3><ul class="arc-list">${r.hits.map(x => `<li><button class="arc-row" data-arc-open="${esc(x.agent)}|${esc(x.id)}" data-arc-q-in="${esc(arcQ)}"><i class="ailogo" data-ai="${esc(x.agent)}"></i><span class="arc-txt"><small>${esc(x.role === 'user' ? T.chat.you : x.agent === 'codex' ? 'Codex' : 'Claude')} · ${esc(arcWhen(x.at))}</small><span>${deskMark(x.snippet, arcQ.toLowerCase())}</span></span></button></li>`).join('')}</ul>` : ''}`}`;
|
|
41
|
+
body.querySelectorAll('.ailogo[data-ai]').forEach(el => { const id = el.dataset.ai; el.appendChild(Art.aiIconEl(id, (AGENT[id] || {}).color || '#888')); });
|
|
42
|
+
if (focus) { const i = body.querySelector('[data-arc-q]'); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
|
|
43
|
+
}
|
|
44
|
+
function arcDrawTalk() {
|
|
45
|
+
const A = T.archive, body = reportEl.querySelector('.report-body'), o = arcOpen;
|
|
46
|
+
body.innerHTML = tabsHtml() + `<p><button class="tback" data-arc-back>${esc(A.back)}</button></p><h2>${esc(o.title || A.untitled)}</h2><p class="sub">${esc(o.agent === 'codex' ? 'Codex' : 'Claude Code')}${o.cwd ? ' · ' + esc(o.cwd) : ''}${o.at ? ' · ' + esc(arcWhen(o.at)) : ''}</p>
|
|
47
|
+
<div id="arc-helpers">${arcHelpersHtml()}</div><input type="search" class="ptalk-q" data-arc-tq placeholder="${esc(T.chat.search)}" value="${esc(arcTalkQ)}"><div id="arc-chat" class="arc-chat">${arcChatHtml()}</div>`;
|
|
48
|
+
}
|
|
49
|
+
function renderArchive() { if (arcOpen) arcDrawTalk(); else { arcDrawList(); if (!arcList) arcLoad(); } }
|
|
50
|
+
reportEl.addEventListener('click', e => {
|
|
51
|
+
if (reportTab !== 'archive') return;
|
|
52
|
+
const op = e.target.closest('[data-arc-open]');
|
|
53
|
+
if (op) { const [agent, id] = op.dataset.arcOpen.split('|'), x = (arcList && arcList.items.find(i => i.id === id)) || { agent, id, title: '' }; arcOpen = { ...x, helpers: [] }; arcTalk = null; arcSub = ''; arcTalkQ = op.dataset.arcQIn || ''; arcDrawTalk(); arcLoadTalk(!!arcTalkQ); return; }
|
|
54
|
+
if (e.target.closest('[data-arc-back]')) { arcOpen = null; arcTalk = null; arcDrawList(); return; }
|
|
55
|
+
const sb = e.target.closest('[data-arc-sub]'); if (sb) { arcSub = sb.dataset.arcSub; arcTalk = null; const c = document.getElementById('arc-chat'); if (c) c.innerHTML = '<p class="note">…</p>'; arcLoadTalk(true); }
|
|
56
|
+
});
|
|
57
|
+
reportEl.addEventListener('input', e => {
|
|
58
|
+
if (reportTab !== 'archive') return;
|
|
59
|
+
if (e.target.matches('[data-arc-q]')) { clearTimeout(arcTimer); arcTimer = setTimeout(() => { arcQ = e.target.value.trim().slice(0, 100); arcLoad(); }, 400); }
|
|
60
|
+
if (e.target.matches('[data-arc-tq]')) { clearTimeout(arcTimer); arcTimer = setTimeout(() => { arcTalkQ = e.target.value.trim().slice(0, 100); arcLoadTalk(true); }, 350); }
|
|
61
|
+
});
|
package/public/js/i18n.js
CHANGED
|
@@ -595,10 +595,37 @@ Object.assign(I18N.en, { clashPause: t => `Pause “${t}”`, clashPauseSure: 'P
|
|
|
595
595
|
Object.assign(I18N.en, { phone: { diagApp: 'Running as a full-screen app ✓', diagSafari: 'Running inside Safari (not as an app): add it to the Home Screen with “Open as Web App” on', diagScreen: 'screen', diagPage: 'page', diagInsets: 'inset top / bottom', tabNow: 'Now', tabTasks: 'Tasks', tabOffice: 'Office', tabUsage: 'Usage', agents: 'Agents', noAgents: 'No agent open.', justNow: 'now', newTask: 'New task', newPh: 'What should the agent do?', noFolders: 'Open an agent or create a task on the computer first: the phone only creates tasks in folders already in use.', working: n => `${n} working`, needCount: n => `${n} need${n === 1 ? 's' : ''} you`, idleCount: n => `${n} idle`, officeAlt: 'The office now, without names', officeNote: t => `Picture ${t === 'now' ? 'from just now' : 'from ' + t + ' ago'}, without names. It refreshes while the office is open on the computer.`, noFrame: 'Open the office on the computer to see its picture here.', today: 'spent today', month: 'spent this month', goal: g => `Month's goal (${g})`, limits: 'Limits', noLimits: 'No limit data yet.', fiveH: '5 hours', week: 'week', resets: t => `resets in ${t}`, estimate: 'Estimates at API prices, from the logs on the computer.', homeTitle: 'As an app on the phone', homeIos: 'iPhone: open the link in Safari → Share → Add to Home Screen.', homeAndroid: 'Android: open it in Chrome → ⋮ menu → Add to Home screen (or Install app).', homeNote: 'The icon opens full screen, like an app, whenever the phone is on the same Wi-Fi and the office runs with --phone. The code is kept, so no need to scan again.', rotate: 'Change the phone code', rotateSure: 'Change the code? Every phone will have to scan the QR again.', status: (w, n) => `${w} working · ${n} need you`, needYou: 'Need you', nothing: 'Nobody is waiting on you right now.', tasks: 'Tasks', noTasks: 'No open tasks.', openOffice: 'This question has to be answered on the computer.', offline: 'no connection to the office', done: 'done', fail: { demo: 'demo mode', gone: 'the session closed', busy: 'it is no longer waiting', text: 'invalid option', state: 'not possible now', notty: 'could not find the terminal', unsupported: 'cannot answer from here', nodir: 'folder not allowed', nodesc: 'write what to do', attempts: 'no attempts left' },
|
|
596
596
|
title: 'Phone', how: 'Start the office with --phone to see tasks and answer permissions from your phone, on the same Wi-Fi.', scan: 'Point your phone camera at the code (same Wi-Fi).', warn: 'Trusted networks only: the local network connection is not encrypted.' } });
|
|
597
597
|
// the phone's live view: one agent in full, writing to it, a task's changes
|
|
598
|
-
Object.assign(I18N.pt.phone, { back: 'Voltar', agentGone: 'Esta sessão fechou.', asking: 'Pedindo agora', doingNow: 'Fazendo agora', lastAsk: 'Seu último pedido', lastReply: 'Última resposta', msgPh: 'Escreva para o agente…', msgSend: 'Enviar', msgOnlyIdle: 'Dá para escrever quando ele terminar a vez (no terminal; o app do Claude e o VS Code não recebem texto de fora).', recent: 'O que fez', files: 'Arquivos', helpers: 'Ajudantes', info: 'Detalhes', model: 'Modelo', ctx: 'Contexto', cost: 'Custo', turns: 'Turnos', open: 'Aberto há', folder: 'Pasta', diff: 'Ver mudanças', diffHide: 'Esconder mudanças', noDiff: 'Nada mudou ainda.', live: 'ao vivo' });
|
|
599
|
-
Object.assign(I18N.pt.phone.fail, { desktop: 'é o app do Claude: não recebe texto de fora', notforeground: 'o agente não está na frente no terminal', notfound: 'não achei a janela do terminal', platform: 'só funciona no Mac', private: 'modo privado', notask: 'a tarefa sumiu', unknown: 'erro' });
|
|
600
|
-
Object.assign(I18N.en.phone, { back: 'Back', agentGone: 'This session closed.', asking: 'Asking now', doingNow: 'Doing now', lastAsk: 'Your last request', lastReply: 'Last answer', msgPh: 'Write to the agent…', msgSend: 'Send', msgOnlyIdle: 'You can write once it finishes its turn (in a terminal; the Claude app and VS Code take no text from outside).', recent: 'What it did', files: 'Files', helpers: 'Helpers', info: 'Details', model: 'Model', ctx: 'Context', cost: 'Cost', turns: 'Turns', open: 'Open for', folder: 'Folder', diff: 'See changes', diffHide: 'Hide changes', noDiff: 'Nothing changed yet.', live: 'live' });
|
|
601
|
-
Object.assign(I18N.en.phone.fail, { desktop: 'it is the Claude app: it takes no text from outside', notforeground: 'the agent is not in front in its terminal', notfound: 'could not find the terminal window', platform: 'only works on a Mac', private: 'private mode', notask: 'the task is gone', unknown: 'error' });
|
|
598
|
+
Object.assign(I18N.pt.phone, { main: 'Principal', helperOf: t => `Ajudante ${t}`, summaryTab: 'Resumo', talkTab: 'Conversa', talkSearch: 'Buscar na conversa', talkFound: n => `${n} mensage${n === 1 ? 'm encontrada' : 'ns encontradas'}`, talkEmpty: 'Nada na conversa ainda.', talkNone: 'Nada encontrado.', talkCut: 'Mostrando as mensagens mais recentes; a busca procura no resto.', you: 'Você', ipAlt: 'O link usa o nome do computador na rede, que continua valendo quando o Wi-Fi troca o endereço. Se o celular não abrir pelo nome (alguns Android), use o endereço de agora:', back: 'Voltar', agentGone: 'Esta sessão fechou.', asking: 'Pedindo agora', doingNow: 'Fazendo agora', lastAsk: 'Seu último pedido', lastReply: 'Última resposta', msgPh: 'Escreva para o agente…', msgSend: 'Enviar', msgOnlyIdle: 'Dá para escrever quando ele terminar a vez (no terminal; o app do Claude e o VS Code não recebem texto de fora).', recent: 'O que fez', files: 'Arquivos', helpers: 'Ajudantes', info: 'Detalhes', model: 'Modelo', ctx: 'Contexto', cost: 'Custo', turns: 'Turnos', open: 'Aberto há', folder: 'Pasta', diff: 'Ver mudanças', diffHide: 'Esconder mudanças', noDiff: 'Nada mudou ainda.', live: 'ao vivo' });
|
|
599
|
+
Object.assign(I18N.pt.phone.fail, { nofile: 'não achei o registro da conversa', desktop: 'é o app do Claude: não recebe texto de fora', notforeground: 'o agente não está na frente no terminal', notfound: 'não achei a janela do terminal', platform: 'só funciona no Mac', private: 'modo privado', notask: 'a tarefa sumiu', unknown: 'erro' });
|
|
600
|
+
Object.assign(I18N.en.phone, { main: 'Main', helperOf: t => `Helper ${t}`, summaryTab: 'Summary', talkTab: 'Conversation', talkSearch: 'Search the conversation', talkFound: n => `${n} message${n === 1 ? '' : 's'} found`, talkEmpty: 'Nothing in the conversation yet.', talkNone: 'Nothing found.', talkCut: 'Showing the latest messages; the search looks through the rest.', you: 'You', ipAlt: 'The link uses the computer’s name on the network, which keeps working when the Wi-Fi hands out a new address. If the phone does not open it by name (some Android phones), use the current address:', back: 'Back', agentGone: 'This session closed.', asking: 'Asking now', doingNow: 'Doing now', lastAsk: 'Your last request', lastReply: 'Last answer', msgPh: 'Write to the agent…', msgSend: 'Send', msgOnlyIdle: 'You can write once it finishes its turn (in a terminal; the Claude app and VS Code take no text from outside).', recent: 'What it did', files: 'Files', helpers: 'Helpers', info: 'Details', model: 'Model', ctx: 'Context', cost: 'Cost', turns: 'Turns', open: 'Open for', folder: 'Folder', diff: 'See changes', diffHide: 'Hide changes', noDiff: 'Nothing changed yet.', live: 'live' });
|
|
601
|
+
Object.assign(I18N.en.phone.fail, { nofile: 'could not find the conversation log', desktop: 'it is the Claude app: it takes no text from outside', notforeground: 'the agent is not in front in its terminal', notfound: 'could not find the terminal window', platform: 'only works on a Mac', private: 'private mode', notask: 'the task is gone', unknown: 'error' });
|
|
602
|
+
// an agent's whole conversation in the side panel
|
|
603
|
+
Object.assign(I18N.pt, { chat: { main: 'Principal', helpers: n => `${n} ajudante${n === 1 ? '' : 's'} (subagentes): toque para ver a conversa de cada um.`, helperOf: t => `Ajudante ${t}`, summary: 'Resumo', talk: 'Conversa', search: 'Buscar na conversa', found: n => `${n} mensage${n === 1 ? 'm encontrada' : 'ns encontradas'}`, empty: 'Nada na conversa ainda.', none: 'Nada encontrado.', cut: 'Mostrando as mensagens mais recentes; a busca procura no resto.', you: 'Você', write: 'Escreva para o agente…', send: 'Enviar', onlyIdle: 'Dá para escrever quando ele terminar a vez (no terminal; o app do Claude e o VS Code não recebem texto de fora). Use “Perguntar a este agente” no resumo para os outros.', fail: { gone: 'a sessão fechou', nofile: 'não achei o registro da conversa', private: 'modo privado', busy: 'ele está trabalhando; espere terminar', unsupported: 'não dá para escrever nesta sessão', notty: 'não achei o terminal', notforeground: 'o agente não está na frente no terminal', notfound: 'não achei a janela do terminal', desktop: 'é o app do Claude: não recebe texto de fora', platform: 'só funciona no Mac', text: 'texto inválido', demo: 'modo demonstração', unknown: 'erro' } } });
|
|
604
|
+
Object.assign(I18N.en, { chat: { main: 'Main', helpers: n => `${n} helper${n === 1 ? '' : 's'} (subagents): click one to see its conversation.`, helperOf: t => `Helper ${t}`, summary: 'Summary', talk: 'Conversation', search: 'Search the conversation', found: n => `${n} message${n === 1 ? '' : 's'} found`, empty: 'Nothing in the conversation yet.', none: 'Nothing found.', cut: 'Showing the latest messages; the search looks through the rest.', you: 'You', write: 'Write to the agent…', send: 'Send', onlyIdle: 'You can write once it finishes its turn (in a terminal; the Claude app and VS Code take no text from outside). Use “Ask this agent” in the summary for the others.', fail: { gone: 'the session closed', nofile: 'could not find the conversation log', private: 'private mode', busy: 'it is working; wait for it to finish', unsupported: 'cannot write to this session', notty: 'could not find the terminal', notforeground: 'the agent is not in front in its terminal', notfound: 'could not find the terminal window', desktop: 'it is the Claude app: it takes no text from outside', platform: 'only works on a Mac', text: 'invalid text', demo: 'demo mode', unknown: 'error' } } });
|
|
605
|
+
// tasks as a board
|
|
606
|
+
Object.assign(I18N.pt.tasks, { board: { list: 'Lista', board: 'Quadro', hint: 'Arraste um cartão: de “Esperando você” para “Rodando” tenta de novo, para “Concluídas” conclui; da fila ou de “Rodando” para “Concluídas” cancela. Clique para abrir.', cols: { queue: 'Na fila', running: 'Rodando', you: 'Esperando você', done: 'Concluídas' } } });
|
|
607
|
+
Object.assign(I18N.en.tasks, { board: { list: 'List', board: 'Board', hint: 'Drag a card: from “Waiting on you” to “Running” retries it, to “Done” concludes it; from the queue or “Running” to “Done” cancels it. Click to open.', cols: { queue: 'Queued', running: 'Running', you: 'Waiting on you', done: 'Done' } } });
|
|
608
|
+
// usage map and the pace of the limits
|
|
609
|
+
Object.assign(I18N.pt, { heat: { title: 'Quando você gasta', peak: (d, h, day) => `Pico: ${d} às ${h}h. O dia que mais gasta: ${day}.`, note: n => `Tokens de saída por dia da semana e hora, nos últimos ${n} dias. Passe o mouse para ver o valor.`, endsAt: (t, r) => `Nesse ritmo (${r} pontos/h) acaba às ${t}, antes de renovar`, lasts: r => `Nesse ritmo (${r} pontos/h) dura até renovar`, steady: 'Parado ou quase: dura até renovar' } });
|
|
610
|
+
Object.assign(I18N.en, { heat: { title: 'When you spend', peak: (d, h, day) => `Peak: ${d} at ${h}:00. Busiest day: ${day}.`, note: n => `Output tokens per weekday and hour over the last ${n} days. Hover for the value.`, endsAt: (t, r) => `At this pace (${r} points/h) it runs out at ${t}, before it resets`, lasts: r => `At this pace (${r} points/h) it lasts until it resets`, steady: 'Flat or nearly: it lasts until it resets' } });
|
|
611
|
+
// the day's timelapse
|
|
612
|
+
Object.assign(I18N.pt.officeObj.menu, { timelapse: 'Timelapse do dia' });
|
|
613
|
+
Object.assign(I18N.en.officeObj.menu, { timelapse: 'Day timelapse' });
|
|
614
|
+
Object.assign(I18N.pt, { timelapse: { title: 'Timelapse do dia', sub: 'Uma foto do escritório a cada 5 minutos enquanto ele está aberto, sem nomes, branches nem nome do escritório. Vira um GIF de uns 20 segundos.', today: n => `Gerar o GIF de hoje (${n} foto${n === 1 ? '' : 's'})`, yesterday: n => `De ontem (${n})`, now: 'Tirar uma foto agora', reading: 'Lendo as fotos…', making: 'Montando o GIF…', few: 'Poucas fotos ainda: deixe o escritório aberto um pouco mais.', alt: 'O dia no escritório, acelerado', save: 'Salvar GIF', info: (n, s) => `${n} fotos · ${s}`, note: 'As fotos ficam só neste navegador (os últimos 3 dias) e nunca saem do computador.' } });
|
|
615
|
+
Object.assign(I18N.en, { timelapse: { title: 'Day timelapse', sub: 'A picture of the office every 5 minutes while it is open, without names, branches or the office name. It becomes a GIF of about 20 seconds.', today: n => `Make today's GIF (${n} picture${n === 1 ? '' : 's'})`, yesterday: n => `Yesterday's (${n})`, now: 'Take a picture now', reading: 'Reading the pictures…', making: 'Making the GIF…', few: 'Too few pictures yet: leave the office open a while longer.', alt: 'The day in the office, sped up', save: 'Save GIF', info: (n, s) => `${n} pictures · ${s}`, note: 'The pictures stay in this browser only (the last 3 days) and never leave the computer.' } });
|
|
616
|
+
// how many agents and helpers are in the office
|
|
617
|
+
Object.assign(I18N.pt, { crew: { title: 'Todos no escritório', sub: (a, h) => `${a} agente${a === 1 ? '' : 's'} e ${h} ajudante${h === 1 ? '' : 's'} (subagentes) trabalhando agora.`, note: 'Clique num agente para abrir o painel dele, ou num ajudante para ler a conversa dele. Os ajudantes que já terminaram ficam na aba Conversa do agente.', agents: n => n === 1 ? 'agente' : 'agentes', helpers: n => n === 1 ? 'ajudante' : 'ajudantes', none: 'Nenhum ajudante (subagente) trabalhando agora.', status: n => `${n} ajudante${n === 1 ? '' : 's'}` } });
|
|
618
|
+
Object.assign(I18N.en, { crew: { title: 'Everyone in the office', sub: (a, h) => `${a} agent${a === 1 ? '' : 's'} and ${h} helper${h === 1 ? '' : 's'} (subagents) working right now.`, note: 'Click an agent to open its panel, or a helper to read its conversation. Helpers that finished stay in the agent\'s Conversation tab.', agents: n => n === 1 ? 'agent' : 'agents', helpers: n => n === 1 ? 'helper' : 'helpers', none: 'No helpers (subagents) working right now.', status: n => `${n} helper${n === 1 ? '' : 's'}` } });
|
|
619
|
+
// the conversation archive
|
|
620
|
+
Object.assign(I18N.pt.officeObj.menu, { archive: 'Conversas antigas' });
|
|
621
|
+
Object.assign(I18N.en.officeObj.menu, { archive: 'Past conversations' });
|
|
622
|
+
Object.assign(I18N.pt, { archive: { title: 'Conversas antigas', sub: 'Todas as conversas do Claude Code e do Codex guardadas no computador nos últimos 60 dias, abertas ou já fechadas. Só leitura.', search: 'Buscar pelo nome, pela pasta ou pelo que foi dito', said: n => `No que foi dito (últimos 30 dias): ${n}`, list: (n, t) => `Conversas: ${n}${t > n ? ' de ' + t : ''}`, none: 'Nenhuma conversa encontrada.', untitled: '(sem título)', open: 'aberta agora', back: '‹ Voltar', mainAgent: 'agente principal' } });
|
|
623
|
+
Object.assign(I18N.en, { archive: { title: 'Past conversations', sub: 'Every Claude Code and Codex conversation kept on this computer over the last 60 days, open or closed. Read only.', search: 'Search by name, folder or what was said', said: n => `In what was said (last 30 days): ${n}`, list: (n, t) => `Conversations: ${n}${t > n ? ' of ' + t : ''}`, none: 'No conversation found.', untitled: '(untitled)', open: 'open now', back: '‹ Back', mainAgent: 'main agent' } });
|
|
624
|
+
// "tell me when…"
|
|
625
|
+
Object.assign(I18N.pt.officeObj.menu, { watches: 'Me avise quando…' });
|
|
626
|
+
Object.assign(I18N.en.officeObj.menu, { watches: 'Tell me when…' });
|
|
627
|
+
Object.assign(I18N.pt, { watch: { title: 'Me avise quando…', sub: 'Avisos que você escolhe. Chegam aqui, no sino e como notificação do sistema.', empty: 'Nenhum aviso ainda.', add: 'Adicionar', addDone: 'Quando um agente terminar', addLimit: 'Quando um limite passar de', addFile: 'Quando algum agente mexer num arquivo', filePh: 'parte do caminho, ex.: checkout.ts ou src/auth', note: 'Funcionam enquanto o escritório estiver aberto. O de agente vale uma vez; o de limite volta a valer quando o limite renova; o de arquivo avisa a cada edição nova.', whenDone: 'Me avise quando terminar', cancelDone: 'Cancelar o aviso', set: 'Aviso criado', whenDoneOf: n => `Quando ${n} terminar`, whenLimit: (l, p) => `Quando ${l} passar de ${p}%`, whenFile: t => `Quando algum agente mexer em “${t}”`, done: n => `${n} terminou`, needs: n => `${n} parou e precisa de você`, closed: n => `${n} fechou`, limit: (l, p) => `${l} chegou a ${p}%`, limitWhy: p => `Você pediu aviso a partir de ${p}%.`, file: (n, f) => `${n} mexeu em ${f}` } });
|
|
628
|
+
Object.assign(I18N.en, { watch: { title: 'Tell me when…', sub: 'Notices you choose. They show here, in the bell and as system notifications.', empty: 'No notices yet.', add: 'Add', addDone: 'When an agent finishes', addLimit: 'When a limit passes', addFile: 'When any agent touches a file', filePh: 'part of the path, e.g. checkout.ts or src/auth', note: 'They work while the office is open. The agent one fires once; the limit one again after the limit resets; the file one on every new edit.', whenDone: 'Tell me when it finishes', cancelDone: 'Cancel the notice', set: 'Notice set', whenDoneOf: n => `When ${n} finishes`, whenLimit: (l, p) => `When ${l} passes ${p}%`, whenFile: t => `When any agent touches “${t}”`, done: n => `${n} finished`, needs: n => `${n} stopped and needs you`, closed: n => `${n} closed`, limit: (l, p) => `${l} reached ${p}%`, limitWhy: p => `You asked to be told from ${p}%.`, file: (n, f) => `${n} edited ${f}` } });
|
|
602
629
|
let T = I18N[lang];
|
|
603
630
|
// sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
|
|
604
631
|
let soundOn = store.get('sound', true);
|
package/public/js/panel.js
CHANGED
|
@@ -78,6 +78,7 @@ function renderPanel() {
|
|
|
78
78
|
const p = data.people.find(x => x.id === selected);
|
|
79
79
|
if (!p) { selected = null; panel.hidden = true; return; }
|
|
80
80
|
if (panelBody.contains(document.activeElement) && /^(TEXTAREA|INPUT|SELECT)$/.test(document.activeElement.tagName)) return; // don't wipe what is being typed
|
|
81
|
+
if (typeof deskTalkOn === 'function' && deskTalkOn(p)) return; // the Conversation view (talkdesk.js)
|
|
81
82
|
const col = KIND_COLOR[p.state] || '#888';
|
|
82
83
|
const ctxMax = p.ctxMax || 0, ctxPct = ctxMax ? Math.min(1, p.ctx / ctxMax) : 0;
|
|
83
84
|
const certs = certsOf(p);
|
|
@@ -126,6 +127,7 @@ function renderPanel() {
|
|
|
126
127
|
${tools.length ? `<h3>${P.tools}</h3><ul class="bars">${tools.map(([k, n]) => `<li><code>${esc(k)}</code><span class="tbar"><i style="width:${(n / toolMax * 100).toFixed(1)}%"></i></span><span class="t">${Number(n) || 0}</span></li>`).join('')}</ul>` : ''}
|
|
127
128
|
<h3 id="certs-h">${P.certs}</h3>${certs.length ? `<div class="certs">${certs.map(c => `<div class="cert" style="--c:${certColor(c)}"><small>${T.kinds[c.kind]}</small>${esc(c.name)}${c.kind === 'badge' ? `<br><span class="note">${esc(c.desc)}</span>` : ` <span class="note">${Number(c.n) || 0}×</span>`}</div>`).join('')}</div>` : `<p class="note">${P.noCerts}</p>`}
|
|
128
129
|
<p class="note" style="margin-top:18px">${P.session}: <code>${esc(p.id)}</code></p>`;
|
|
130
|
+
if (typeof deskTabsHtml === 'function') panelBody.querySelector('header.ph').insertAdjacentHTML('afterend', deskTabsHtml() + (typeof watchBtnHtml === 'function' ? watchBtnHtml(p) : ''));
|
|
129
131
|
const face = Art.portrait(p.id, p.state === 'needs_you' ? 'open' : null);
|
|
130
132
|
const fb = panelBody.querySelector('.ph-face');
|
|
131
133
|
fb.appendChild(face);
|
package/public/js/report.js
CHANGED
|
@@ -146,6 +146,23 @@ reportEl.addEventListener('click', async e => {
|
|
|
146
146
|
});
|
|
147
147
|
function closeReport() { reportEl.hidden = true; }
|
|
148
148
|
|
|
149
|
+
// how fast a limit is filling and when it runs out at that pace (measured over the last hour; nothing until there is enough)
|
|
150
|
+
function paceHtml(u, id, key) {
|
|
151
|
+
const H = T.heat, x = u && u.pace && u.pace[id] && u.pace[id][key]; if (!x) return '';
|
|
152
|
+
const at = ms => new Date(ms).toLocaleTimeString(lang === 'pt' ? 'pt-BR' : 'en', { hour: '2-digit', minute: '2-digit' }) + (new Date(ms).toDateString() === new Date().toDateString() ? '' : ' ' + new Date(ms).toLocaleDateString(lang === 'pt' ? 'pt-BR' : 'en', { weekday: 'short' }));
|
|
153
|
+
return x.fullAt ? `<small class="pace hot">⏱ ${esc(H.endsAt(at(x.fullAt), x.perHour))}</small>` : `<small class="pace">✓ ${esc(x.perHour > 0.5 ? H.lasts(x.perHour) : H.steady)}</small>`;
|
|
154
|
+
}
|
|
155
|
+
// when the tokens go: weekday × hour over the last 28 days, darker = more output tokens
|
|
156
|
+
function heatHtml(u) {
|
|
157
|
+
const H = T.heat, g = u && u.heat && u.heat.grid; if (!g) return '';
|
|
158
|
+
const max = Math.max(1, ...g.flat().map(c => c.out)); if (max <= 1) return '';
|
|
159
|
+
const days = lang === 'pt' ? ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], order = [1, 2, 3, 4, 5, 6, 0];
|
|
160
|
+
let best = null; for (const d of order) for (let h = 0; h < 24; h++) if (!best || g[d][h].out > best.out) best = { d, h, out: g[d][h].out };
|
|
161
|
+
const byDay = order.map(d => ({ d, out: g[d].reduce((n, c) => n + c.out, 0) })).sort((a, b) => b.out - a.out)[0];
|
|
162
|
+
const rows = order.map(d => `<div class="hrow"><span>${esc(days[d])}</span>${g[d].map((c, h) => `<i style="--a:${c.out ? (0.12 + 0.88 * c.out / max).toFixed(2) : 0}" title="${esc(days[d])} ${h}h · ${esc(fmtK(c.out))} tokens${c.usd ? ' · ' + esc(fmtUSD(c.usd)) : ''}"></i>`).join('')}</div>`).join('');
|
|
163
|
+
return `<h3>${esc(H.title)}</h3><p class="note">${esc(H.peak(days[best.d], best.h, days[byDay.d]))}</p>
|
|
164
|
+
<div class="heat">${rows}<div class="hrow axis"><span></span>${Array.from({ length: 24 }, (_, h) => `<em>${h % 3 ? '' : h}</em>`).join('')}</div></div><p class="note">${esc(H.note(u.heat.days))}</p>`;
|
|
165
|
+
}
|
|
149
166
|
function renderReport() {
|
|
150
167
|
if (reportTab === 'tasks') return renderTasks();
|
|
151
168
|
if (reportTab === 'notices') return renderNotices();
|
|
@@ -155,6 +172,10 @@ function renderReport() {
|
|
|
155
172
|
if (reportTab === 'broadcast') return renderBroadcast();
|
|
156
173
|
if (reportTab === 'today') return renderToday();
|
|
157
174
|
if (reportTab === 'feed') return renderFeed();
|
|
175
|
+
if (reportTab === 'timelapse') return renderTimelapse();
|
|
176
|
+
if (reportTab === 'crew') return renderCrew();
|
|
177
|
+
if (reportTab === 'archive') return renderArchive();
|
|
178
|
+
if (reportTab === 'watches') return renderWatches();
|
|
158
179
|
if (reportTab === 'ach') return renderAchievements();
|
|
159
180
|
if (reportTab === 'paper') return renderPaper();
|
|
160
181
|
if (reportTab === 'queue') return renderQueue();
|
|
@@ -181,16 +202,17 @@ function renderReport() {
|
|
|
181
202
|
<div class="axis">${days.map((k, i) => `<span>${i % 2 ? '' : k.slice(8) + '/' + k.slice(5, 7)}</span>`).join('')}</div>
|
|
182
203
|
<div class="legend">${Object.keys(AGENT).map(id => `<span><i class="ailogo" data-ai="${esc(id)}"></i>${esc(AGENT[id].label)}</span>`).join('')}</div>`;
|
|
183
204
|
// limits first: one big tile per window, the most urgent thing on the page
|
|
184
|
-
const limTile = (id, label, w) => {
|
|
205
|
+
const limTile = (id, label, w, key) => {
|
|
185
206
|
if (!w) return '';
|
|
186
207
|
const pct = Math.max(0, Math.min(100, w.usedPercent)), c = pct > 85 ? '#e43b44' : pct > 60 ? '#feae34' : '#63c74d';
|
|
187
|
-
return `<div class="ltile" style="--c:${c}"><div class="ltop"><span style="color:${AGENT[id].color}"><i class="ailogo" data-ai="${esc(id)}"></i>${esc(AGENT[id].label)}</span><small>${esc(label)}</small></div><b>${Math.round(pct)}%</b><div class="lbar"><i style="width:${pct}%"></i></div><small>${esc(U.resets)} ${esc(untilText(w.resetsAt))}</small
|
|
208
|
+
return `<div class="ltile" style="--c:${c}"><div class="ltop"><span style="color:${AGENT[id].color}"><i class="ailogo" data-ai="${esc(id)}"></i>${esc(AGENT[id].label)}</span><small>${esc(label)}</small></div><b>${Math.round(pct)}%</b><div class="lbar"><i style="width:${pct}%"></i></div><small>${esc(U.resets)} ${esc(untilText(w.resetsAt))}</small>${paceHtml(u, id, key)}</div>`;
|
|
188
209
|
};
|
|
189
|
-
const tiles = Object.keys(AGENT).flatMap(id => { const l = L[id]; return l ? [limTile(id, U.fiveHour, l.primary), limTile(id, U.week, l.secondary)] : []; }).join('');
|
|
210
|
+
const tiles = Object.keys(AGENT).flatMap(id => { const l = L[id]; return l ? [limTile(id, U.fiveHour, l.primary, '5h'), limTile(id, U.week, l.secondary, 'week')] : []; }).join('');
|
|
190
211
|
const total = id => u && u.byAgent && u.byAgent[id] ? u.byAgent[id].total : null;
|
|
191
212
|
body.innerHTML = tabsHtml() + `<h2 hidden>${esc(U.title)}</h2><p class="sub">${esc(U.reportSub)}${u && u.scannedAt ? ' · ' + esc(U.updated) + ' ' + esc(ago(Date.now() - u.scannedAt)) : ''}</p>
|
|
192
213
|
<h3>${esc(U.limits)}</h3><div class="ltiles">${tiles || `<p class="note">${esc(U.none)}</p>`}</div>
|
|
193
214
|
${costGoalHtml(u)}
|
|
215
|
+
${heatHtml(u)}
|
|
194
216
|
<h3>${esc(U.last14)}</h3><div class="chart-wrap"><span class="ymax">${tok(max)}</span>${chart}</div>
|
|
195
217
|
<h3>${esc(U.allTime)}</h3><div class="rgrid">${Object.keys(AGENT).map(card).join('')}</div>
|
|
196
218
|
<p class="note" style="margin-top:14px">${esc(U.note)} ${esc(U.costNote)}</p>`;
|
package/public/js/scene.js
CHANGED
|
@@ -58,25 +58,35 @@ function drawUsageBoard(x, y, t) {
|
|
|
58
58
|
const skyHour = () => styleHour(qs.get('hour') ? Number(qs.get('hour')) : new Date().getHours() + new Date().getMinutes() / 60);
|
|
59
59
|
function drawWall(t, sky) {
|
|
60
60
|
r(0, 0, W, TOP - 6, PAL.wall);
|
|
61
|
-
|
|
61
|
+
// wallpaper: a quiet dotted diamond pattern, then a chair rail with wood wainscot below the frames
|
|
62
|
+
for (let y = 8, k = 0; y < TOP - 16; y += 6, k++) for (let x = (k % 2) * 6; x < W; x += 12) r(x, y, 1, 1, PAL.wallShade);
|
|
62
63
|
r(0, 0, W, 3, PAL.wallTrim); r(0, 3, W, 1, PAL.ink2);
|
|
64
|
+
r(0, TOP - 14, W, 2, PAL.wallTrim); r(0, TOP - 12, W, 1, PAL.wallShade); r(0, TOP - 11, W, 3, shade(PAL.wall, .93));
|
|
65
|
+
for (let x = 18; x < W; x += 36) r(x, TOP - 11, 1, 3, PAL.wallShade);
|
|
63
66
|
r(0, TOP - 8, W, 2, PAL.wallShade); r(0, TOP - 6, W, 5, PAL.base); r(0, TOP - 6, W, 1, '#8f553f'); r(0, TOP - 1, W, 1, PAL.ink);
|
|
64
67
|
windowBoxes = []; releaseBox = historyBox = costBox = noticesBox = null;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// what hangs on the wall, chosen by how much wall there is (as many 74 px slots as fit), then spread evenly
|
|
69
|
+
// between the light switch and the whiteboard so the gaps match
|
|
70
|
+
const nWin = Math.max(1, Math.floor((CX - 30) / 74)), items = [];
|
|
71
|
+
let extra = 0; // slots after the agent of the month become the costs screen and the notice board
|
|
68
72
|
for (let i = 0; i < nWin; i++) {
|
|
69
|
-
|
|
70
|
-
if (i === 1) drawUsageBoard(wx - 6, 7, t);
|
|
73
|
+
if (i === 1) items.push({ w: 112, draw: x => drawUsageBoard(x, 7, t) });
|
|
71
74
|
else if (i === 2) continue; // covered by the usage screen
|
|
72
|
-
else if (i === 3 && nWin >= 5)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
else if (i === 3 && nWin >= 5) items.push({ w: 84, draw: x => { // the dev corner: two posters around the neon </>
|
|
76
|
+
Art.drawPoster(x, 14, 0); Art.drawPoster(x + 62, 14, 2); releaseBox = { x: x - 1, y: 13, w: 22, h: 28 }; historyBox = { x: x + 61, y: 13, w: 22, h: 28 };
|
|
77
|
+
Art.drawNeon(x + 25, 10, Date.now() - neonFlash < 400 ? (((Date.now() / 60) | 0) % 2 ? t : 120 * 47) : t, wallGlows); neonBox = { x: x + 23, y: 8, w: 34, h: 18 };
|
|
78
|
+
if (typeof drawReleaseBadge === 'function') drawReleaseBadge(x, 14, t);
|
|
79
|
+
} });
|
|
80
|
+
else if (i === 4 && nWin >= 7) items.push({ w: 64, draw: x => drawTrophyShelf(x, 9, 64, t) });
|
|
81
|
+
else if (i === 5) items.push(nWin >= 7 ? { w: 52, draw: x => drawAgentOfMonth(x, 10, t, wallGlows) } : { w: 22, draw: x => Art.drawPoster(x, 12, 1) });
|
|
82
|
+
else if (i >= 6 && extra < 2 && typeof drawCostScreen === 'function') items.push(extra++ === 0 ? { w: 48, draw: x => { drawCostScreen(x, 9, 48, 30, t); costBox = { x: x - 1, y: 8, w: 50, h: 32 }; } } : { w: 48, draw: x => { drawNoticeCork(x, 9, 48, 30, t); noticesBox = { x: x - 1, y: 8, w: 50, h: 32 }; } });
|
|
83
|
+
else items.push({ w: 48, draw: x => { Art.drawWindow(x, 9, 48, 30, sky, t, i); drawSkyExtras(x, 9, 48, 30, sky, t, i, skyHour()); drawWindowView(x, 9, 48, 30, sky, i); drawSeasonWindow(x, 9, 48, 30, t); drawWeather(x, 9, 48, 30, t); drawGlassDrops(x, 9, 48, 30, t); drawHolidayWindow(x, 9, 48, 30, t, sky); windowBoxes.push({ x, y: 9, w: 48, h: 30 }); } });
|
|
77
84
|
}
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
moodBox = null;
|
|
86
|
+
const left = 18, right = RX - 24, sum = () => items.reduce((n, it) => n + it.w, 0);
|
|
87
|
+
if (right - left - sum() - 26 >= (items.length + 1) * 8) items.push({ w: 26, draw: x => drawMoodSign(x + 1, 16, t, wallGlows) }); // ON AIR, when there is room
|
|
88
|
+
const gap = items.length > 1 ? Math.min(40, Math.max(6, (right - left - sum()) / (items.length - 1))) : 0, start = left + Math.max(0, ((right - left) - sum() - gap * (items.length - 1)) / 2);
|
|
89
|
+
let x = start; for (const it of items) { it.draw(Math.round(x)); x += it.w + gap; }
|
|
80
90
|
boardBox = { x: RX - 14, y: 8, w: 56, h: 30 };
|
|
81
91
|
if (typeof drawTaskBoard === 'function') drawTaskBoard(boardBox.x, boardBox.y, boardBox.w, boardBox.h, t); else Art.drawWhiteboard(boardBox.x, boardBox.y, boardBox.w, boardBox.h, counts(), t);
|
|
82
92
|
Art.drawClock(RX + 58, 20);
|
|
@@ -94,8 +104,8 @@ function drawShafts(sky, nWin) {
|
|
|
94
104
|
if (sky.phase === 'night') return;
|
|
95
105
|
ctx.save();
|
|
96
106
|
ctx.fillStyle = sky.phase === 'day' ? 'rgba(255,246,214,0.08)' : 'rgba(255,180,120,0.08)';
|
|
97
|
-
for (
|
|
98
|
-
const x =
|
|
107
|
+
for (const b of windowBoxes) {
|
|
108
|
+
const x = b.x;
|
|
99
109
|
ctx.beginPath(); ctx.moveTo(x, TOP); ctx.lineTo(x + 48, TOP); ctx.lineTo(x + 80, TOP + 80); ctx.lineTo(x + 32, TOP + 80); ctx.closePath(); ctx.fill();
|
|
100
110
|
}
|
|
101
111
|
ctx.restore();
|
|
@@ -400,7 +410,7 @@ function drawScene(t, dt) {
|
|
|
400
410
|
r(CX - 8, TOP, 16, H - TOP, '#6e2f3f'); r(CX - 6, TOP, 12, H - TOP, '#8e3e52');
|
|
401
411
|
for (let y = TOP + 6; y < H; y += 10) { r(CX - 1, y, 2, 4, '#c96f84'); r(CX - 5, y + 5, 1, 1, '#c96f84'); r(CX + 4, y + 5, 1, 1, '#c96f84'); }
|
|
402
412
|
for (let y = TOP + 70; y < H - 30; y += 150) Art.drawPlant(CX + 9, y, false);
|
|
403
|
-
if (sky.phase !== 'night') for (
|
|
413
|
+
if (sky.phase !== 'night') for (const b of windowBoxes) lights.push({ x: b.x + 24, y: TOP + 20, r: 60 });
|
|
404
414
|
const arrived = z => layout.filter(c => c.actor && c.actor.mode === z);
|
|
405
415
|
const meet = arrived('meet').map(c => ({ spot: c.actor.spot, lk: look(c.p.id), label: c.p.name, lead: true }));
|
|
406
416
|
const used = new Set(meet.map(m => m.spot));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ---------------- an agent's whole conversation in the side panel ----------------
|
|
3
|
+
// Two views of the selected agent: Summary (the panel as it was) and Conversation (a chat with a search inside it).
|
|
4
|
+
// The conversation is read on the server from the session's own log; only what you wrote and what it answered.
|
|
5
|
+
let deskSub = 'info', deskTalkSub = '', deskHelpers = [], deskTalk = null, deskTalkFor = '', deskTalkKey = '', deskTalkQ = '', deskTalkBusy = false, deskTalkDrawn = '', deskTalkTimer = 0;
|
|
6
|
+
const deskCanMsg = p => ['claude', 'codex'].includes(p.agent) && p.entrypoint !== 'claude-desktop' && ['idle', 'asleep'].includes(p.state);
|
|
7
|
+
function deskTabsHtml() {
|
|
8
|
+
const C = T.chat;
|
|
9
|
+
return `<div class="ptabs"><button class="${deskSub === 'info' ? 'on' : ''}" data-psub="info">${esc(C.summary)}</button>${data && data.privacy ? '' : `<button class="${deskSub === 'talk' ? 'on' : ''}" data-psub="talk">💬 ${esc(C.talk)}</button>`}</div>`;
|
|
10
|
+
}
|
|
11
|
+
const deskMark = (text, q) => {
|
|
12
|
+
const t = String(text || ''); if (!q) return esc(t);
|
|
13
|
+
const low = t.toLowerCase(), out = []; let i = 0, k;
|
|
14
|
+
while ((k = low.indexOf(q, i)) >= 0) { out.push(esc(t.slice(i, k)), '<mark>', esc(t.slice(k, k + q.length)), '</mark>'); i = k + q.length; }
|
|
15
|
+
out.push(esc(t.slice(i))); return out.join('');
|
|
16
|
+
};
|
|
17
|
+
// **bold** and `code` on already escaped text
|
|
18
|
+
const deskMd = h => h.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>').replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
19
|
+
function deskTalkList(p) {
|
|
20
|
+
const C = T.chat, r = deskTalk, q = deskTalkQ.toLowerCase();
|
|
21
|
+
if (!r) return `<p class="note">…</p>`;
|
|
22
|
+
if (!r.ok) return `<p class="note">⚠ ${esc(C.fail[r.reason] || r.reason || '?')}</p>`;
|
|
23
|
+
if (!r.items.length) return `<p class="note">${esc(q ? C.none : C.empty)}</p>`;
|
|
24
|
+
const when = ms => { const x = new Date(ms), today = new Date().toDateString() === x.toDateString(); return x.toLocaleString(lang === 'pt' ? 'pt-BR' : 'en', today ? { hour: '2-digit', minute: '2-digit' } : { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
|
25
|
+
return (q ? `<p class="note">${esc(C.found(r.found))}</p>` : r.cut || r.total > r.items.length ? `<p class="note">${esc(C.cut)}</p>` : '')
|
|
26
|
+
+ r.items.map(m => `<div class="bubble ${m.role === 'user' ? 'me' : 'it'}"><p>${deskMd(deskMark(m.text, q))}</p><small>${(h => m.role === 'user' ? esc(h ? p.name : C.you) : esc(h ? h.type : p.name))(deskHelpers.find(x => x.id === deskTalkSub))}${m.at ? ' · ' + esc(when(m.at)) : ''}</small></div>`).join('');
|
|
27
|
+
}
|
|
28
|
+
// the agent itself and each of its helpers (subagents), newest first; a dot marks the ones still working
|
|
29
|
+
function deskHelpersHtml() {
|
|
30
|
+
const C = T.chat; if (!deskHelpers.length) return '';
|
|
31
|
+
const cur = deskHelpers.find(h => h.id === deskTalkSub);
|
|
32
|
+
return `<div class="phelpers"><button class="${deskTalkSub ? '' : 'on'}" data-psubag="">${esc(C.main)}</button>${deskHelpers.map(h => `<button class="${h.id === deskTalkSub ? 'on' : ''}" data-psubag="${esc(h.id)}" title="${esc(h.what)}">${h.live ? '● ' : ''}${esc(h.type)}</button>`).join('')}</div>
|
|
33
|
+
${cur ? `<p class="note">🤖 ${esc(C.helperOf(cur.type))}${cur.what ? ': ' + esc(cur.what) : ''}</p>` : `<p class="note">${esc(C.helpers(deskHelpers.length))}</p>`}`;
|
|
34
|
+
}
|
|
35
|
+
async function deskLoadTalk(p, toEnd) {
|
|
36
|
+
if (deskTalkBusy) return; deskTalkBusy = true;
|
|
37
|
+
const id = p.id, r = await fetch(`api/talk?id=${encodeURIComponent(id)}&q=${encodeURIComponent(deskTalkQ)}&sub=${encodeURIComponent(deskTalkSub)}`).then(x => x.json()).catch(() => ({ ok: false, reason: 'unknown' }));
|
|
38
|
+
deskTalkBusy = false;
|
|
39
|
+
if (selected !== id || deskSub !== 'talk') return;
|
|
40
|
+
deskTalk = r; deskTalkFor = id; if (r.helpers) deskHelpers = r.helpers;
|
|
41
|
+
const hb = document.getElementById('ptalk-helpers'); if (hb) hb.innerHTML = deskHelpersHtml();
|
|
42
|
+
const box = panelBody.querySelector('.ptalk-box'); if (box) box.hidden = !!deskTalkSub; // a helper takes no messages
|
|
43
|
+
const list = document.getElementById('ptalk-list');
|
|
44
|
+
const atEnd = panel.scrollHeight - panel.scrollTop - panel.clientHeight < 140;
|
|
45
|
+
if (list) list.innerHTML = deskTalkList(p);
|
|
46
|
+
if (list && (toEnd || atEnd) && !deskTalkQ) panel.scrollTop = panel.scrollHeight;
|
|
47
|
+
}
|
|
48
|
+
// called by renderPanel: draws the conversation view and returns true, or false for the summary
|
|
49
|
+
function deskTalkOn(p) {
|
|
50
|
+
if (deskSub !== 'talk' || (data && data.privacy)) return false;
|
|
51
|
+
const C = T.chat, key = `${p.state}|${p.lastReplyAt}|${p.lastPromptAt}`;
|
|
52
|
+
if (deskTalkFor !== p.id) { deskTalk = null; deskTalkQ = ''; deskTalkKey = ''; deskTalkDrawn = ''; deskTalkSub = ''; deskHelpers = []; }
|
|
53
|
+
const shell = `${p.id}|${p.state}|${deskCanMsg(p)}|${lang}`;
|
|
54
|
+
if (shell !== deskTalkDrawn || !document.getElementById('ptalk-list')) { // the frame only when it changes; the list fills in place
|
|
55
|
+
deskTalkDrawn = shell;
|
|
56
|
+
panelBody.innerHTML = `<header class="ph"><div class="ph-face"></div><div><h2>${esc(p.name)}</h2><p class="sub">${esc(p.title || '—')}</p>
|
|
57
|
+
<div class="pills"><span class="pill" style="background:${agentOf(p).color}">${esc(agentOf(p).label)}</span><span class="pill" style="background:${KIND_COLOR[p.state] || '#888'}">${esc(T.states[p.state] || p.state)}</span></div></div></header>
|
|
58
|
+
${deskTabsHtml()}<div id="ptalk-helpers">${deskHelpersHtml()}</div><input type="search" class="ptalk-q" data-ptalkq placeholder="${esc(C.search)}" value="${esc(deskTalkQ)}"><div id="ptalk-list">${deskTalkList(p)}</div>
|
|
59
|
+
<div class="card ptalk-box"${deskTalkSub ? ' hidden' : ''}>${deskCanMsg(p) ? `<textarea data-ptalk-text rows="3" placeholder="${esc(C.write)}"></textarea><p><button class="btn small" data-ptalk-send="${esc(p.id)}">➤ ${esc(C.send)}</button> <small class="note" data-ptalk-msg></small></p>` : `<p class="note">${esc(C.onlyIdle)}</p>`}</div>`;
|
|
60
|
+
panelBody.querySelector('.ph-face').appendChild(Art.portrait(p.id, p.state === 'needs_you' ? 'open' : null));
|
|
61
|
+
}
|
|
62
|
+
if (key !== deskTalkKey || !deskTalk) { const first = !deskTalk; deskTalkKey = key; deskLoadTalk(p, first); }
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
panelBody.addEventListener('click', async e => {
|
|
66
|
+
const t = e.target.closest('[data-psub]');
|
|
67
|
+
if (t) { deskSub = t.dataset.psub; deskTalkDrawn = ''; deskTalk = null; deskTalkKey = ''; renderPanel(); return; }
|
|
68
|
+
const sa = e.target.closest('[data-psubag]');
|
|
69
|
+
if (sa) { deskTalkSub = sa.dataset.psubag; deskTalk = null; const p = data && data.people.find(x => x.id === selected); const list = document.getElementById('ptalk-list'); if (list) list.innerHTML = '<p class="note">…</p>'; if (p) deskLoadTalk(p, true); return; }
|
|
70
|
+
const s = e.target.closest('[data-ptalk-send]'); if (!s) return;
|
|
71
|
+
const ta = panelBody.querySelector('[data-ptalk-text]'), m = panelBody.querySelector('[data-ptalk-msg]'), text = ta ? ta.value.trim() : '';
|
|
72
|
+
if (!text) return ta && ta.focus();
|
|
73
|
+
s.disabled = true;
|
|
74
|
+
const r = await fetch('api/reply', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Coworking': '1' }, body: JSON.stringify({ id: s.dataset.ptalkSend, text }) }).then(x => x.json()).catch(() => ({ ok: false, reason: 'unknown' }));
|
|
75
|
+
s.disabled = false;
|
|
76
|
+
if (r.ok) { ta.value = ''; ta.blur(); }
|
|
77
|
+
if (m) m.textContent = r.ok ? '✓' : '⚠ ' + (T.chat.fail[r.reason] || r.reason || '?');
|
|
78
|
+
});
|
|
79
|
+
panelBody.addEventListener('input', e => {
|
|
80
|
+
if (!e.target.matches('[data-ptalkq]')) return;
|
|
81
|
+
clearTimeout(deskTalkTimer);
|
|
82
|
+
deskTalkTimer = setTimeout(() => { deskTalkQ = e.target.value.trim().slice(0, 100); const p = data && data.people.find(x => x.id === selected); if (p) deskLoadTalk(p, !deskTalkQ); }, 350);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------- everyone in the office ----------------
|
|
86
|
+
// Opened from the count under the office name: every agent with its state, and the helpers working for it right now.
|
|
87
|
+
// An agent opens its panel; a helper opens that agent's conversation on the helper.
|
|
88
|
+
function renderCrew() {
|
|
89
|
+
const C = T.crew, body = reportEl.querySelector('.report-body'), live = data.people.filter(p => !p.leaving);
|
|
90
|
+
const order = { needs_you: 0, waiting: 0, idle: 2, asleep: 3 }, rank = p => order[p.state] ?? 1;
|
|
91
|
+
const rows = live.slice().sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)).map(p => `<li class="crew-row">
|
|
92
|
+
<button class="crew-agent" data-crew="${esc(p.id)}"><span class="crew-face" data-crew-face="${esc(p.id)}"></span><span class="crew-txt"><b>${esc(p.name)}</b><small>${esc(agentOf(p).label)}${p.repo ? ' · ' + esc(p.repo.name) : ''}${p.title ? ' · ' + esc(p.title) : ''}</small></span><span class="pill" style="background:${KIND_COLOR[p.state] || '#888'}">${esc(T.states[p.state] || p.state)}</span></button>
|
|
93
|
+
${(p.subagents || []).length ? `<ul class="crew-subs">${p.subagents.map(a => `<li><button data-crew="${esc(p.id)}" data-crew-sub="${esc(a.id)}">🤖 <b>${esc(a.type)}</b> ${esc(a.description || '')}${a.doing ? `<small> · ${esc(a.doing)}</small>` : ''}</button></li>`).join('')}</ul>` : ''}</li>`).join('');
|
|
94
|
+
const n = live.reduce((k, p) => k + (p.subagents || []).length, 0);
|
|
95
|
+
body.innerHTML = tabsHtml() + `<h2>👥 ${esc(C.title)}</h2><p class="sub">${esc(C.sub(live.length, n))}</p><ul class="crew-list">${rows || `<li class="note">—</li>`}</ul><p class="note">${esc(C.note)}</p>`;
|
|
96
|
+
body.querySelectorAll('[data-crew-face]').forEach(el => { try { el.appendChild(Art.portrait(el.dataset.crewFace)); } catch {} });
|
|
97
|
+
}
|
|
98
|
+
reportEl.addEventListener('click', e => {
|
|
99
|
+
if (reportTab !== 'crew') return;
|
|
100
|
+
const b = e.target.closest('[data-crew]'); if (!b) return;
|
|
101
|
+
const id = b.dataset.crew, sub = b.dataset.crewSub || '';
|
|
102
|
+
closeReport();
|
|
103
|
+
if (sub) { deskSub = 'talk'; deskTalkFor = id; deskTalkSub = sub; deskTalk = null; deskTalkKey = ''; deskTalkDrawn = ''; } else deskSub = 'info';
|
|
104
|
+
showPerson(id);
|
|
105
|
+
});
|
|
106
|
+
document.addEventListener('click', e => { if (e.target.closest('#host .crew')) openReport('crew'); });
|