coworking-agents 0.7.0 โ 0.8.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 +15 -0
- package/README.md +5 -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 +2 -0
- package/public/js/i18n.js +21 -4
- package/public/js/panel.js +2 -0
- package/public/js/report.js +23 -3
- package/public/js/scene.js +26 -16
- package/public/js/talkdesk.js +106 -0
- package/public/js/tasks.js +41 -4
- package/public/js/timelapse.js +97 -0
- package/public/js/topbar.js +3 -1
- 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 +54 -0
- package/src/collect.js +6 -2
- package/src/demo.js +19 -2
- package/src/pace.js +32 -0
- package/src/phone.js +34 -11
- package/src/said.js +62 -1
- package/src/server.js +14 -3
- package/src/usage.js +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 โ 2026-10-14
|
|
4
|
+
- 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
|
|
5
|
+
- 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
|
|
6
|
+
- 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
|
|
7
|
+
- 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
|
|
8
|
+
- 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
|
|
9
|
+
- Tasks as a board (Tasks โ *Board*): queued, running, waiting on you, done; drag a card to retry, conclude or cancel it
|
|
10
|
+
- 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
|
|
11
|
+
- 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
|
|
12
|
+
- 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
|
|
13
|
+
- 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
|
|
14
|
+
- 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
|
|
15
|
+
- 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)
|
|
16
|
+
- 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
|
|
17
|
+
|
|
3
18
|
## 0.7.0 โ 2026-10-13
|
|
4
19
|
- Phone app, live: the office pushes every change as it happens (the 3-second poll only runs while the stream is down)
|
|
5
20
|
- 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,10 @@ 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
|
+
- **๐ 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
231
|
- **๐บ TV mode** (or `?tv=1`): full screen, only the office and big counters; Esc leaves.
|
|
228
232
|
- **Waiting on you today** in the daily report: measured time the agents waited on your answers and tasks stood blocked on you.
|
|
229
233
|
- **Sounds and do not disturb** (Personalise): each sound on or off, and a daily quiet window that mutes sounds and system notifications.
|
|
@@ -233,7 +237,7 @@ The office works in any browser. If you prefer an app:
|
|
|
233
237
|
`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
238
|
|
|
235
239
|
- **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).
|
|
240
|
+
- **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
241
|
- **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
242
|
- **Office:** a picture of the office drawn without names. **Usage:** today's and the month's spend and the limits.
|
|
239
243
|
|
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.8.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,8 @@
|
|
|
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>
|
|
85
87
|
<script src="js/main.js"></script>
|
|
86
88
|
</body>
|
|
87
89
|
</html>
|
package/public/js/i18n.js
CHANGED
|
@@ -595,10 +595,27 @@ 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'}` } });
|
|
602
619
|
let T = I18N[lang];
|
|
603
620
|
// sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
|
|
604
621
|
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());
|
|
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,8 @@ 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();
|
|
158
177
|
if (reportTab === 'ach') return renderAchievements();
|
|
159
178
|
if (reportTab === 'paper') return renderPaper();
|
|
160
179
|
if (reportTab === 'queue') return renderQueue();
|
|
@@ -181,16 +200,17 @@ function renderReport() {
|
|
|
181
200
|
<div class="axis">${days.map((k, i) => `<span>${i % 2 ? '' : k.slice(8) + '/' + k.slice(5, 7)}</span>`).join('')}</div>
|
|
182
201
|
<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
202
|
// limits first: one big tile per window, the most urgent thing on the page
|
|
184
|
-
const limTile = (id, label, w) => {
|
|
203
|
+
const limTile = (id, label, w, key) => {
|
|
185
204
|
if (!w) return '';
|
|
186
205
|
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
|
|
206
|
+
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
207
|
};
|
|
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('');
|
|
208
|
+
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
209
|
const total = id => u && u.byAgent && u.byAgent[id] ? u.byAgent[id].total : null;
|
|
191
210
|
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
211
|
<h3>${esc(U.limits)}</h3><div class="ltiles">${tiles || `<p class="note">${esc(U.none)}</p>`}</div>
|
|
193
212
|
${costGoalHtml(u)}
|
|
213
|
+
${heatHtml(u)}
|
|
194
214
|
<h3>${esc(U.last14)}</h3><div class="chart-wrap"><span class="ymax">${tok(max)}</span>${chart}</div>
|
|
195
215
|
<h3>${esc(U.allTime)}</h3><div class="rgrid">${Object.keys(AGENT).map(card).join('')}</div>
|
|
196
216
|
<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'); });
|
package/public/js/tasks.js
CHANGED
|
@@ -359,6 +359,7 @@ function shipHtml(t, c) {
|
|
|
359
359
|
}
|
|
360
360
|
const shipApi = body => fetch('api/task-ship', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Coworking': '1' }, body: JSON.stringify(body) }).then(r => r.json()).catch(() => ({ ok: false, reason: 'unknown' }));
|
|
361
361
|
function renderTasks() {
|
|
362
|
+
if (boardDrag) return; // never redraw under a card being dragged
|
|
362
363
|
const X = TT(), body = reportEl.querySelector('.report-body');
|
|
363
364
|
if (taskSel && taskDetail) return renderTaskDetail(body);
|
|
364
365
|
const draft = reportEl.querySelector('.tform') ? tfVal() : null; // what was typed survives the refresh
|
|
@@ -369,15 +370,50 @@ function renderTaskList(X, body) {
|
|
|
369
370
|
const order = ['blocked', 'running', 'pending', 'failed', 'done', 'cancelled'], list = taskData.tasks.slice().sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state) || b.priority - a.priority || b.updated - a.updated);
|
|
370
371
|
const kindL = { task: X.kinds.task, session: X.kinds.session, today: X.kinds.today, month: X.kinds.month };
|
|
371
372
|
body.innerHTML = tabsHtml() + `<p class="sub">${esc(X.sub)}</p>${taskData.privacy ? `<p class="note">๐ ${esc(X.privateNote)}</p>` : ''}${taskData.dayStopped ? `<div class="talert">โธ ${esc(X.dayStopped(fmtUSD(taskData.spentToday || 0), fmtUSD(taskData.settings.dayBudget || 0)))}</div>` : ''}` + formHtml() + settingsHtml()
|
|
372
|
-
+ `<div class="trow"><label style="flex:2">๐ <input data-tsearch placeholder="${esc(X.search)}"></label></div>`
|
|
373
|
+
+ `<div class="trow"><label style="flex:2">๐ <input data-tsearch placeholder="${esc(X.search)}"></label><span class="tview">${[['list', 'โฐ', X.board.list], ['board', 'โฆ', X.board.board]].map(([k, i, l]) => `<button class="btn small ${taskView === k ? '' : 'ghost'}" data-tview="${k}">${i} ${esc(l)}</button>`).join('')}</span></div>`
|
|
373
374
|
+ (taskSaid.length ? `<h4 class="tsaid-h">๐ฌ ${esc(X.saidTitle(taskSaid.length))}</h4><ul class="queue tsaid">${taskSaid.map(r => { const live = data && data.people.some(p => p.id === r.id); return `<li ${live ? `data-tperson="${esc(r.id)}"` : ''}><span><small>${esc(r.agent)} ยท ${esc(r.role === 'user' ? X.saidYou : X.saidAgent)} ยท ${esc(ago(Date.now() - r.at))}${live ? ' ยท ' + esc(X.saidLive) : ''}</small><br>${esc(r.snippet)}</span></li>`; }).join('')}</ul>` : '')
|
|
374
375
|
+ (taskSearch.length ? `<ul class="queue">${taskSearch.map(r => `<li ${r.kind === 'task' ? `data-topen="${esc(r.id)}"` : r.live ? `data-tperson="${esc(r.id)}"` : ''}><b>${esc(r.title)}</b> <small>${esc(kindL[r.kind] || r.kind)} ยท ${esc(r.agent || '')}${r.state ? ' ยท ' + esc(X.states[r.state] || T.states[r.state] || r.state) : ''}</small></li>`).join('')}</ul>` : '')
|
|
375
|
-
+ (list.length ? [['need', ['blocked', 'failed']], ['active', ['running', 'pending']], ['done', ['done', 'cancelled']]].map(([g, sts]) => {
|
|
376
|
+
+ (list.length && taskView === 'board' ? boardHtml(list) : list.length ? [['need', ['blocked', 'failed']], ['active', ['running', 'pending']], ['done', ['done', 'cancelled']]].map(([g, sts]) => {
|
|
376
377
|
const items = list.filter(t => sts.includes(t.state)); if (!items.length) return '';
|
|
377
378
|
const ul = `<ul class="queue tlist">${items.map(rowHtml).join('')}</ul>`;
|
|
378
379
|
return g === 'done' ? `<details class="tgroup"><summary><h4>${esc(X.groups[g])} <small>${items.length}</small></h4></summary>${ul}</details>` : `<div class="tgroup"><h4>${esc(X.groups[g])} <small>${items.length}</small></h4>${ul}</div>`;
|
|
379
380
|
}).join('') : `<p class="note">${esc(X.empty)}</p>`) + rankingHtml() + compareHtml();
|
|
380
381
|
}
|
|
382
|
+
// ---- the tasks as a board: queue, running, waiting on you, done; drag a card to retry, conclude or cancel it ----
|
|
383
|
+
let taskView = store.get('taskView', 'list'), boardDrag = null;
|
|
384
|
+
const BOARD = [['queue', ['pending']], ['running', ['running']], ['you', ['blocked', 'failed']], ['done', ['done', 'cancelled']]];
|
|
385
|
+
// what dropping a task on a column does (null: not allowed there)
|
|
386
|
+
function boardMove(t, to) {
|
|
387
|
+
if (to === 'running' && ['blocked', 'failed'].includes(t.state)) return 'retry';
|
|
388
|
+
if (to === 'done' && t.state === 'blocked') return 'conclude';
|
|
389
|
+
if (to === 'done' && ['pending', 'running', 'failed'].includes(t.state)) return 'cancel';
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
function boardHtml(list) {
|
|
393
|
+
const X = TT(), B = X.board;
|
|
394
|
+
const card = t => `<div class="bcard st-${t.state}" draggable="true" data-bdrag="${esc(t.id)}" data-topen="${esc(t.id)}"><b>${esc(t.title)}</b>
|
|
395
|
+
<small>${esc(t.agent)} ยท ${t.costUnknown && !t.cost ? '?' : esc(fmtUSD(t.cost || 0))} ยท ${esc(ago(Date.now() - t.updated))}</small>${t.ask ? `<span class="bask">๐ ${esc(t.ask.tool)}</span>` : t.reason && ['blocked', 'failed'].includes(t.state) ? `<span class="bwhy">${esc(X.reasons[t.reason] || t.reason)}</span>` : ''}</div>`;
|
|
396
|
+
return `<p class="note">${esc(B.hint)}</p><div class="board">${BOARD.map(([k, sts]) => { const items = list.filter(t => sts.includes(t.state)), shown = k === 'done' ? items.slice(0, 12) : items;
|
|
397
|
+
return `<div class="bcol" data-bcol="${k}"><h4>${esc(B.cols[k])} <small>${items.length}</small></h4>${shown.map(card).join('') || `<p class="note bempty">โ</p>`}</div>`; }).join('')}</div>`;
|
|
398
|
+
}
|
|
399
|
+
reportEl.addEventListener('dragstart', e => {
|
|
400
|
+
const c = e.target.closest && e.target.closest('[data-bdrag]'); if (!c || reportTab !== 'tasks') return;
|
|
401
|
+
const t = taskData.tasks.find(x => x.id === c.dataset.bdrag); if (!t) return;
|
|
402
|
+
boardDrag = t; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', t.id); c.classList.add('dragging');
|
|
403
|
+
reportEl.querySelectorAll('[data-bcol]').forEach(col => col.classList.toggle('bok', !!boardMove(t, col.dataset.bcol)));
|
|
404
|
+
});
|
|
405
|
+
reportEl.addEventListener('dragover', e => { const col = e.target.closest && e.target.closest('[data-bcol]'); if (boardDrag && col && boardMove(boardDrag, col.dataset.bcol)) { e.preventDefault(); col.classList.add('bover'); } });
|
|
406
|
+
reportEl.addEventListener('dragleave', e => { const col = e.target.closest && e.target.closest('[data-bcol]'); if (col && !col.contains(e.relatedTarget)) col.classList.remove('bover'); });
|
|
407
|
+
reportEl.addEventListener('dragend', () => { boardDrag = null; reportEl.querySelectorAll('.bok,.bover,.dragging').forEach(x => x.classList.remove('bok', 'bover', 'dragging')); });
|
|
408
|
+
reportEl.addEventListener('drop', async e => {
|
|
409
|
+
const col = e.target.closest && e.target.closest('[data-bcol]'), t = boardDrag; if (!col || !t) return;
|
|
410
|
+
e.preventDefault(); boardDrag = null;
|
|
411
|
+
const op = boardMove(t, col.dataset.bcol); reportEl.querySelectorAll('.bok,.bover,.dragging').forEach(x => x.classList.remove('bok', 'bover', 'dragging'));
|
|
412
|
+
if (!op || (op === 'cancel' && !confirm(TT().cancelSure))) return;
|
|
413
|
+
const r = await api('api/tasks', { op, id: t.id });
|
|
414
|
+
if (!r.ok) toast(`<b>${esc(t.title)}</b>${esc(tFail(r))}`, 'warn');
|
|
415
|
+
await pollTasks(); renderTasks();
|
|
416
|
+
});
|
|
381
417
|
function renderTaskDetail(body) {
|
|
382
418
|
const X = TT(), t = taskDetail, ev = t.events || [], live = t.state === 'running';
|
|
383
419
|
const evHtml = ev.slice(-60).reverse().map(e => {
|
|
@@ -434,6 +470,7 @@ reportEl.addEventListener('click', async e => {
|
|
|
434
470
|
const ti = reportEl.querySelector('[data-tf="title"]'); if (ti && !ti.value) ti.value = x.name; return;
|
|
435
471
|
}
|
|
436
472
|
const per = e.target.closest('[data-tperiod]'); if (per) { taskPeriod = per.dataset.tperiod; return renderTasks(); }
|
|
473
|
+
const tv = e.target.closest('[data-tview]'); if (tv) { taskView = tv.dataset.tview; store.set('taskView', taskView); return renderTasks(); }
|
|
437
474
|
const op1 = e.target.closest('[data-topen]'); if (op1) { taskDiff = null; return loadTask(op1.dataset.topen); }
|
|
438
475
|
const pp = e.target.closest('[data-tperson]'); if (pp) { closeReport(); selected = pp.dataset.tperson; return renderPanel(); }
|
|
439
476
|
const b = e.target.closest('[data-tact]'); if (!b) return;
|
|
@@ -585,7 +622,7 @@ function drawReleaseBadge(x, y, t) { // the rocket poster gets a star when a new
|
|
|
585
622
|
// the same panels in the โฐ menu
|
|
586
623
|
{
|
|
587
624
|
const menu = document.getElementById('tools-menu');
|
|
588
|
-
if (menu) for (const [tab, icon] of [['tv', '๐บ'], ['phone', '๐ฑ'], ['broadcast', '๐ฃ'], ['repos', '๐ฆ'], ['tasks', '๐'], ['usage', '๐ฒ'], ['feed', '๐'], ['notices', '๐'], ['version', '๐']]) {
|
|
625
|
+
if (menu) for (const [tab, icon] of [['tv', '๐บ'], ['phone', '๐ฑ'], ['broadcast', '๐ฃ'], ['repos', '๐ฆ'], ['tasks', '๐'], ['usage', '๐ฒ'], ['timelapse', '๐'], ['feed', '๐'], ['notices', '๐'], ['version', '๐']]) {
|
|
589
626
|
const b = document.createElement('button'); b.className = 'icon-btn menu-panel'; b.dataset.menuTab = tab;
|
|
590
627
|
b.innerHTML = `<span aria-hidden="true">${icon}</span><span class="ml"></span>`; menu.prepend(b);
|
|
591
628
|
b.addEventListener('click', () => { if (typeof setMenu === 'function') setMenu(false); if (tab === 'tv') return setTv(true); openReport(tab); });
|
|
@@ -694,7 +731,7 @@ async function renderPhone() {
|
|
|
694
731
|
const P = T.phone, body = reportEl.querySelector('.report-body');
|
|
695
732
|
const d = await api('api/phone'); if (reportTab !== 'phone' || reportEl.hidden) return;
|
|
696
733
|
if (!d || !d.on) { body.innerHTML = tabsHtml() + `<h3>๐ฑ ${esc(P.title)}</h3><p>${esc(P.how)}</p><p><code>npx coworking-agents --phone</code></p><p class="note">${esc(P.warn)}</p>`; return; }
|
|
697
|
-
body.innerHTML = tabsHtml() + `<h3>๐ฑ ${esc(P.title)}</h3><p>${esc(P.scan)}</p><canvas class="qr" width="10" height="10"></canvas><p><code class="qr-url">${esc(d.url)}</code></p><p class="note">${esc(P.warn)}</p>`;
|
|
734
|
+
body.innerHTML = tabsHtml() + `<h3>๐ฑ ${esc(P.title)}</h3><p>${esc(P.scan)}</p><canvas class="qr" width="10" height="10"></canvas><p><code class="qr-url">${esc(d.url)}</code></p>${d.ipUrl && d.ipUrl !== d.url ? `<p class="note">${esc(T.phone.ipAlt)}</p><p><code class="qr-url">${esc(d.ipUrl)}</code></p>` : ''}<p class="note">${esc(P.warn)}</p>`;
|
|
698
735
|
drawQr(body.querySelector('canvas.qr'), d.qr);
|
|
699
736
|
body.insertAdjacentHTML('beforeend', `<h3>๐ฒ ${esc(T.phone.homeTitle)}</h3><p class="note">${esc(T.phone.homeIos)}</p><p class="note">${esc(T.phone.homeAndroid)}</p><p class="note">${esc(T.phone.homeNote)}</p><p><button class="btn small ghost" data-phonerotate>๐ ${esc(T.phone.rotate)}</button> <small class="note" data-phonerotate-msg></small></p>`);
|
|
700
737
|
const V = T.visit; visitUntil = d.visit ? d.visit.until : 0;
|