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 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
 
Binary file
package/docs/building.png CHANGED
Binary file
package/docs/camera.png CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/docs/mobile.png CHANGED
Binary file
Binary file
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
Binary file
package/docs/usage.png CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coworking-agents",
3
- "version": "0.7.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);
@@ -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);
@@ -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></div>`;
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>`;
@@ -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
- for (let x = 0; x < W; x += 24) r(x, 0, 1, TOP - 6, PAL.wallShade);
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
- let extra = 0; // window slots after the agent of the month become the costs screen and the notice board
66
- const nWin = Math.max(1, Math.floor((CX - 30) / 74));
67
- // the third window slot becomes the dev corner of the wall: neon </> and two posters
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
- const wx = 16 + i * 74;
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) { Art.drawNeon(wx + 9, 10, Date.now() - neonFlash < 400 ? (((Date.now() / 60) | 0) % 2 ? t : 120 * 47) : t, wallGlows); neonBox = { x: wx + 7, y: 8, w: 34, h: 18 }; Art.drawPoster(wx - 16, 14, 0); Art.drawPoster(wx + 46, 14, 2); releaseBox = { x: wx - 17, y: 13, w: 22, h: 28 }; historyBox = { x: wx + 45, y: 13, w: 22, h: 28 }; if (typeof drawReleaseBadge === 'function') drawReleaseBadge(wx - 16, 14, t); }
73
- else if (i === 4 && nWin >= 7) drawTrophyShelf(wx - 8, 9, 64, t);
74
- else if (i === 5) { if (nWin >= 7) drawAgentOfMonth(wx, 10, t, wallGlows); else Art.drawPoster(wx + 14, 12, 1); }
75
- else if (i >= 6 && extra < 2 && typeof drawCostScreen === 'function') { if (extra++ === 0) { drawCostScreen(wx, 9, 48, 30, t); costBox = { x: wx - 1, y: 8, w: 50, h: 32 }; } else { drawNoticeCork(wx, 9, 48, 30, t); noticesBox = { x: wx - 1, y: 8, w: 50, h: 32 }; } }
76
- else { Art.drawWindow(wx, 9, 48, 30, sky, t, i); drawSkyExtras(wx, 9, 48, 30, sky, t, i, skyHour()); drawWindowView(wx, 9, 48, 30, sky, i); drawSeasonWindow(wx, 9, 48, 30, t); drawWeather(wx, 9, 48, 30, t); drawGlassDrops(wx, 9, 48, 30, t); drawHolidayWindow(wx, 9, 48, 30, t, sky); windowBoxes.push({ x: wx, y: 9, w: 48, h: 30 }); }
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
- // the ON AIR sign takes the gap between the last window and the whiteboard, when there is one
79
- { const lastEnd = 16 + (nWin - 1) * 74 + 48, gap = RX - 14 - lastEnd; moodBox = null; if (gap >= 32) drawMoodSign(lastEnd + ((gap - 24) / 2 | 0), 16, t, wallGlows); }
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 (let i = 0; i < nWin; i++) {
98
- const x = 16 + i * 74;
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 (let i = 0; i < nWin; i++) lights.push({ x: 40 + i * 74, y: TOP + 20, r: 60 });
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'); });
@@ -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;