From 5b539f8edb5074e7ce066dcddee9dd13bbf76918 Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 04:38:39 +0200 Subject: [PATCH 1/7] Plan GTA-style smartphone for the player character Contacts are the active workers; call walks over and opens their terminal, SMS sends a worker.prompt, all in an iFruit-styled modal. No new wire protocol in MVP. Docs-only change. --- docs/plans/smartphone.md | 234 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/plans/smartphone.md diff --git a/docs/plans/smartphone.md b/docs/plans/smartphone.md new file mode 100644 index 000000000..307fa0dd7 --- /dev/null +++ b/docs/plans/smartphone.md @@ -0,0 +1,234 @@ +# GTA-style smartphone — implementation plan + +> **Plan only — no game code.** This document is the design the implementation PR will follow. +> Target: a GTA-style smartphone for the player character, in the game's look and feel. + +## 1. Goal + +The main character gets a GTA-style smartphone with the game's look and feel: + +- **Contacts** = the currently active agents/workers in the office. +- The player can **"call"** an active agent and **"send SMS"** to agents. +- Everything stays in the game's style/optics (toon look, HUD, modals, sounds). + +## 2. What exists today (explored read-only) + +The phone composes existing systems; it invents almost no wire protocol: + +| System | Where it lives | What the phone reuses | +| --- | --- | --- | +| Active workers | `store.workers` (`Map`), painted by `src/client/features/workers/views.ts`, acted on by `src/client/features/workers/actions.ts` | Contacts list source; `STATUS_LABEL`, `clip`, `workerPr` badges | +| Waiting order | `waitingInOrder` in `src/client/nextup.ts` | Contacts sort (needs-you first, longest-waiting first) | +| Go to a worker | `parts.waiting.goToWorker(id)` (`src/client/features/waiting/index.ts`) — closes modals, `actions.standAt(desk)` | The "pick up and walk over" half of a call | +| Open a worker's terminal | `parts.waiting.openWorkerTerminal(id)` — resumes if asleep, `openTerminal(net, id, …)` (`src/client/ui/terminal.ts`) with `doing: "💻 in 's terminal"` | The "talk" half of a call | +| Prompt a worker | `net.send({ t: 'worker.prompt', workerId, prompt })` (used by `promptAtDesk`, `openPrompt` in `src/client/ui/prompt.ts`, `openAsk` in `src/client/ui/ask.ts`) | The SMS transport — no new message type | +| Chat rendering | `renderChat` in `src/client/ui/chat.ts`, `installChat` in `src/client/features/chat/index.ts` (`T`/`Enter`, `chat` topic) | Visual language for message bubbles only (no chat-protocol reuse) | +| Voice | `installVoice` in `src/client/features/voice/index.ts` (`V` join / push-to-talk, `M` mute, WebRTC between browsers) | Explicitly **not** reused: workers are text agents with no voice peer | +| Modal system | `openModal` in `src/client/ui/dom.ts` (auto top-right ✕, `Esc` closes, `doing` line under the name tag) + `installFocus` in `src/client/input/focus.ts` (`backToGame`: closing the last modal returns straight to mouse-look, no extra click) | Phone, call screen and SMS thread are `openModal` dialogs | +| Feature-window example | `src/client/features/jukebox/` (`index.ts` + `ui.ts` + `ui.css`, `openJukebox(net, …)`, `doing: '🎵 at the jukebox'`) | Template for `smartphone/` module shape | +| HUD menu | `mountHud` list in `src/client/features/hud/index.ts`, `HudParts` in the same file | One `📱 Smartphone` entry (`section: 'Open'`, `key: 'J'`) | +| Help rows | `HELP_ROWS` in `src/client/ui/help.ts` | One `J` row | +| Sounds | Recipe fn taking `AudioCore` in the feature folder + method on `OfficeSound` (`src/client/sound/index.ts`, e.g. `features/gong/sound.ts`, `features/needsyou/sound.ts`) | Ringtone, SMS swoosh, key blips | +| Player / character | `ctx.player` (`PlayerController`), `ctx.me`/`ctx.hands`, `src/client/world/character/`, `src/client/player/` | Phone-in-hand prop while the phone is open (follow-up; MVP: HUD/modal only) | +| Registries | `ctx.keys`, `ctx.messages`, `ctx.ticks`, `store.on('')` (`src/client/core/registry.ts`, `src/client/core/context.ts`) | `ctx.keys.bind({ code: 'KeyJ', … })`; `store.on('workers')` / `store.on('smartphone')` | +| Size guard | `tests/size.test.ts` (600-line budget per `.ts`/`.css` under `src/`) | Every new file stays well under 600 lines; split `ui.ts` if it grows | +| Structure guard | `tests/client-structure.test.ts` (`main.ts` calls each feature's install once; `lite.ts` loads no three.js / `features/`) | Phone UI imports nothing from three.js, `world/`, `player/` or `features/` so `/lite` stays clean | + +Free key check: office bindings use `E P C B R X L O N F Q H T G V M K`, `WASD`/arrows, `1–6`, `/`, `Tab`, `Esc`, `Ctrl+K`, `Ctrl+Space`, `Ctrl+[`. **`J` is free** (no `KeyJ` binding anywhere; `Z` is taken by the arcade cabinet while playing). + +## 3. Decided semantics: what "call" and "SMS" mean + +### 3.1 "Call" = walk over + open a direct line (no voice) + +Workers are text agents: they have **no voice peer**, so a WebRTC call with a worker is not +possible without building a whole TTS/STT bridge (explicit follow-up, §5). And "walk to them" alone +already exists (`N` / Next worker that needs you). + +MVP call semantics — a GTA-style placed call with three beats: + +1. **Dial** — phone UI shows `Calling …`, plays the ringback tone (2 rings). +2. **Connect** — the phone modal closes, `waiting.goToWorker(id)` puts you at their desk looking + at the laptop, and `waiting.openWorkerTerminal(id)` opens their terminal with + `doing: "📱 on a call with "` (teammates see it under your name tag, like `"💻 in …"`). + Sleeping workers are resumed first (already handled inside `openWorkerTerminal`). +3. **Hang up** — closing the terminal (`✕` / `Esc`, the existing terminal behavior) ends the call; + the phone logs it in Recents. + +Edge cases: `lost` worktree → route to `actions.fixLostWorktree(w)` instead of dialing; worker gone +mid-dial → toast and back to contacts; shells (`kind: 'shell'`) are callable too (🐚 icon, same flow). + +Rejected alternatives: real-time voice with the agent (no peer; TTS/STT bridge = separate project); +walk-only (duplicates `N`); terminal-only without moving (loses the GTA "call your guy and you're +with him" fantasy). + +### 3.2 "SMS" = `worker.prompt` with a phone-thread skin + +SMS transport is the existing prompt wire message: + +```ts +net.send({ t: 'worker.prompt', workerId, prompt: text }); +``` + +It lands in the worker's input box (queued while busy), exactly like prompting at the desk. The +phone adds the SMS fantasy around it: + +- Per-worker thread view (blue/green bubbles) kept in the phone's state slice, seeded in-memory and + persisted to `localStorage` (namespaced key, same pattern as `worktreePref()` in `ui/prompt.ts`). +- `📩 SMS sent to ` toast on send; status hints derived from existing worker status + (`working` ≈ "delivered", `needs_input` ≈ "💬 replied — open the call to read it"). +- The worker's actual reply happens in its terminal/session, not in the phone (same as desk prompts). + +Rejected alternatives: sending via `chat` (that's human-to-human broadcast, wrong channel); toasts as +the message body (not conversational); server-persisted threads (needs protocol + storage — follow-up). + +## 4. MVP scope + +**In:** + +- `J` key + `📱 Smartphone` HUD/☰ entry open the phone modal (GTA iFruit parody styling, §7). +- Contacts tab: all workers on the current floor from `store.workers`, sorted with + `waitingInOrder` first then the rest alphabetically; color dot, name, `STATUS_LABEL` pill, + clipped activity, PR badge where present; tap → contact actions (Call / SMS / Go to desk). +- Call flow per §3.1 (dial → ringback → `goToWorker` + `openWorkerTerminal`, Recents log). +- SMS flow per §3.2 (thread view, send via `worker.prompt`, toast, local thread persistence). +- Recents tab (calls + sent SMS, from the slice; cleared on floor change). +- Sound recipe: ringtone loop (2 rings then stop), SMS swoosh, dial blips — synthesized, positional + only where it makes sense (phone sounds are UI-local, like `ding`). +- Help row for `J`; docs touch-up in the implementation PR (`README.md`, `docs/features.md`, + `docs/controls.md`). + +**Explicitly out (follow-ups, §5):** real voice/TTS calls, server-persisted threads, +delivery/read receipts, cross-floor contacts, photos/MMS, phone-in-hand 3D prop, `/lite` phone UI. + +**No new wire protocol, no server handler, no `FloorView` field in MVP.** Both flows ride existing +messages (`worker.prompt`, `worker.attach`/`term.*` via the terminal). If a follow-up needs pushing +SMS state to other tabs, it gets a protocol domain file + handler file then — not now. + +## 5. Follow-ups (not MVP) + +1. **Real voice calls** — TTS reads the worker's latest output, STT feeds mic back as prompts. + Needs a worker-voice peer/bridge; whole project on its own. +2. **Server-persisted SMS threads** — protocol domain (`sms.*` client msgs, `sms` server msgs), + handler in `src/server/ws/handlers/sms.ts`, `FloorView` field + view piece, slice `enter()`. +3. **Delivery/read receipts** — derive from `needs_input`/`working` transitions; toast "💬 X replied". +4. **Cross-floor contacts** — contacts across `store.floors`, SMS to another floor, call rides the + elevator first (reuse `travel`). +5. **Phone-in-hand 3D prop** — `world/character/props.ts` + `hands` pose while phone open; ring + audible to nearby players. +6. **`/lite` support** — a lite-safe contacts+SKS list; must keep `lite.ts` three.js-free. + +## 6. Architecture (registry rules) + +New self-contained feature module `src/client/features/smartphone/`: + +- `index.ts` — `installSmartphone(ctx, deps)` with + `deps: Pick` (reaches across only when something happens, per + `core/parts.ts` conventions). Binds `KeyJ`, subscribes `store.on('workers')` for the HUD badge. +- `ui.ts` — `openSmartphone(deps)` modal: contacts / call screen / thread view. Imports `./ui.css`. +- `ui.css` — phone skin next to its module. +- `sound.ts` — `ringtone(a: AudioCore, …)`, `smsSwoosh(a)`, `dialBlip(a)`; methods on `OfficeSound`. +- `logic.ts` — pure helpers (contact sort, thread append, recents) so `tests/smartphone.test.ts` + can cover them without a DOM. +- State slice `src/client/state/slices/smartphone.ts` (client-only: `init` + `on.workers` → topic + `smartphone`; `enter` resets recents/threads to the new floor). + +One-line joins only (the sanctioned "one line in each list it joins"): + +| List | One line | +| --- | --- | +| `src/client/main.ts` | `parts.smartphone = installSmartphone(ctx, parts);` after the waiting/actions installs | +| `src/client/core/parts.ts` | `smartphone: Made;` (+ type import) | +| `src/client/state/slices/index.ts` | import + `SLICES` entry **at the end** | +| `src/client/features/hud/index.ts` | one `mountHud` entry + `'smartphone'` added to `HudParts` Pick | +| `src/client/ui/help.ts` | one `HELP_ROWS` row for `J` | +| `src/client/sound/index.ts` | recipe import + `ringtone()/smsSwoosh()/dialBlip()` methods on `OfficeSound` | + +Nothing else is touched: no `server.ts`, no store core, no `protocol.ts`, no other feature's logic. + +## 7. UX flow + +1. Press `J` (or `📱 Smartphone` in HUD/☰). Phone slides up, GTA-iFruit style: rounded slab, notch, + `Tinkle 📶` carrier gag in the status bar, toon panel colors from `base.css` vars, click blip. +2. **Contacts**: workers on this floor, needs-you first (red 🙋 / green ✅ markers, same as the + banner), each row: color dot, name, status pill, clipped activity, PR badge. Empty floor: + "No contacts — hire someone first (E at an empty desk)". +3. Tap a contact → actions: `📞 Call` (primary), `💬 SMS`, `🚶 Go to desk` (no terminal). +4. **Call**: full-screen call UI (`Calling…` → `Ringing…` with ringback, `📱 with ` + timer on + connect, big red End button). Connect = close phone, stand at desk, open terminal with the + `📱 on a call with ` doing-line. End/hang-up before connect cancels with a blip. +5. **SMS**: thread view (own messages right/blue, worker status-notes left/grey — clearly labeled as + status, not their words), input box with Dictate (🎤) support via existing `dictateField`, `Enter` + sends, toast confirms. Thread persists per floor+worker in `localStorage`. +6. **Recents**: calls (outgoing, duration) + sent SMS, newest first. +7. Close via top-right `✕` or `Esc` → `backToGame` puts the player straight back into mouse-look + (inherited from `installFocus`; nothing custom). + +## 8. File-by-file changes (implementation PR) + +| File | Change | +| --- | --- | +| `src/client/features/smartphone/index.ts` | **New.** `installSmartphone(ctx, deps)`: `KeyJ` bind, `store.on('workers')`, `showSmartphone()` | +| `src/client/features/smartphone/ui.ts` | **New.** `openSmartphone(deps)`: contacts/call/SMS/recents modal via `openModal`, `doing: '📱 checking contacts'` | +| `src/client/features/smartphone/ui.css` | **New.** iFruit skin: slab, notch, green/red call buttons, SMS bubbles; selectors more specific than `base.css` | +| `src/client/features/smartphone/sound.ts` | **New.** `ringtone`/`smsSwoosh`/`dialBlip` recipes on `AudioCore` | +| `src/client/features/smartphone/logic.ts` | **New.** Pure contact-sort/thread/recents helpers | +| `src/client/state/slices/smartphone.ts` | **New.** Client-only slice: threads, recents; `init`/`on.workers`/`enter`, topic `smartphone` | +| `src/client/main.ts` | +1 install line | +| `src/client/core/parts.ts` | +1 `Parts` field (+ type import) | +| `src/client/state/slices/index.ts` | +import, +`SLICES` entry at end | +| `src/client/features/hud/index.ts` | +1 `mountHud` entry, `HudParts` Pick + `'smartphone'` | +| `src/client/ui/help.ts` | +1 `HELP_ROWS` row (`J`) | +| `src/client/sound/index.ts` | +import, +3 `OfficeSound` methods | +| `tests/smartphone.test.ts` | **New.** `logic.ts` unit tests (sort order, thread caps, recents) | +| `README.md`, `docs/features.md`, `docs/controls.md` | Feature bullet + `J` control row (implementation PR) | + +Size-guard note: each new file ≤ ~350 lines (`ui.ts` split into `contacts.ts`/`call.ts`/`sms.ts` +if it approaches the budget — never raise ceilings). + +## 9. Verification (implementation PR) + +1. `npm run typecheck` — passes (new module wired through registries; `HandlerMap` untouched). +2. `npm test` — passes, incl. new `tests/smartphone.test.ts`, `size.test.ts` (all new files < 600 + lines) and `client-structure.test.ts` (one install line, no `main.ts`/`lite.ts` imports). +3. `npm run build` — client + server build clean. +4. Headless-browser screenshot (no manual playthrough): boot dev server, open floor, evaluate + `showSmartphone()` (exposed via `parts`/debug handle like `__office`), press Call on a hired + worker, screenshot contacts + call screen + SMS thread; confirm toon styling, ✕ placement, and + `backToGame` focus return on `Esc`. + +## 10. Risks and open questions + +- **Key choice**: `J` is free today but sits near no mnemonic cluster; alternative `U` ("ring u up"). + Either is one line to change. Decision leans `J` (single-hand reach next to `H`/`K`/`N`). +- **Call vs `N` overlap**: `N` goes to whoever waits; Call goes to *whomever you choose*, then opens + the channel. Docs must say so in one line. +- **Thread-vs-terminal truth**: the phone thread is a *sent-box + status hints*, not the + conversation; the terminal stays canonical. The UI must label status-notes as such to avoid + implying the worker "texted back". +- **Modal chaining**: Call connects by closing the phone and opening the terminal — relies on + `closeAllModals` + focus `backToGame` timing (same pattern as `goToDesk`); verify no mouse-capture + gap in the screenshot pass. +- **`lite.ts` contamination**: `ui.ts` must import only `ui/*`, `state`, `shared/*` — enforced by + `client-structure.test.ts`; keep it that way. +- **Scope creep magnet**: anything with a server component (receipts, persistence, voice) is a + follow-up with its own protocol domain — not smuggled into MVP. + +## 11. Hard repo rules this plan respects + +> - "Ship every code change as a PR branched from freshly fetched `origin/main`, and end with the PR URL instead of stopping at a local commit or asking first." +> - "The main checkout is shared with other live sessions and board agents, so do branch work in a worktree and never stash, reset or commit anyone else's changes there." +> - "Verify with `npm run typecheck`, `npm test` and `npm run build`, plus a headless-browser screenshot for visual changes, rather than slow manual playthroughs." +> - "When a change affects how people run, deploy or use the office, update `README.md` and the matching `docs/*.md` page in the same PR." +> - "New features plug in through the registries as modules of their own (see `docs/code-layout.md`), never by adding their code to `main.ts`, `server.ts`, the state store, `protocol.ts` or another feature's files, and `tests/size.test.ts` must stay green." +> - "Every modal needs a top-right ✕, and closing it by ✕ or Esc must put the player straight back into mouse-look with no extra click." + +Conformance: the phone is a module of its own (`features/smartphone/` + one slice); registry files +get one join-line each, never feature logic; all new files stay far under the 600-line budget; the +phone is an `openModal` dialog (automatic top-right ✕, `Esc`, `backToGame` via `installFocus`); the +implementation PR updates `README.md` + `docs/features.md` + `docs/controls.md` and ends with a PR URL. + +## 12. Rollout + +- This plan lands as a **draft PR** containing only `docs/plans/smartphone.md` (no game code). +- The implementation follows as a separate PR from a fresh branch off `origin/main`, implementing + §8 file-by-file, verifying per §9, and reporting the PR URL with a screenshot. From 5817ca9742b59d6087782ca5205779bea588327f Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 05:23:24 +0200 Subject: [PATCH 2/7] GTA-style smartphone: call workers, SMS them from your iFruit J (or the Smartphone HUD entry) opens a phone whose contacts are the workers on the floor. A call rings twice, then puts you at their desk with the terminal open; an SMS sends a worker.prompt and is kept in a per-floor thread in the browser. No new wire protocol: both ride existing messages. New self-contained features/smartphone/ module plus one-line registry joins, J help row, synthesized ring/swoosh/blip, unit tests and docs. --- README.md | 2 + docs/controls.md | 1 + docs/features.md | 1 + src/client/core/parts.ts | 2 + src/client/features/hud/index.ts | 3 +- src/client/features/smartphone/index.ts | 26 ++ src/client/features/smartphone/logic.ts | 38 +++ src/client/features/smartphone/sound.ts | 72 ++++++ src/client/features/smartphone/ui.css | 39 +++ src/client/features/smartphone/ui.ts | 304 ++++++++++++++++++++++++ src/client/lab/phone.html | 16 ++ src/client/lab/phone.ts | 46 ++++ src/client/main.ts | 2 + src/client/sound/index.ts | 16 ++ src/client/state/slices/smartphone.ts | 24 ++ src/client/ui/help.ts | 1 + src/shared/smartphone.ts | 64 +++++ tests/client-store.test.ts | 4 +- tests/smartphone.test.ts | 65 +++++ 19 files changed, 723 insertions(+), 3 deletions(-) create mode 100644 src/client/features/smartphone/index.ts create mode 100644 src/client/features/smartphone/logic.ts create mode 100644 src/client/features/smartphone/sound.ts create mode 100644 src/client/features/smartphone/ui.css create mode 100644 src/client/features/smartphone/ui.ts create mode 100644 src/client/lab/phone.html create mode 100644 src/client/lab/phone.ts create mode 100644 src/client/state/slices/smartphone.ts create mode 100644 src/shared/smartphone.ts create mode 100644 tests/smartphone.test.ts diff --git a/README.md b/README.md index 0c8d672ba..9474035aa 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. +- **A smartphone.** Press **J** to pull out your iFruit: call a worker (you go to their desk with its terminal open) or send one an SMS (a prompt it answers in its terminal). - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). @@ -376,6 +377,7 @@ deploy/coolify.sh ssh 'node /opt/agent-office/bin/agent-office.js accounts invit | T / Enter | Chat | | V | Join voice; then hold V to talk | | M | Mute / unmute in voice | +| J | Smartphone: call a worker or send one an SMS | | Ctrl + Space | Dictate into a terminal or a prompt box: hold it and talk (or hold the **🎤**) | | Tab | The ☰ menu: every window | | Esc | Close any window | diff --git a/docs/controls.md b/docs/controls.md index 808603aae..b18743b1d 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -26,6 +26,7 @@ Back to the [README](../README.md). | Ctrl + K (⌘K on a Mac) | Command palette: find a worker, issue, PR, service, board, teammate or action; Enter opens it, Shift+Enter walks you there first | | V | Join voice; in voice, hold to talk (you're muted when you let go) | | M | Mute / unmute in voice | +| J | Smartphone: call a worker (you go to their desk with its terminal open) or send one an SMS (a prompt it answers in its terminal) | | Ctrl + Space | Dictate, in a worker's terminal or a prompt box: hold it and talk, and what you said is typed in when you let go. A quick tap leaves it listening until the next tap. The **🎤** does the same | | Tab | The ☰ menu: every window, and what shows on screen | | Esc | Close any window (a terminal too) and get back to looking around | diff --git a/docs/features.md b/docs/features.md index f320d9b99..0076b6084 100644 --- a/docs/features.md +++ b/docs/features.md @@ -61,6 +61,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists and the project's own pictures. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. - **An office dog.** Every floor has a dog. It naps under the desks of workers who are busy, trots after people for a while, sniffs around and hangs out on the lounge rug. When a worker needs input, it drops everything, runs to that desk and barks until someone opens that worker's terminal, so you can see from across the room who's waiting. It stays there until the worker is answered. Walk up and press **E** to pet it, and everyone sees it wag. Name it under **⚙️** → *Office dog*, and pick its breed (a pup, a corgi, a dachshund, a pug, a shiba or a pomeranian) and its coat there too: they're for everyone on that floor. Until someone picks them, each floor's dog is a breed and coat of its own. - **Sent home with a box.** A worker you send home (**X** at its desk) doesn't just vanish. Its light goes out, its face falls, and its things go into a cardboard box: a plant, a framed photo, its mug and a rubber duck. Its laptop snaps shut, and it hops off its chair and trudges out with the box in its arms, out the exit door, down the steps and off along the sidewalk. Floors above the bottom one have no exit door, so there it walks out onto the balcony instead, climbs up on the railing and jumps: its parachute pops open and it circles down onto the lot out front, box and all, then walks off along the sidewalk. The desk is free straight away, so you can hire someone new while the last one is still on the way out. In the castle it goes to the dungeon instead and on the space station out of the airlock, and a map of your own can script its own way of seeing workers off ([Sending workers home](maps.md#sending-workers-home)). +- **A smartphone.** Press **J** to pull out your iFruit: its contacts are the workers on your floor, the ones waiting on you first. **Call** one and you're put at its desk with its terminal open; send one an **SMS** and it lands in its input box like a prompt at its desk (its answer comes in its terminal, not on the phone). Threads are kept in your browser, recents per floor. - **Voice.** Browser-to-browser WebRTC voice. Volume depends on how close you stand, but people are never fully silent. **V** joins, and once you're in, **V** is push to talk: hold it to talk, and you're muted when you let go. **M** mutes and unmutes. Pick **Push to talk** under **⚙️** to join muted, or keep an open mic. Leave voice from the **☰** menu. - **Office sounds.** Busy workers clatter away at their keyboards, footsteps knock across the planks (and tick on the garage's concrete, swish through the grass and echo off the castle's flagstones, quicker and harder at a run), the fridge hums, birds chirp outside the windows by day and crickets at night, rain patters on the glass, thunder rolls in a storm, the coffee machine grinds and gurgles, the gong booms when a PR merges and the dog barks. It's all synthesized in the browser and placed where it happens, so it gets louder as you walk closer. Turn it down or mute it under **⚙️**, which also covers the worker dings but not voice chat. - **Arcade cabinet.** Workers take minutes, so there's an arcade cabinet in the lounge, next to the jukebox. Press **E** at it and the camera glides up to its screen to play BLOCKFALL, a falling-blocks game: arrows (or WASD) move and turn, **Space** drops, **C** holds a piece for later, **P** pauses. Everyone else on your floor sees your game on the cabinet as you play, and anyone can walk up and press **E** to watch it up close over your shoulder. When one of your workers needs input, the game pauses and says which one, with a button to its terminal. Walk away and your game waits, paused, until you come back. The high-score table is the whole building's: it shows on every floor's cabinet, and it survives restarts. diff --git a/src/client/core/parts.ts b/src/client/core/parts.ts index 09c274ae2..b96c1a4c7 100644 --- a/src/client/core/parts.ts +++ b/src/client/core/parts.ts @@ -37,6 +37,7 @@ import type { installNeedsYou } from '../features/needsyou'; import type { installPeers } from '../features/peers'; import type { installRooftop } from '../features/rooftop'; import type { installSeating } from '../features/seating'; +import type { installSmartphone } from '../features/smartphone'; import type { installSmoke } from '../features/smoke'; import type { installTelescope } from '../features/telescope'; import type { installTv } from '../features/tv'; @@ -114,6 +115,7 @@ export interface Parts { waiting: Made; needsYou: Made; meeting: Made; + smartphone: Made; bookshelf: Made; bar: Made; coffee: Made; diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index a54730173..e38856ca4 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -25,7 +25,7 @@ import { openUpgrade } from '../../ui/upgrade'; import { openWhiteboard } from '../whiteboard/ui'; import { describeSky } from '../../world/sky'; -export type HudParts = Pick; +export type HudParts = Pick; /** Listens for clicks on the HUD and the project, registers what the HUD follows (see mountHud), and binds Tab, H and F. */ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { @@ -52,6 +52,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'issues', icon: '📌', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, + { id: 'smartphone', icon: '📱', label: 'Smartphone', section: 'Open', key: 'J', count: () => waitingInOrder(store.workers.values()).length, title: () => 'Call a worker or send one an SMS', run: () => parts.smartphone.showSmartphone() }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, // Up on the top bar while a meeting is on: what's being worked through in the meeting room. diff --git a/src/client/features/smartphone/index.ts b/src/client/features/smartphone/index.ts new file mode 100644 index 000000000..7e6ab6b0d --- /dev/null +++ b/src/client/features/smartphone/index.ts @@ -0,0 +1,26 @@ +/** + * The player's GTA-style smartphone: J (or 📱 in the menu) opens it. Contacts are the workers on + * this floor; a call puts you at their desk with their terminal open, and an SMS is a prompt sent + * to their session. Both ride existing messages, so the phone keeps no server state of its own. + */ +import type { Ctx } from '../../core/context'; +import type { Parts } from '../../core/parts'; +import { openSmartphone } from './ui'; + +/** Binds J and opens the phone, reaching the waiting room and the desk actions only when used. */ +export function installSmartphone(ctx: Ctx, parts: Pick) { + function showSmartphone() { + openSmartphone({ + net: ctx.net, + goToWorker: (id) => parts.waiting.goToWorker(id), + openWorkerTerminal: (id) => parts.waiting.openWorkerTerminal(id), + fixLostWorktree: (w) => parts.actions.fixLostWorktree(w), + sound: ctx.sound, + }); + } + ctx.keys.bind({ + code: 'KeyJ', + run: () => showSmartphone(), + }); + return { showSmartphone }; +} diff --git a/src/client/features/smartphone/logic.ts b/src/client/features/smartphone/logic.ts new file mode 100644 index 000000000..1120c9a8c --- /dev/null +++ b/src/client/features/smartphone/logic.ts @@ -0,0 +1,38 @@ +// The smartphone's pure helpers: what a contact row says, and what the SMS view may promise. +// Nothing here touches the DOM or the network, so tests cover it without a browser. + +import type { WorkerInfo } from '../../../shared/protocol'; + +/** The icon before a contact's name: a shell is a shared login shell, the rest are agents. */ +export function kindIcon(w: Pick): string { + return w.kind === 'shell' ? '🐚' : '🤖'; +} + +/** The line under a contact's name: what it's doing now, or where its work stands. */ +export function contactSub(w: Pick): string | undefined { + if (w.lost) return '🌿 worktree deleted — tap to fix it'; + if (w.activity) return w.activity; + if (w.pr) return `🔀 PR #${w.pr.number}`; + return undefined; +} + +/** + * What the SMS view may honestly say about delivery, from the worker's status alone: a prompt lands + * in its input box (queued while it's busy); only its terminal shows the answer. Sleeping workers + * can't take one until someone wakes them (see actions.promptAtDesk). + */ +export function statusNote(w: Pick): string { + switch (w.status) { + case 'needs_input': + return `💬 ${w.name} might have answered — call to read it`; + case 'working': + case 'starting': + return '📩 delivered — working on it'; + case 'done': + return '📩 delivered — done, call to follow up'; + case 'idle': + return '📩 delivered — ready when you are'; + default: + return `💤 ${w.name} is asleep — wake it (R at its desk, or call) before texting`; + } +} diff --git a/src/client/features/smartphone/sound.ts b/src/client/features/smartphone/sound.ts new file mode 100644 index 000000000..36f562966 --- /dev/null +++ b/src/client/features/smartphone/sound.ts @@ -0,0 +1,72 @@ +import type { AudioCore } from '../../sound/core'; +import { biquad } from '../../sound/dsp'; + +// The smartphone's sounds, synthesized like the rest of the office: a double ring for placing a +// call, a swoosh for an SMS going out, and a blip for tapping through the phone. UI-local, like the +// worker dings: they come out of the alerts bus, not from anywhere in the room. + +/** An old landline's two-tone ring (440 + 480 Hz), rung twice, for placing a call. */ +export function phoneRing(a: AudioCore) { + a.unlock(); + const ctx = a.ctx; + if (!ctx) return; + if (ctx.state === 'suspended') void ctx.resume(); + a.count('phone-ring'); + for (const at of [0, 0.9]) { + const t0 = ctx.currentTime + at; + for (const f of [440, 480]) { + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.type = 'sine'; + o.frequency.value = f; + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(0.22, t0 + 0.02); + g.gain.setValueAtTime(0.22, t0 + 0.55); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.65); + o.connect(g).connect(a.alerts); + o.start(t0); + o.stop(t0 + 0.7); + } + } +} + +/** An SMS going out: a short whoosh upward. */ +export function smsSwoosh(a: AudioCore) { + a.unlock(); + const ctx = a.ctx; + if (!ctx) return; + if (ctx.state === 'suspended') void ctx.resume(); + a.count('sms-swoosh'); + const t0 = ctx.currentTime + 0.01; + const noise = a.noise(a.buf.white); + const bp = biquad(ctx, 'bandpass', 900, 1.4); + bp.frequency.setValueAtTime(900, t0); + bp.frequency.exponentialRampToValueAtTime(4200, t0 + 0.22); + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(0.3, t0 + 0.05); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.25); + noise.connect(bp).connect(g).connect(a.alerts); + noise.start(t0); + noise.stop(t0 + 0.3); +} + +/** Tapping through the phone: one soft key blip. */ +export function dialBlip(a: AudioCore) { + a.unlock(); + const ctx = a.ctx; + if (!ctx) return; + if (ctx.state === 'suspended') void ctx.resume(); + a.count('dial-blip'); + const t0 = ctx.currentTime + 0.01; + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.type = 'triangle'; + o.frequency.value = 1350; + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(0.12, t0 + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.07); + o.connect(g).connect(a.alerts); + o.start(t0); + o.stop(t0 + 0.1); +} diff --git a/src/client/features/smartphone/ui.css b/src/client/features/smartphone/ui.css new file mode 100644 index 000000000..03f377808 --- /dev/null +++ b/src/client/features/smartphone/ui.css @@ -0,0 +1,39 @@ +/* The player's smartphone: a GTA iFruit-style slab with contacts, a call screen and SMS bubbles */ +.modal.smartphone { width: min(380px, 100%); overflow: hidden; } +.modal.smartphone > header { position: relative; } +.sp-back { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); font-size: 22px; line-height: 1; padding: 0 8px; } +.sp-back.hidden { display: none; } +.sp-status { display: flex; justify-content: space-between; padding: 2px 14px 0; font-size: 12px; font-weight: 700; color: var(--muted); } +.sp-tabs { display: flex; gap: 6px; padding: 8px 14px 0; } +.sp-tab { flex: 1; border: 3px solid var(--ink); border-radius: 12px; background: var(--paper-2); font-weight: 800; padding: 4px 0; cursor: pointer; } +.sp-tab.on { background: var(--ink); color: var(--paper); } +.sp-count { display: inline-block; min-width: 20px; margin-left: 6px; border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 12px; padding: 0 5px; } +.sp-tab.on .sp-count { background: var(--paper-2); } +.sp-body { display: flex; flex-direction: column; gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 10px 14px 14px; } +.sp-contact { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 3px solid var(--ink); border-radius: 14px; background: var(--paper-2); padding: 8px 10px; cursor: pointer; } +.sp-main { flex: 1; min-width: 0; } +.sp-name { font-weight: 800; } +.sp-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sp-dot { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--ink); display: inline-flex; align-items: center; justify-content: center; font-size: 14px; } +.sp-bigdot { flex: none; width: 46px; height: 46px; border-radius: 50%; border: 3px solid var(--ink); } +.sp-pill { flex: none; font-size: 12px; font-weight: 800; border: 2px solid var(--ink); border-radius: 10px; padding: 1px 8px; background: var(--paper); } +.sp-needs { background: #ff5a5a; color: #fff; } +.sp-done { background: #7ddf8e; } +.sp-pr { margin-left: 6px; font-size: 12px; font-weight: 700; } +.sp-when { flex: none; font-size: 12px; color: var(--muted); } +.sp-empty { margin: 12px 4px; font-weight: 700; color: var(--muted); } +.sp-who { display: flex; align-items: center; gap: 10px; padding: 4px 2px; } +.sp-actions { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; } +.sp-actions .btn { width: 100%; } +.sp-call { font-size: 16px; } +.sp-hang { font-size: 16px; } +.sp-calling { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 0 4px; text-align: center; } +.sp-calling .sp-bigdot { width: 64px; height: 64px; } +.sp-phase { font-weight: 800; color: var(--muted); } +.sp-thread { display: flex; flex-direction: column; gap: 6px; padding: 4px 2px; overflow-y: auto; } +.sp-bubble { max-width: 85%; border: 2px solid var(--ink); border-radius: 14px; padding: 5px 10px; font-size: 14px; overflow-wrap: anywhere; } +.sp-out { align-self: flex-end; background: #3b9dff; color: #fff; border-bottom-right-radius: 4px; } +.sp-note { align-self: center; background: var(--paper); color: var(--muted); font-size: 12px; font-style: italic; } +.sp-hint { font-size: 12px; color: var(--muted); text-align: center; } +.sp-compose { display: flex; gap: 6px; } +.sp-compose input { flex: 1; min-width: 0; } diff --git a/src/client/features/smartphone/ui.ts b/src/client/features/smartphone/ui.ts new file mode 100644 index 000000000..242043359 --- /dev/null +++ b/src/client/features/smartphone/ui.ts @@ -0,0 +1,304 @@ +import './ui.css'; +import type { WorkerInfo } from '../../../shared/protocol'; +import { appendSms, logRecent, saveThreads, threadKey } from '../../../shared/smartphone'; +import { isAsleep } from '../../../shared/status'; +import { byUrgency } from '../../nextup'; +import { store } from '../../state'; +import { clip, h, openModal, STATUS_LABEL, timeAgo, toast } from '../../ui/dom'; +import { dictateField } from '../../ui/dictate'; +import { contactSub, kindIcon, statusNote } from './logic'; + +export interface SmartphoneDeps { + net: { send(msg: { t: 'worker.prompt'; workerId: string; prompt: string }): void }; + /** Puts you at the worker's desk; false when there's no getting there. */ + goToWorker(id: string): boolean; + openWorkerTerminal(id: string): void; + fixLostWorktree(w: WorkerInfo): void; + sound: { phoneRing(): void; smsSwoosh(): void; dialBlip(): void }; +} + +type View = { t: 'contacts' } | { t: 'recents' } | { t: 'actions'; id: string } | { t: 'calling'; id: string } | { t: 'thread'; id: string }; + +/** A placed call connects once the ringback has rung twice. */ +const CONNECT_MS = 2600; + +/** + * The player's GTA-style smartphone: contacts are the workers on this floor, a call puts you at + * their desk with their terminal open, and an SMS is a prompt sent to their session. + */ +export function openSmartphone(deps: SmartphoneDeps) { + let view: View = { t: 'contacts' }; + let timers: ReturnType[] = []; + const clearTimers = () => { + for (const t of timers) clearTimeout(t); + timers = []; + }; + + const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); + const back = h('button.btn.sp-back', { type: 'button', 'aria-label': 'Back', title: 'Back' }, '‹'); + const tabs = h('nav.sp-tabs', { 'aria-label': 'Phone' }); + const body = h('div.sp-body'); + const el = h( + 'div.modal.smartphone', + { role: 'dialog', 'aria-label': 'Smartphone' }, + h('header', {}, back, h('h2', {}, '📱 iFruit'), close), + h('div.sp-status', {}, h('span', {}, 'Tinkle 📶'), h('span', {}, '🔋 100%')), + tabs, + body, + ); + + const worker = (id: string) => store.workers.get(id); + + function draw() { + clearTimers(); + const contacts = byUrgency(store.workers.values()); + const recents = store.smartphone.recents; + back.classList.toggle('hidden', view.t === 'contacts' || view.t === 'recents'); + tabs.replaceChildren( + tab('Contacts', contacts.length, view.t === 'contacts' || view.t === 'actions' || view.t === 'calling' || view.t === 'thread', () => { + view = { t: 'contacts' }; + deps.sound.dialBlip(); + draw(); + }), + tab('Recents', recents.length, view.t === 'recents', () => { + view = { t: 'recents' }; + deps.sound.dialBlip(); + draw(); + }), + ); + if (view.t === 'contacts') renderContacts(contacts); + else if (view.t === 'recents') renderRecents(); + else if (view.t === 'actions') renderActions(view.id); + else if (view.t === 'calling') renderCalling(view.id); + else renderThread(view.id); + } + + const tab = (label: string, n: number, on: boolean, go: () => void) => h('button.sp-tab', { type: 'button', class: on ? 'on' : '', 'aria-pressed': String(on), onclick: go }, label, n ? h('span.sp-count', {}, String(n)) : null); + + function pill(w: WorkerInfo) { + const cls = w.status === 'needs_input' ? 'sp-pill sp-needs' : w.status === 'done' ? 'sp-pill sp-done' : 'sp-pill'; + return h('span', { class: cls }, STATUS_LABEL[w.status] ?? w.status); + } + + function renderContacts(contacts: WorkerInfo[]) { + if (!contacts.length) { + body.replaceChildren(h('p.sp-empty', {}, '📵 No contacts — hire someone first (E at an empty desk).')); + return; + } + body.replaceChildren( + ...contacts.map((w) => { + const sub = contactSub(w); + return h( + 'button.sp-contact', + { + type: 'button', + onclick: () => { + view = { t: 'actions', id: w.id }; + deps.sound.dialBlip(); + draw(); + }, + }, + h('span.sp-dot', { style: `background:${w.color}` }), + h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr && w.activity ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), + pill(w), + ); + }), + ); + } + + function renderRecents() { + const recents = store.smartphone.recents; + if (!recents.length) { + body.replaceChildren(h('p.sp-empty', {}, 'No calls or texts yet — tap a contact to ring them.')); + return; + } + body.replaceChildren( + ...recents.map((r) => + h( + 'button.sp-contact', + { + type: 'button', + onclick: () => { + if (!worker(r.workerId)) { + toast(`${r.name} went home`, 'warn'); + return; + } + view = { t: 'actions', id: r.workerId }; + deps.sound.dialBlip(); + draw(); + }, + }, + h('span.sp-dot', {}, r.kind === 'call' ? '📞' : '💬'), + h('div.sp-main', {}, h('div.sp-name', {}, r.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), + h('span.sp-when', { title: new Date(r.at).toLocaleString() }, timeAgo(r.at)), + ), + ), + ); + } + + function renderActions(id: string) { + const w = worker(id); + if (!w) { + view = { t: 'contacts' }; + return draw(); + } + const sub = contactSub(w); + body.replaceChildren( + h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${w.color}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), + h( + 'div.sp-actions', + {}, + h('button.btn.primary.sp-call', { type: 'button', onclick: () => startCall(w) }, '📞 Call'), + h( + 'button.btn.sp-sms', + { + type: 'button', + onclick: () => { + view = { t: 'thread', id: w.id }; + deps.sound.dialBlip(); + draw(); + }, + }, + '💬 SMS', + ), + h( + 'button.btn', + { + type: 'button', + title: 'Walk over without opening anything', + onclick: () => { + if (!deps.goToWorker(w.id)) toast(`Couldn't get to ${w.name}'s desk`, 'warn'); + }, + }, + '🚶 Go to desk', + ), + ), + ); + } + + function startCall(w: WorkerInfo) { + view = { t: 'calling', id: w.id }; + draw(); + deps.sound.phoneRing(); + timers.push( + setTimeout(() => { + const now = worker(w.id); + if (!now) { + toast(`${w.name} went home`, 'warn'); + view = { t: 'contacts' }; + return draw(); + } + if (now.lost && now.worktree) { + modal.close(); + deps.fixLostWorktree(now); + return; + } + const st = store.smartphone; + st.recents = logRecent(st.recents, { kind: 'call', workerId: now.id, name: now.name, at: Date.now() }); + store.emit('smartphone'); + if (!deps.goToWorker(now.id)) { + toast(`Couldn't get to ${now.name}'s desk`, 'warn'); + view = { t: 'contacts' }; + return draw(); + } + deps.openWorkerTerminal(now.id); + }, CONNECT_MS), + ); + } + + function renderCalling(id: string) { + const w = worker(id); + if (!w) { + view = { t: 'contacts' }; + return draw(); + } + const phase = h('div.sp-phase', {}, 'Dialing…'); + timers.push(setTimeout(() => phase.replaceChildren('Ringing…'), 800)); + body.replaceChildren( + h('div.sp-calling', {}, h('span.sp-bigdot', { style: `background:${w.color}` }), h('div.sp-name', {}, w.name), phase, h('div.sp-sub', {}, `${STATUS_LABEL[w.status] ?? w.status} · connects into their terminal`)), + h( + 'div.sp-actions', + {}, + h( + 'button.btn.danger.sp-hang', + { + type: 'button', + onclick: () => { + view = { t: 'actions', id }; + deps.sound.dialBlip(); + draw(); + }, + }, + '📵 End', + ), + ), + ); + } + + function renderThread(id: string) { + const w = worker(id); + if (!w) { + view = { t: 'contacts' }; + return draw(); + } + const key = threadKey(store.floor, id); + const thread = store.smartphone.threads[key] ?? []; + const asleep = isAsleep(w.status); + const input = h('input', { type: 'text', placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; + input.toggleAttribute('disabled', asleep); + const sendBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); + sendBtn.toggleAttribute('disabled', asleep); + const form = h('form.sp-compose', {}, dictateField(input), sendBtn); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const text = input.value.trim(); + const now = worker(id); + if (!text || !now || isAsleep(now.status)) return; + deps.net.send({ t: 'worker.prompt', workerId: now.id, prompt: text }); + const st = store.smartphone; + st.threads[key] = appendSms(st.threads[key], text); + saveThreads(st.threads); + st.recents = logRecent(st.recents, { kind: 'sms', workerId: now.id, name: now.name, at: Date.now() }); + store.emit('smartphone'); + deps.sound.smsSwoosh(); + toast(`📩 SMS sent to ${now.name}`); + input.value = ''; + draw(); + (body.querySelector('input') as HTMLInputElement | null)?.focus(); + }); + const msgs = h( + 'div.sp-thread', + {}, + ...thread.map((m) => (m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text))), + ); + body.replaceChildren(h('div.sp-who', {}, h('span.sp-dot', { style: `background:${w.color}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, h('div.sp-hint', {}, statusNote(w)), form); + msgs.scrollTop = msgs.scrollHeight; + setTimeout(() => input.focus(), 30); + } + + back.addEventListener('click', () => { + if (view.t === 'actions' || view.t === 'thread') view = view.t === 'thread' && worker(view.id) ? { t: 'actions', id: view.id } : { t: 'contacts' }; + else view = { t: 'contacts' }; + deps.sound.dialBlip(); + draw(); + }); + + // The workers change under the phone (a hire, a status flip): redraw the lists, but never a view + // with focus or timers in it (the thread's composer, the call being placed). + const onStore = () => { + if (view.t === 'thread' || view.t === 'calling') return; + draw(); + }; + const offWorkers = store.on('workers', onStore); + const offPhone = store.on('smartphone', onStore); + const modal = openModal(el, { + doing: '📱 checking contacts', + onClose: () => { + clearTimers(); + offWorkers(); + offPhone(); + }, + }); + close.addEventListener('click', () => modal.close()); + draw(); +} diff --git a/src/client/lab/phone.html b/src/client/lab/phone.html new file mode 100644 index 000000000..9e9ad4388 --- /dev/null +++ b/src/client/lab/phone.html @@ -0,0 +1,16 @@ + + + + + Smartphone lab + + + + +
+ + + + diff --git a/src/client/lab/phone.ts b/src/client/lab/phone.ts new file mode 100644 index 000000000..65f996dc9 --- /dev/null +++ b/src/client/lab/phone.ts @@ -0,0 +1,46 @@ +// Screenshots the smartphone UI without booting the office: seeds the store with three workers and +// opens the phone on its contacts (or ?sms=1 to walk the real UI into Byte's SMS thread). +import '../styles/base.css'; +import type { WorkerInfo } from '../../shared/protocol'; +import { store } from '../state'; +import { openSmartphone } from '../features/smartphone/ui'; + +function seed(): WorkerInfo[] { + const now = Date.now(); + const base = { kind: 'agent' as const, acked: false, createdBy: 'Sam', cols: 80, rows: 24, viewers: [] as string[], viewerIds: [] as string[] }; + const workers: WorkerInfo[] = [ + { ...base, id: 'w1', deskId: 'd1', name: 'Byte', color: '#4f86f7', status: 'needs_input', waitingSince: now - 240_000, createdAt: now - 3_600_000, activity: 'Wants permission: Bash: npm test' }, + { ...base, id: 'w2', deskId: 'd2', name: 'Pixel', color: '#e07a5f', status: 'working', createdAt: now - 1_800_000, activity: 'Editing src/client' }, + { ...base, id: 'w3', deskId: 'd3', name: 'Mochi', color: '#81b29a', status: 'done', acked: true, waitingSince: now - 600_000, createdAt: now - 900_000, pr: { number: 12, url: 'https://example.com/pr/12' } }, + ]; + for (const w of workers) store.workers.set(w.id, w); + return workers; +} + +seed(); +store.floor = 'f1'; +// Like a browser without speech recognition (Firefox): no 🎤 on the composer, so the lab never +// touches the recognizer (whose on-device check crashes headless Chromium). +for (const k of ['SpeechRecognition', 'webkitSpeechRecognition'] as const) delete (window as unknown as Record)[k]; +openSmartphone({ + net: { send: () => {} }, + goToWorker: () => true, + openWorkerTerminal: () => {}, + fixLostWorktree: () => {}, + sound: { phoneRing: () => {}, smsSwoosh: () => {}, dialBlip: () => {} }, +}); + +// The SMS thread through the real taps: first contact, then its SMS button (or its Call button). +const params = new URLSearchParams(location.search); +if (params.has('sms') || params.has('call')) { + const pick = (sel: string) => document.querySelector(sel); + pick('.sp-contact')?.click(); + pick(params.has('call') ? '.sp-call' : '.sp-sms')?.click(); +} + +setTimeout( + () => { + ((window as unknown as { __ready: unknown }).__ready = { phone: params.has('sms') ? 'thread' : params.has('call') ? 'calling' : 'contacts' }); + }, + params.has('call') ? 1500 : 400, +); diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..c8b9aa45e 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -54,6 +54,7 @@ import { installPalette } from './features/palette'; import { installPeers } from './features/peers'; import { installRooftop } from './features/rooftop'; import { installSeating } from './features/seating'; +import { installSmartphone } from './features/smartphone'; import { installSmoke } from './features/smoke'; import { installLamplight } from './features/lamplight'; import { installTelescope } from './features/telescope'; @@ -147,6 +148,7 @@ parts.waiting = installWaiting(ctx, core, parts); parts.needsYou = installNeedsYou(ctx, parts); installPalette(ctx, parts); parts.meeting = installMeeting(ctx, parts); +parts.smartphone = installSmartphone(ctx, parts); parts.bookshelf = installBookshelf(ctx); installHerald(ctx, parts); diff --git a/src/client/sound/index.ts b/src/client/sound/index.ts index 321c2ea74..c86221b71 100644 --- a/src/client/sound/index.ts +++ b/src/client/sound/index.ts @@ -29,6 +29,7 @@ import { golf, type GolfSound } from '../features/golf/sound'; import { gong } from '../features/gong/sound'; import { Jukebox, type JukeboxPlay } from '../features/jukebox/sound'; import { needsYou } from '../features/needsyou/sound'; +import { dialBlip, phoneRing, smsSwoosh } from '../features/smartphone/sound'; import type { Pos } from './places'; import { Footsteps, pageTurn, paper } from './steps'; import { toss, type TossSound } from '../features/bargames/sound'; @@ -273,6 +274,21 @@ export class OfficeSound { needsYou(this.a, again); } + /** The smartphone's double ring for a call you place. */ + phoneRing() { + phoneRing(this.a); + } + + /** An SMS going out. */ + smsSwoosh() { + smsSwoosh(this.a); + } + + /** Tapping through the phone. */ + dialBlip() { + dialBlip(this.a); + } + // ---- The rooftop bar (features/bar) ------------------------------------------------------------- /** The DJ's set on the roof, `clock` saying how far into it it is (see djTime); null stops it. */ diff --git a/src/client/state/slices/smartphone.ts b/src/client/state/slices/smartphone.ts new file mode 100644 index 000000000..bd737531c --- /dev/null +++ b/src/client/state/slices/smartphone.ts @@ -0,0 +1,24 @@ +import { loadThreads } from '../../../shared/smartphone'; +import type { RecentEntry, SmsMsg } from '../../../shared/smartphone'; +import type { Slice, Store } from '../store'; + +declare module '../store' { + interface Store { + /** The smartphone's SMS threads by floor/worker, and its recent calls and texts (per floor). */ + smartphone: { threads: Record; recents: RecentEntry[] }; + } + interface Topics { + smartphone: true; + } +} + +export const smartphone: Slice = { + init(s: Store) { + s.smartphone = { threads: loadThreads(), recents: [] }; + }, + enter(s: Store) { + // A new floor: recents start over; threads stay (they're keyed by floor). + s.smartphone = { threads: s.smartphone.threads, recents: [] }; + return ['smartphone']; + }, +}; diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index ac1f51c5a..6a0328d2b 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -16,6 +16,7 @@ export const HELP_ROWS: readonly (readonly [string, string])[] = [ ['🕹️', 'The arcade cabinet in the lounge plays BLOCKFALL: arrows (or WASD) move and turn, Space drops, C holds, P pauses. Everyone on the floor sees your game on it, and E there watches whoever is playing. One of your workers needing input pauses it'], ['🎉', 'Whenever a pull request merges, the gong next to the PR board rings, confetti rains down all over the floor and every worker gets up on its desk for a quick dance. Walk up to the gong and press E to bang it yourself'], ['N', "Next worker that needs you: go to whoever needs input (they have a red beacon over their desk, and a banner up top says what each is asking), then whoever is done and nobody's looked at, longest-waiting first, and again for the next one. Arrows at the edge of the screen point to the ones out of sight"], + ['J', 'Smartphone: call a worker (you go to their desk with their terminal open) or send one an SMS (a prompt it answers in its terminal)'], ['🏀', 'The hoop on the west wall, by the exit door: E at the ball picks it up. Hold E (or the mouse, in first person) and let go when the meter is in the green to sink it. In first person it goes where you look. Q drops it. Everyone on your floor sees your shot'], ['🏎️', "The Lambos and Ferraris in the garage: E at one gets you behind the wheel, or beside whoever's driving it. W is the gas, S brakes and reverses, A and D steer, Space brakes, H honks and E gets you out. Everyone on your floor sees you drive by"], ['🍸', 'The elevator goes up to the rooftop bar: a DJ playing drum and bass under the lights, and the city all around. Press E at the bar for a drink (it goes to your head for a bit) and at the DJ booth for the air horn'], diff --git a/src/shared/smartphone.ts b/src/shared/smartphone.ts new file mode 100644 index 000000000..bb31f9f49 --- /dev/null +++ b/src/shared/smartphone.ts @@ -0,0 +1,64 @@ +// The player's smartphone: SMS threads and recent calls. Pure data and local persistence both the +// browser's slice and the phone UI use; the server never touches it (SMS rides `worker.prompt`). + +/** One line in an SMS thread: what you sent, or a note the phone adds itself (never the worker's words). */ +export interface SmsMsg { + dir: 'out' | 'note'; + text: string; + /** When it was sent, on Date.now()'s clock. */ + at: number; +} + +/** One line in Recents: a call you placed or an SMS you sent. */ +export interface RecentEntry { + kind: 'call' | 'sms'; + workerId: string; + name: string; + at: number; +} + +/** How many messages a thread keeps per worker, and how many lines Recents keeps. */ +export const MAX_THREAD = 100; +export const MAX_RECENTS = 20; + +const THREAD_KEY = 'agent-office.smartphone.threads'; + +/** Threads by `floor/worker`, so each floor keeps its own conversations. */ +export function threadKey(floor: string | null, workerId: string): string { + return `${floor ?? 'lobby'}/${workerId}`; +} + +/** A thread with `text` appended (a line the phone adds itself is a `note`). */ +export function appendSms(thread: SmsMsg[] | undefined, text: string, dir: SmsMsg['dir'] = 'out'): SmsMsg[] { + return [...(thread ?? []), { dir, text, at: Date.now() }].slice(-MAX_THREAD); +} + +/** Recents with `entry` on top, one line per worker and kind at most. */ +export function logRecent(recents: RecentEntry[], entry: RecentEntry): RecentEntry[] { + return [entry, ...recents.filter((r) => r.workerId !== entry.workerId || r.kind !== entry.kind)].slice(0, MAX_RECENTS); +} + +/** The threads the browser kept, or none (private mode, or nothing sent yet). */ +export function loadThreads(): Record { + try { + if (typeof localStorage === 'undefined') return {}; + const raw = localStorage.getItem(THREAD_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) if (Array.isArray(v)) out[k] = v.slice(-MAX_THREAD); + return out; + } catch { + return {}; + } +} + +/** Keeps the threads in the browser. Never throws (private mode just doesn't keep them). */ +export function saveThreads(threads: Record): void { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(THREAD_KEY, JSON.stringify(threads)); + } catch { + // storage blocked or full: the threads still last the session + } +} diff --git a/tests/client-store.test.ts b/tests/client-store.test.ts index adf83062c..5ed91dd76 100644 --- a/tests/client-store.test.ts +++ b/tests/client-store.test.ts @@ -225,7 +225,7 @@ test('what the browser remembers keeps its keys and shapes', () => { test("the store's keys are its state, as window.__office shows them", () => { // As the office had them before its store was split into slices: methods and the slices aren't among them. - assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); + assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'smartphone', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); }); test('a new store starts every field where it always has', async () => { @@ -249,7 +249,7 @@ test('a new store starts every field where it always has', async () => { dog: null, dogStart: 0, jukebox: { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0, since: 0 }, clock: '', whiteboard: [], drawing: [], cabinet: { player: null, scores: [] }, cabinetFrame: null, ball: {}, cars: parked(), carsAt: [], jail: { prisoners: [], bones: 0 }, - team: null, accounts: null, signins: null, + team: null, accounts: null, signins: null, smartphone: { threads: {}, recents: [] }, }, ); }); diff --git a/tests/smartphone.test.ts b/tests/smartphone.test.ts new file mode 100644 index 000000000..60ca1e558 --- /dev/null +++ b/tests/smartphone.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { appendSms, loadThreads, logRecent, MAX_RECENTS, MAX_THREAD, saveThreads, threadKey } from '../src/shared/smartphone.js'; +import { contactSub, kindIcon, statusNote } from '../src/client/features/smartphone/logic.js'; + +test('threads are keyed by floor and worker, and capped', () => { + assert.equal(threadKey('f1', 'w1'), 'f1/w1'); + assert.equal(threadKey(null, 'w1'), 'lobby/w1'); + let thread = appendSms(undefined, 'hello'); + assert.deepEqual(thread.map((m) => [m.dir, m.text]), [['out', 'hello']]); + for (let i = 0; i < MAX_THREAD + 5; i++) thread = appendSms(thread, `m${i}`, i % 2 ? 'note' : 'out'); + assert.equal(thread.length, MAX_THREAD); + assert.equal(thread[0].text, 'm5'); +}); + +test('recents put the latest first, one line per worker and kind, and are capped', () => { + const at = Date.now(); + let recents = logRecent([], { kind: 'sms', workerId: 'a', name: 'A', at }); + recents = logRecent(recents, { kind: 'call', workerId: 'a', name: 'A', at }); + recents = logRecent(recents, { kind: 'sms', workerId: 'b', name: 'B', at }); + assert.deepEqual( + recents.map((r) => [r.kind, r.workerId]), + [ + ['sms', 'b'], + ['call', 'a'], + ['sms', 'a'], + ], + ); + // Texting A again moves only its SMS line to the top. + recents = logRecent(recents, { kind: 'sms', workerId: 'a', name: 'A', at }); + assert.deepEqual( + recents.map((r) => [r.kind, r.workerId]), + [ + ['sms', 'a'], + ['sms', 'b'], + ['call', 'a'], + ], + ); + for (let i = 0; i < MAX_RECENTS + 5; i++) recents = logRecent(recents, { kind: 'sms', workerId: `w${i}`, name: `W${i}`, at }); + assert.equal(recents.length, MAX_RECENTS); +}); + +test('without a browser there are no kept threads, and keeping them never throws', () => { + assert.deepEqual(loadThreads(), {}); + saveThreads({ 'f1/w1': [{ dir: 'out', text: 'hi', at: 0 }] }); +}); + +test("a contact row names what it's on, and a lost worktree says how to fix it", () => { + assert.equal(kindIcon({ kind: 'agent' }), '🤖'); + assert.equal(kindIcon({ kind: 'shell' }), '🐚'); + assert.equal(contactSub({ activity: 'Wants permission: npm test' }), 'Wants permission: npm test'); + assert.equal(contactSub({ pr: { number: 12, url: 'https://x' } }), '🔀 PR #12'); + assert.equal(contactSub({}), undefined); + assert.equal(contactSub({ lost: { branch: 'here' } }), '🌿 worktree deleted — tap to fix it'); +}); + +test('the SMS view promises only what the status allows', () => { + assert.equal(statusNote({ name: 'Byte', status: 'needs_input' }), '💬 Byte might have answered — call to read it'); + assert.equal(statusNote({ name: 'Byte', status: 'working' }), '📩 delivered — working on it'); + assert.equal(statusNote({ name: 'Byte', status: 'starting' }), '📩 delivered — working on it'); + assert.equal(statusNote({ name: 'Byte', status: 'done' }), '📩 delivered — done, call to follow up'); + assert.equal(statusNote({ name: 'Byte', status: 'idle' }), '📩 delivered — ready when you are'); + assert.equal(statusNote({ name: 'Byte', status: 'exited' }), '💤 Byte is asleep — wake it (R at its desk, or call) before texting'); + assert.equal(statusNote({ name: 'Byte', status: 'offline' }), '💤 Byte is asleep — wake it (R at its desk, or call) before texting'); +}); From c5f7ce140471705c0222e93cea71f467994c6483 Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 05:23:34 +0200 Subject: [PATCH 3/7] Register the smartphone slice (SLICES entry) --- src/client/state/slices/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/state/slices/index.ts b/src/client/state/slices/index.ts index 06bc121ea..ff7f93999 100644 --- a/src/client/state/slices/index.ts +++ b/src/client/state/slices/index.ts @@ -27,6 +27,7 @@ import { prompts } from './prompts'; import { services } from './services'; import { signins } from './signins'; import { sky } from './sky'; +import { smartphone } from './smartphone'; import { team } from './team'; import { theme } from './theme'; import { upgrade } from './upgrade'; @@ -61,4 +62,5 @@ export const SLICES: readonly Slice[] = [ team, accounts, signins, + smartphone, ]; From 880e550b18685ca5569b291f578665d548224ac8 Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 06:02:51 +0200 Subject: [PATCH 4/7] Address review: harden and fix the smartphone - Validate worker dot colors (plain hex only, fallback otherwise). - Threads are plaintext in this browser like terminal scrollback; Recents gets a visible Clear message history control. - Calls branch on lost alone, toast when nothing can be rebuilt; lost workers get a Fix it view instead of a composer. - Log call recents only once the call is really placed. - loadThreads keeps only well-formed lines, capped in length. - SMS refusal toasts; thread hint/composer follow the worker live. - Composer maxlength matches the server's 20000 truncation. - Persisted threads capped (count + per-thread), quietest evicted. - Back/tabs during a call hang up to actions, never cancel silently. - HUD badge reuses waitingNow(); store events redraw only the visible list; recents resolve names live and prune gone workers. - Extract the sound-context boilerplate into ready(). --- src/client/features/hud/index.ts | 2 +- src/client/features/smartphone/logic.ts | 7 + src/client/features/smartphone/sound.ts | 25 ++-- src/client/features/smartphone/ui.css | 1 + src/client/features/smartphone/ui.ts | 162 +++++++++++++++++++----- src/client/lab/phone.ts | 7 +- src/shared/smartphone.ts | 30 ++++- tests/smartphone.test.ts | 68 +++++++++- 8 files changed, 249 insertions(+), 53 deletions(-) diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index e38856ca4..76dafee99 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -52,7 +52,7 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { { id: 'issues', icon: '📌', label: 'Issues', section: 'Open', count: () => store.issues.items.filter((i) => i.state === 'OPEN').length, run: () => openBoard('issues', net, actions.boardActions()) }, { id: 'pulls', icon: '🔀', label: 'Pull requests', section: 'Open', count: () => store.pulls.items.filter((p) => p.state === 'OPEN').length, run: () => openBoard('pulls', net, actions.boardActions()) }, { id: 'queue', icon: '📋', label: 'Task queue', section: 'Open', count: () => store.queue.tasks.filter((t) => t.status !== 'done').length, title: () => 'Issues and tasks waiting for a worker', run: waiting.showQueue }, - { id: 'smartphone', icon: '📱', label: 'Smartphone', section: 'Open', key: 'J', count: () => waitingInOrder(store.workers.values()).length, title: () => 'Call a worker or send one an SMS', run: () => parts.smartphone.showSmartphone() }, + { id: 'smartphone', icon: '📱', label: 'Smartphone', section: 'Open', key: 'J', count: () => waitingNow().length, title: () => 'Call a worker or send one an SMS', run: () => parts.smartphone.showSmartphone() }, { id: 'services', icon: '🌐', label: 'Services', section: 'Open', count: () => store.services.items.length, title: () => 'Web servers the workers are running', run: () => openServices() }, { id: 'whiteboard', icon: '📝', label: 'Whiteboard', section: 'Open', title: () => 'Draw together, live', run: () => openWhiteboard(net) }, // Up on the top bar while a meeting is on: what's being worked through in the meeting room. diff --git a/src/client/features/smartphone/logic.ts b/src/client/features/smartphone/logic.ts index 1120c9a8c..420e76f69 100644 --- a/src/client/features/smartphone/logic.ts +++ b/src/client/features/smartphone/logic.ts @@ -8,6 +8,13 @@ export function kindIcon(w: Pick): string { return w.kind === 'shell' ? '🐚' : '🤖'; } +/** A plain hex color only: worker colors come from the avatar palette, but nothing at the call + * sites constrains them, so anything else falls back instead of becoming raw CSS. */ +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +export function dotColor(w: Pick): string { + return HEX_COLOR.test(w.color) ? w.color : '#888888'; +} + /** The line under a contact's name: what it's doing now, or where its work stands. */ export function contactSub(w: Pick): string | undefined { if (w.lost) return '🌿 worktree deleted — tap to fix it'; diff --git a/src/client/features/smartphone/sound.ts b/src/client/features/smartphone/sound.ts index 36f562966..e26babf22 100644 --- a/src/client/features/smartphone/sound.ts +++ b/src/client/features/smartphone/sound.ts @@ -5,13 +5,20 @@ import { biquad } from '../../sound/dsp'; // call, a swoosh for an SMS going out, and a blip for tapping through the phone. UI-local, like the // worker dings: they come out of the alerts bus, not from anywhere in the room. -/** An old landline's two-tone ring (440 + 480 Hz), rung twice, for placing a call. */ -export function phoneRing(a: AudioCore) { +/** The audio context unlocked and counted, or null before the browser allows audio. */ +function ready(a: AudioCore, name: string): AudioContext | null { a.unlock(); const ctx = a.ctx; - if (!ctx) return; + if (!ctx) return null; if (ctx.state === 'suspended') void ctx.resume(); - a.count('phone-ring'); + a.count(name); + return ctx; +} + +/** An old landline's two-tone ring (440 + 480 Hz), rung twice, for placing a call. */ +export function phoneRing(a: AudioCore) { + const ctx = ready(a, 'phone-ring'); + if (!ctx) return; for (const at of [0, 0.9]) { const t0 = ctx.currentTime + at; for (const f of [440, 480]) { @@ -32,11 +39,8 @@ export function phoneRing(a: AudioCore) { /** An SMS going out: a short whoosh upward. */ export function smsSwoosh(a: AudioCore) { - a.unlock(); - const ctx = a.ctx; + const ctx = ready(a, 'sms-swoosh'); if (!ctx) return; - if (ctx.state === 'suspended') void ctx.resume(); - a.count('sms-swoosh'); const t0 = ctx.currentTime + 0.01; const noise = a.noise(a.buf.white); const bp = biquad(ctx, 'bandpass', 900, 1.4); @@ -53,11 +57,8 @@ export function smsSwoosh(a: AudioCore) { /** Tapping through the phone: one soft key blip. */ export function dialBlip(a: AudioCore) { - a.unlock(); - const ctx = a.ctx; + const ctx = ready(a, 'dial-blip'); if (!ctx) return; - if (ctx.state === 'suspended') void ctx.resume(); - a.count('dial-blip'); const t0 = ctx.currentTime + 0.01; const o = ctx.createOscillator(); const g = ctx.createGain(); diff --git a/src/client/features/smartphone/ui.css b/src/client/features/smartphone/ui.css index 03f377808..358311e4c 100644 --- a/src/client/features/smartphone/ui.css +++ b/src/client/features/smartphone/ui.css @@ -37,3 +37,4 @@ .sp-hint { font-size: 12px; color: var(--muted); text-align: center; } .sp-compose { display: flex; gap: 6px; } .sp-compose input { flex: 1; min-width: 0; } +.sp-clear { align-self: center; font-size: 12px; margin-top: 4px; } diff --git a/src/client/features/smartphone/ui.ts b/src/client/features/smartphone/ui.ts index 242043359..54c2a457d 100644 --- a/src/client/features/smartphone/ui.ts +++ b/src/client/features/smartphone/ui.ts @@ -1,12 +1,12 @@ import './ui.css'; import type { WorkerInfo } from '../../../shared/protocol'; -import { appendSms, logRecent, saveThreads, threadKey } from '../../../shared/smartphone'; +import { appendSms, logRecent, MAX_SMS_TEXT, saveThreads, threadKey } from '../../../shared/smartphone'; import { isAsleep } from '../../../shared/status'; import { byUrgency } from '../../nextup'; import { store } from '../../state'; import { clip, h, openModal, STATUS_LABEL, timeAgo, toast } from '../../ui/dom'; import { dictateField } from '../../ui/dictate'; -import { contactSub, kindIcon, statusNote } from './logic'; +import { contactSub, dotColor, kindIcon, statusNote } from './logic'; export interface SmartphoneDeps { net: { send(msg: { t: 'worker.prompt'; workerId: string; prompt: string }): void }; @@ -51,28 +51,48 @@ export function openSmartphone(deps: SmartphoneDeps) { function draw() { clearTimers(); + drawTabs(); + drawBody(); + } + + function drawTabs() { const contacts = byUrgency(store.workers.values()); const recents = store.smartphone.recents; back.classList.toggle('hidden', view.t === 'contacts' || view.t === 'recents'); tabs.replaceChildren( - tab('Contacts', contacts.length, view.t === 'contacts' || view.t === 'actions' || view.t === 'calling' || view.t === 'thread', () => { + tab('Contacts', contacts.length, view.t !== 'recents', () => { + if (hangUp()) return; view = { t: 'contacts' }; deps.sound.dialBlip(); draw(); }), tab('Recents', recents.length, view.t === 'recents', () => { + if (hangUp()) return; view = { t: 'recents' }; deps.sound.dialBlip(); draw(); }), ); - if (view.t === 'contacts') renderContacts(contacts); + } + + function drawBody() { + live = null; + if (view.t === 'contacts') renderContacts(byUrgency(store.workers.values())); else if (view.t === 'recents') renderRecents(); else if (view.t === 'actions') renderActions(view.id); else if (view.t === 'calling') renderCalling(view.id); else renderThread(view.id); } + /** Hanging up from anywhere: back to the contact's actions, never a silent cancel. */ + function hangUp(): boolean { + if (view.t !== 'calling') return false; + view = { t: 'actions', id: view.id }; + deps.sound.dialBlip(); + draw(); + return true; + } + const tab = (label: string, n: number, on: boolean, go: () => void) => h('button.sp-tab', { type: 'button', class: on ? 'on' : '', 'aria-pressed': String(on), onclick: go }, label, n ? h('span.sp-count', {}, String(n)) : null); function pill(w: WorkerInfo) { @@ -98,7 +118,7 @@ export function openSmartphone(deps: SmartphoneDeps) { draw(); }, }, - h('span.sp-dot', { style: `background:${w.color}` }), + h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr && w.activity ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), pill(w), ); @@ -107,31 +127,47 @@ export function openSmartphone(deps: SmartphoneDeps) { } function renderRecents() { - const recents = store.smartphone.recents; - if (!recents.length) { + const st = store.smartphone; + // Names are resolved live (a renamed worker shows its new name), and rows whose worker went + // home are dropped instead of lingering until the cap evicts them. + const alive = st.recents.filter((r) => worker(r.workerId)); + if (alive.length !== st.recents.length) st.recents = alive; + if (!alive.length) { body.replaceChildren(h('p.sp-empty', {}, 'No calls or texts yet — tap a contact to ring them.')); return; } body.replaceChildren( - ...recents.map((r) => - h( + ...alive.map((r) => { + const w = worker(r.workerId)!; + return h( 'button.sp-contact', { type: 'button', onclick: () => { - if (!worker(r.workerId)) { - toast(`${r.name} went home`, 'warn'); - return; - } - view = { t: 'actions', id: r.workerId }; + view = { t: 'actions', id: w.id }; deps.sound.dialBlip(); draw(); }, }, h('span.sp-dot', {}, r.kind === 'call' ? '📞' : '💬'), - h('div.sp-main', {}, h('div.sp-name', {}, r.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), + h('div.sp-main', {}, h('div.sp-name', {}, w.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), h('span.sp-when', { title: new Date(r.at).toLocaleString() }, timeAgo(r.at)), - ), + ); + }), + h( + 'button.btn.sp-clear', + { + type: 'button', + title: 'Forget every kept text (texts are kept in this browser only)', + onclick: () => { + const s = store.smartphone; + s.threads = {}; + saveThreads(s.threads); + store.emit('smartphone'); + toast('🗑 Message history cleared'); + }, + }, + '🗑 Clear message history', ), ); } @@ -144,7 +180,7 @@ export function openSmartphone(deps: SmartphoneDeps) { } const sub = contactSub(w); body.replaceChildren( - h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${w.color}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), + h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), h( 'div.sp-actions', {}, @@ -188,19 +224,26 @@ export function openSmartphone(deps: SmartphoneDeps) { view = { t: 'contacts' }; return draw(); } - if (now.lost && now.worktree) { + if (now.lost) { + // No worktree to rebuild (a workspace gone with it): say so where the phone still is. + if (!now.worktree) { + toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); + view = { t: 'contacts' }; + return draw(); + } modal.close(); deps.fixLostWorktree(now); return; } - const st = store.smartphone; - st.recents = logRecent(st.recents, { kind: 'call', workerId: now.id, name: now.name, at: Date.now() }); - store.emit('smartphone'); if (!deps.goToWorker(now.id)) { toast(`Couldn't get to ${now.name}'s desk`, 'warn'); view = { t: 'contacts' }; return draw(); } + // Logged only once the call is really placed: no phantoms for failed ones. + const st = store.smartphone; + st.recents = logRecent(st.recents, { kind: 'call', workerId: now.id, name: now.name, at: Date.now() }); + store.emit('smartphone'); deps.openWorkerTerminal(now.id); }, CONNECT_MS), ); @@ -215,7 +258,7 @@ export function openSmartphone(deps: SmartphoneDeps) { const phase = h('div.sp-phase', {}, 'Dialing…'); timers.push(setTimeout(() => phase.replaceChildren('Ringing…'), 800)); body.replaceChildren( - h('div.sp-calling', {}, h('span.sp-bigdot', { style: `background:${w.color}` }), h('div.sp-name', {}, w.name), phase, h('div.sp-sub', {}, `${STATUS_LABEL[w.status] ?? w.status} · connects into their terminal`)), + h('div.sp-calling', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-name', {}, w.name), phase, h('div.sp-sub', {}, `${STATUS_LABEL[w.status] ?? w.status} · connects into their terminal`)), h( 'div.sp-actions', {}, @@ -224,9 +267,7 @@ export function openSmartphone(deps: SmartphoneDeps) { { type: 'button', onclick: () => { - view = { t: 'actions', id }; - deps.sound.dialBlip(); - draw(); + hangUp(); }, }, '📵 End', @@ -241,10 +282,34 @@ export function openSmartphone(deps: SmartphoneDeps) { view = { t: 'contacts' }; return draw(); } + if (w.lost) { + // Like prompting at the desk: a lost worker gets fixed, not texted. + body.replaceChildren( + h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), + h('p.sp-empty', {}, '🌿 Its worktree is gone — put it back before texting.'), + h( + 'div.sp-actions', + {}, + h( + 'button.btn.primary', + { + type: 'button', + onclick: () => { + modal.close(); + deps.fixLostWorktree(w); + }, + }, + '🌿 Fix it', + ), + ), + ); + return; + } const key = threadKey(store.floor, id); const thread = store.smartphone.threads[key] ?? []; const asleep = isAsleep(w.status); - const input = h('input', { type: 'text', placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; + const hint = h('div.sp-hint', {}, statusNote(w)); + const input = h('input', { type: 'text', maxlength: MAX_SMS_TEXT, placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; input.toggleAttribute('disabled', asleep); const sendBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); sendBtn.toggleAttribute('disabled', asleep); @@ -252,8 +317,16 @@ export function openSmartphone(deps: SmartphoneDeps) { form.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); + if (!text) return; const now = worker(id); - if (!text || !now || isAsleep(now.status)) return; + if (!now) { + view = { t: 'contacts' }; + return draw(); + } + if (isAsleep(now.status)) { + toast(`${now.name} is asleep — wake it (R at its desk, or call) before texting`, 'warn'); + return; + } deps.net.send({ t: 'worker.prompt', workerId: now.id, prompt: text }); const st = store.smartphone; st.threads[key] = appendSms(st.threads[key], text); @@ -271,26 +344,49 @@ export function openSmartphone(deps: SmartphoneDeps) { {}, ...thread.map((m) => (m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text))), ); - body.replaceChildren(h('div.sp-who', {}, h('span.sp-dot', { style: `background:${w.color}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, h('div.sp-hint', {}, statusNote(w)), form); + body.replaceChildren(h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, hint, form); + live = { id, hint, input, send: sendBtn }; msgs.scrollTop = msgs.scrollHeight; setTimeout(() => input.focus(), 30); } back.addEventListener('click', () => { + if (hangUp()) return; if (view.t === 'actions' || view.t === 'thread') view = view.t === 'thread' && worker(view.id) ? { t: 'actions', id: view.id } : { t: 'contacts' }; else view = { t: 'contacts' }; deps.sound.dialBlip(); draw(); }); + /** The thread's live bits: the hint and composer follow the worker without a redraw, so the input keeps focus. */ + let live: { id: string; hint: HTMLElement; input: HTMLInputElement; send: HTMLElement } | null = null; + + function liveThread() { + const cur = live; + const w = cur && worker(cur.id); + if (!cur || !w) return draw(); + cur.hint.replaceChildren(statusNote(w)); + const asleep = isAsleep(w.status); + cur.input.toggleAttribute('disabled', asleep); + cur.input.placeholder = asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`; + cur.send.toggleAttribute('disabled', asleep); + } + // The workers change under the phone (a hire, a status flip): redraw the lists, but never a view - // with focus or timers in it (the thread's composer, the call being placed). - const onStore = () => { - if (view.t === 'thread' || view.t === 'calling') return; + // with focus or timers in it (the thread's composer follows along live, or the call being placed). + const onWorkers = () => { + if (view.t === 'calling') return; + if (view.t === 'thread') return liveThread(); draw(); }; - const offWorkers = store.on('workers', onStore); - const offPhone = store.on('smartphone', onStore); + // A text sent (or history cleared) only renames tab counts outside Recents; Recents redraws. + const onPhone = () => { + if (view.t === 'thread' || view.t === 'calling') return; + if (view.t === 'recents') return draw(); + drawTabs(); + }; + const offWorkers = store.on('workers', onWorkers); + const offPhone = store.on('smartphone', onPhone); const modal = openModal(el, { doing: '📱 checking contacts', onClose: () => { diff --git a/src/client/lab/phone.ts b/src/client/lab/phone.ts index 65f996dc9..ad921591d 100644 --- a/src/client/lab/phone.ts +++ b/src/client/lab/phone.ts @@ -37,10 +37,15 @@ if (params.has('sms') || params.has('call')) { pick('.sp-contact')?.click(); pick(params.has('call') ? '.sp-call' : '.sp-sms')?.click(); } +// Recents with one placed call, on the Recents tab (shows the clear-history control). +if (params.has('recent')) { + store.smartphone.recents = [{ kind: 'call', workerId: 'w3', name: 'Mochi', at: Date.now() - 60_000 }]; + document.querySelectorAll('.sp-tab')[1]?.click(); +} setTimeout( () => { - ((window as unknown as { __ready: unknown }).__ready = { phone: params.has('sms') ? 'thread' : params.has('call') ? 'calling' : 'contacts' }); + ((window as unknown as { __ready: unknown }).__ready = { phone: params.has('sms') ? 'thread' : params.has('call') ? 'calling' : params.has('recent') ? 'recents' : 'contacts' }); }, params.has('call') ? 1500 : 400, ); diff --git a/src/shared/smartphone.ts b/src/shared/smartphone.ts index bb31f9f49..ba7dc18e2 100644 --- a/src/shared/smartphone.ts +++ b/src/shared/smartphone.ts @@ -20,6 +20,10 @@ export interface RecentEntry { /** How many messages a thread keeps per worker, and how many lines Recents keeps. */ export const MAX_THREAD = 100; export const MAX_RECENTS = 20; +/** How many threads are kept in the browser: keys (floor/worker) accumulate, so the count is capped. */ +export const MAX_KEYS = 50; +/** The longest SMS kept or sent: the server truncates `worker.prompt` past this (`ws/handlers/workers.ts`). */ +export const MAX_SMS_TEXT = 20000; const THREAD_KEY = 'agent-office.smartphone.threads'; @@ -38,26 +42,44 @@ export function logRecent(recents: RecentEntry[], entry: RecentEntry): RecentEnt return [entry, ...recents.filter((r) => r.workerId !== entry.workerId || r.kind !== entry.kind)].slice(0, MAX_RECENTS); } +/** Whether a kept value is a well-formed thread line (stale or crafted storage is dropped, never rendered). */ +function isSmsMsg(m: unknown): m is SmsMsg { + if (typeof m !== 'object' || m === null) return false; + const o = m as Record; + return (o.dir === 'out' || o.dir === 'note') && typeof o.text === 'string' && typeof o.at === 'number'; +} + /** The threads the browser kept, or none (private mode, or nothing sent yet). */ export function loadThreads(): Record { try { if (typeof localStorage === 'undefined') return {}; const raw = localStorage.getItem(THREAD_KEY); if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; + const parsed = JSON.parse(raw) as Record; const out: Record = {}; - for (const [k, v] of Object.entries(parsed)) if (Array.isArray(v)) out[k] = v.slice(-MAX_THREAD); + for (const [k, v] of Object.entries(parsed)) { + if (!Array.isArray(v)) continue; + const kept = v.filter(isSmsMsg).map((m) => ({ ...m, text: m.text.slice(0, MAX_SMS_TEXT) })); + if (kept.length) out[k] = kept.slice(-MAX_THREAD); + } return out; } catch { return {}; } } -/** Keeps the threads in the browser. Never throws (private mode just doesn't keep them). */ +/** Keeps the threads in the browser: plaintext, like the terminal scrollback the office keeps. Never throws (private mode just doesn't keep them). */ export function saveThreads(threads: Record): void { try { if (typeof localStorage === 'undefined') return; - localStorage.setItem(THREAD_KEY, JSON.stringify(threads)); + const keys = Object.keys(threads).filter((k) => (threads[k]?.length ?? 0) > 0); + let kept = threads; + if (keys.length > MAX_KEYS) { + // Evict the threads quietest the longest. + const latest = (k: string) => threads[k]?.reduce((m, x) => Math.max(m, x.at), 0) ?? 0; + kept = Object.fromEntries(keys.sort((a, b) => latest(b) - latest(a)).slice(0, MAX_KEYS).map((k) => [k, threads[k]])); + } + localStorage.setItem(THREAD_KEY, JSON.stringify(kept)); } catch { // storage blocked or full: the threads still last the session } diff --git a/tests/smartphone.test.ts b/tests/smartphone.test.ts index 60ca1e558..d55bd5df8 100644 --- a/tests/smartphone.test.ts +++ b/tests/smartphone.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { appendSms, loadThreads, logRecent, MAX_RECENTS, MAX_THREAD, saveThreads, threadKey } from '../src/shared/smartphone.js'; -import { contactSub, kindIcon, statusNote } from '../src/client/features/smartphone/logic.js'; +import { appendSms, loadThreads, logRecent, MAX_KEYS, MAX_RECENTS, MAX_SMS_TEXT, MAX_THREAD, saveThreads, threadKey } from '../src/shared/smartphone.js'; +import { contactSub, dotColor, kindIcon, statusNote } from '../src/client/features/smartphone/logic.js'; test('threads are keyed by floor and worker, and capped', () => { assert.equal(threadKey('f1', 'w1'), 'f1/w1'); @@ -45,6 +45,70 @@ test('without a browser there are no kept threads, and keeping them never throws saveThreads({ 'f1/w1': [{ dir: 'out', text: 'hi', at: 0 }] }); }); +test('only plain hex colors reach the stylesheet, anything else falls back', () => { + assert.equal(dotColor({ color: '#4f86f7' }), '#4f86f7'); + assert.equal(dotColor({ color: '#ABCDEF' }), '#ABCDEF'); + for (const bad of ['', 'red', '#fff', '#gggggg', '#1234567', 'a;#x{background:url(//evil)}', '#4f86f7;foo:bar']) assert.equal(dotColor({ color: bad }), '#888888', bad); +}); + +function withStorage(run: (kept: Map) => void) { + const kept = new Map(); + const g = globalThis as unknown as Record; + const had = g.localStorage; + g.localStorage = { getItem: (k: string) => kept.get(k) ?? null, setItem: (k: string, v: string) => void kept.set(k, v) }; + try { + run(kept); + } finally { + if (had === undefined) delete g.localStorage; + else g.localStorage = had; + } +} + +test('loading keeps only well-formed lines, capped in length and count', () => { + withStorage((kept) => { + kept.set( + 'agent-office.smartphone.threads', + JSON.stringify({ + good: [ + { dir: 'out', text: 'hi', at: 1 }, + { dir: 'note', text: 'n', at: 2 }, + null, + 'text', + 7, + { dir: 'in', text: 'wrong dir', at: 3 }, + { dir: 'out', text: 'x'.repeat(MAX_SMS_TEXT + 10), at: 4 }, + { dir: 'out', at: 5 }, + ], + empty: [{ dir: 'out', text: 'x', at: 1 }].slice(1), + nothread: 'nope', + }), + ); + const threads = loadThreads(); + assert.deepEqual(Object.keys(threads), ['good']); + assert.deepEqual( + threads.good.map((m) => [m.dir, m.text.length]), + [ + ['out', 2], + ['note', 1], + ['out', MAX_SMS_TEXT], + ], + ); + }); +}); + +test('saving caps the thread count, quietest threads go first', () => { + withStorage((kept) => { + const threads: Record = {}; + for (let i = 0; i < MAX_KEYS + 5; i++) threads[`f1/w${i}`] = [{ dir: 'out', text: `m${i}`, at: i }]; + threads.empty = []; + saveThreads(threads); + const saved = JSON.parse(kept.get('agent-office.smartphone.threads')!) as Record; + assert.equal(Object.keys(saved).length, MAX_KEYS); + assert.ok(!('empty' in saved)); + assert.ok(!('f1/w0' in saved) && !('f1/w4' in saved) && 'f1/w5' in saved && `f1/w${MAX_KEYS + 4}` in saved); + }); +}); + test("a contact row names what it's on, and a lost worktree says how to fix it", () => { assert.equal(kindIcon({ kind: 'agent' }), '🤖'); assert.equal(kindIcon({ kind: 'shell' }), '🐚'); From e8143d7c35971187ec55ef698845d192f8cbc336 Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 07:10:32 +0200 Subject: [PATCH 5/7] Address second review: split phone UI, harden storage, fix lost races - Split the phone shell into ui.ts + contacts.ts + call.ts + sms.ts. - Per-thread localStorage keys: one small write per text; load validates shape, skips __proto__/constructor/prototype, drops malformed and over-cap keys (from storage too); in-memory map capped the same way. - Calls re-check existence/lost after the walk-over and log only connected calls; SMS submit and Go-to-desk route lost workers to the fix flow; live thread switches to Fix-it UI when a worker turns lost. - pruneRecents runs at the top of every draw (no render-time mutation); clearing also drops sms recents. - Workers events coalesced per frame and skipped when nothing visible changed; draw() sorts once; HUD waiting list memoized per workers change. - Connect timer tracked separately; focus timer tracked; mic created once per thread view and dropped on the way out. - statusNote defaults to a neutral line for unknown statuses. --- src/client/features/hud/index.ts | 7 +- src/client/features/smartphone/call.ts | 77 ++++ src/client/features/smartphone/contacts.ts | 134 +++++++ src/client/features/smartphone/logic.ts | 5 +- src/client/features/smartphone/sms.ts | 132 ++++++ src/client/features/smartphone/ui.ts | 443 ++++++--------------- src/shared/smartphone.ts | 102 +++-- tests/smartphone.test.ts | 100 +++-- 8 files changed, 612 insertions(+), 388 deletions(-) create mode 100644 src/client/features/smartphone/call.ts create mode 100644 src/client/features/smartphone/contacts.ts create mode 100644 src/client/features/smartphone/sms.ts diff --git a/src/client/features/hud/index.ts b/src/client/features/hud/index.ts index 76dafee99..011135cd1 100644 --- a/src/client/features/hud/index.ts +++ b/src/client/features/hud/index.ts @@ -45,7 +45,12 @@ export function installHud(ctx: Ctx, core: CoreState, parts: HudParts) { }); // ---- The HUD: a few buttons on the top bar, everything else in the ☰ menu ---------------------------- - const waitingNow = () => waitingInOrder(store.workers.values()); + // The waiting list, computed once per change: a refresh reads it several times over (badge, chip, tone…). + let waitingCache: ReturnType | null = null; + store.on('workers', () => { + waitingCache = null; + }); + const waitingNow = () => (waitingCache ??= waitingInOrder(store.workers.values())); const noMedia = () => (window.isSecureContext ? undefined : 'Voice and screen sharing need HTTPS or localhost — use a TLS proxy, --self-signed, or an SSH tunnel'); const hud = mountHud( [ diff --git a/src/client/features/smartphone/call.ts b/src/client/features/smartphone/call.ts new file mode 100644 index 000000000..37cdbd18d --- /dev/null +++ b/src/client/features/smartphone/call.ts @@ -0,0 +1,77 @@ +// The smartphone's call: a ringback, then you at their desk with their terminal open. A call is +// only logged once it really connected: re-checked after the walk-over, never for failed ones. +import type { WorkerInfo } from '../../../shared/protocol'; +import { logRecent } from '../../../shared/smartphone'; +import { store } from '../../state'; +import { h, STATUS_LABEL, toast } from '../../ui/dom'; +import { dotColor } from './logic'; +import type { Phone } from './ui'; + +/** A placed call connects once the ringback has rung twice. */ +const CONNECT_MS = 2600; + +export function startCall(phone: Phone, w: WorkerInfo) { + phone.go({ t: 'calling', id: w.id }); + phone.deps.sound.phoneRing(); + phone.setConnect(CONNECT_MS, () => { + const now = phone.worker(w.id); + if (!now) { + toast(`${w.name} went home`, 'warn'); + phone.go({ t: 'contacts' }); + return; + } + if (now.lost) { + // No worktree to rebuild (a workspace gone with it): say so where the phone still is. + if (!now.worktree) { + toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); + phone.go({ t: 'contacts' }); + return; + } + phone.close(); + phone.deps.fixLostWorktree(now); + return; + } + if (!phone.deps.goToWorker(now.id)) { + toast(`Couldn't get to ${now.name}'s desk`, 'warn'); + phone.go({ t: 'contacts' }); + return; + } + // Re-checked after the walk-over (the phone is closed by now): only connected calls are logged. + const open = phone.worker(w.id); + if (!open || open.lost) { + toast(`${w.name} went home`, 'warn'); + return; + } + const st = store.smartphone; + st.recents = logRecent(st.recents, { kind: 'call', workerId: open.id, name: open.name, at: Date.now() }); + store.emit('smartphone'); + phone.deps.openWorkerTerminal(open.id); + }); +} + +export function renderCalling(phone: Phone, id: string): HTMLElement[] { + const w = phone.worker(id); + if (!w) { + phone.go({ t: 'contacts' }); + return []; + } + const phase = h('div.sp-phase', {}, 'Dialing…'); + phone.after(800, () => phase.replaceChildren('Ringing…')); + return [ + h('div.sp-calling', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-name', {}, w.name), phase, h('div.sp-sub', {}, `${STATUS_LABEL[w.status] ?? w.status} · connects into their terminal`)), + h( + 'div.sp-actions', + {}, + h( + 'button.btn.danger.sp-hang', + { + type: 'button', + onclick: () => { + phone.hangUp(); + }, + }, + '📵 End', + ), + ), + ]; +} diff --git a/src/client/features/smartphone/contacts.ts b/src/client/features/smartphone/contacts.ts new file mode 100644 index 000000000..fdca48de5 --- /dev/null +++ b/src/client/features/smartphone/contacts.ts @@ -0,0 +1,134 @@ +// The smartphone's contacts, actions and recents views: who they are, what each one does, and what +// was placed or sent. Recents resolve names live and drop workers that went home. +import type { WorkerInfo } from '../../../shared/protocol'; +import { clearThreads } from '../../../shared/smartphone'; +import { store } from '../../state'; +import { clip, h, STATUS_LABEL, timeAgo, toast } from '../../ui/dom'; +import { startCall } from './call'; +import { contactSub, dotColor, kindIcon } from './logic'; +import type { Phone } from './ui'; + +function pill(w: WorkerInfo) { + const cls = w.status === 'needs_input' ? 'sp-pill sp-needs' : w.status === 'done' ? 'sp-pill sp-done' : 'sp-pill'; + return h('span', { class: cls }, STATUS_LABEL[w.status] ?? w.status); +} + +/** Drops recents rows whose worker went home. Runs at the top of every draw, before anything is + * painted, so renderers never mutate (every listener draws right after). */ +export function pruneRecents(): void { + const st = store.smartphone; + const alive = st.recents.filter((r) => store.workers.has(r.workerId)); + if (alive.length !== st.recents.length) st.recents = alive; +} + +export function renderContacts(phone: Phone, contacts: WorkerInfo[]): HTMLElement[] { + if (!contacts.length) return [h('p.sp-empty', {}, '📵 No contacts — hire someone first (E at an empty desk).')]; + return contacts.map((w) => { + const sub = contactSub(w); + return h( + 'button.sp-contact', + { + type: 'button', + onclick: () => { + phone.go({ t: 'actions', id: w.id }); + phone.deps.sound.dialBlip(); + }, + }, + h('span.sp-dot', { style: `background:${dotColor(w)}` }), + h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr && w.activity ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), + pill(w), + ); + }); +} + +export function renderActions(phone: Phone, id: string): HTMLElement[] { + const w = phone.worker(id); + if (!w) { + phone.go({ t: 'contacts' }); + return []; + } + const sub = contactSub(w); + return [ + h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), + h( + 'div.sp-actions', + {}, + h('button.btn.primary.sp-call', { type: 'button', onclick: () => startCall(phone, w) }, '📞 Call'), + h( + 'button.btn.sp-sms', + { + type: 'button', + onclick: () => { + phone.go({ t: 'thread', id: w.id }); + phone.deps.sound.dialBlip(); + }, + }, + '💬 SMS', + ), + h( + 'button.btn', + { + type: 'button', + title: 'Walk over without opening anything', + onclick: () => { + // Lost workers get the fix flow the other actions use, not a walk to a dead desk. + const now = phone.worker(w.id) ?? w; + if (now.lost) { + if (!now.worktree) { + toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); + return; + } + phone.close(); + phone.deps.fixLostWorktree(now); + return; + } + if (!phone.deps.goToWorker(now.id)) toast(`Couldn't get to ${now.name}'s desk`, 'warn'); + }, + }, + '🚶 Go to desk', + ), + ), + ]; +} + +export function renderRecents(phone: Phone): HTMLElement[] { + // Pruned at the top of every draw; filtered again read-only so a race still can't render a ghost. + // Names resolve live: a renamed worker shows its new name. + const recents = store.smartphone.recents.filter((r) => phone.worker(r.workerId)); + if (!recents.length) return [h('p.sp-empty', {}, 'No calls or texts yet — tap a contact to ring them.')]; + return [ + ...recents.map((r) => { + const w = phone.worker(r.workerId)!; + return h( + 'button.sp-contact', + { + type: 'button', + onclick: () => { + phone.go({ t: 'actions', id: w.id }); + phone.deps.sound.dialBlip(); + }, + }, + h('span.sp-dot', {}, r.kind === 'call' ? '📞' : '💬'), + h('div.sp-main', {}, h('div.sp-name', {}, w.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), + h('span.sp-when', { title: new Date(r.at).toLocaleString() }, timeAgo(r.at)), + ); + }), + h( + 'button.btn.sp-clear', + { + type: 'button', + title: 'Forget every kept text and text recents (kept in this browser only)', + onclick: () => { + const s = store.smartphone; + s.threads = {}; + clearThreads(); + // Calls reference no threads, but texts to forgotten threads would. + s.recents = s.recents.filter((r) => r.kind !== 'sms'); + store.emit('smartphone'); + toast('🗑 Message history cleared'); + }, + }, + '🗑 Clear message history', + ), + ]; +} diff --git a/src/client/features/smartphone/logic.ts b/src/client/features/smartphone/logic.ts index 420e76f69..20fefdc6c 100644 --- a/src/client/features/smartphone/logic.ts +++ b/src/client/features/smartphone/logic.ts @@ -39,7 +39,10 @@ export function statusNote(w: Pick): string { return '📩 delivered — done, call to follow up'; case 'idle': return '📩 delivered — ready when you are'; - default: + case 'exited': + case 'offline': return `💤 ${w.name} is asleep — wake it (R at its desk, or call) before texting`; + default: + return `📩 ${w.name} · ${w.status}`; } } diff --git a/src/client/features/smartphone/sms.ts b/src/client/features/smartphone/sms.ts new file mode 100644 index 000000000..c93a1492e --- /dev/null +++ b/src/client/features/smartphone/sms.ts @@ -0,0 +1,132 @@ +// The smartphone's SMS thread: what you sent, the worker's status as an honest hint, and a composer. +// The thread never redraws under you (the input keeps focus); store changes follow along live, and a +// sent text is appended by hand. The mic is created once per thread view and dropped on the way out. +import { appendSms, logRecent, MAX_SMS_TEXT, pruneThreadKeys, saveThread, threadKey } from '../../../shared/smartphone'; +import { isAsleep } from '../../../shared/status'; +import { store } from '../../state'; +import { h, toast } from '../../ui/dom'; +import { dictation } from '../../ui/dictate'; +import { spliceSpoken } from '../../ui/speech'; +import { dotColor, statusNote } from './logic'; +import type { Phone } from './ui'; + +/** The thread's live bits, for following the worker without a redraw. */ +export interface ThreadLive { + id: string; + hint: HTMLElement; + input: HTMLInputElement; + send: HTMLElement; + msgs: HTMLElement; + dropMic(): void; +} + +/** A text box you can dictate into, with its mic disposable (ui/dictate's field keeps its mic with the window instead). */ +function dictateInput(field: HTMLInputElement): { el: HTMLElement; drop(): void } { + const d = dictation({ + off: () => field.disabled, + insert: (text) => { + const start = field.selectionStart ?? field.value.length; + const r = spliceSpoken(field.value, start, field.selectionEnd ?? start, text); + field.value = r.value; + field.setSelectionRange(r.caret, r.caret); + field.dispatchEvent(new Event('input', { bubbles: true })); + }, + }); + if (!d.button) return { el: field, drop: d.drop }; + field.addEventListener('keydown', (e) => void d.key(e as KeyboardEvent)); + return { el: h('div.dictate-field.line', {}, field, d.button, d.live), drop: d.drop }; +} + +export function renderThread(phone: Phone, id: string): HTMLElement[] { + const w = phone.worker(id); + if (!w) { + phone.go({ t: 'contacts' }); + return []; + } + if (w.lost) { + // Like prompting at the desk: a lost worker gets fixed, not texted. + const fix = () => { + const now = phone.worker(id) ?? w; + if (!now.worktree) { + toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); + return; + } + phone.close(); + phone.deps.fixLostWorktree(now); + }; + return [ + h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), + h('p.sp-empty', {}, '🌿 Its worktree is gone — put it back before texting.'), + h('div.sp-actions', {}, h('button.btn.primary', { type: 'button', onclick: fix }, '🌿 Fix it')), + ]; + } + const key = threadKey(store.floor, id); + const thread = store.smartphone.threads[key] ?? []; + const asleep = isAsleep(w.status); + const hint = h('div.sp-hint', {}, statusNote(w)); + const input = h('input', { type: 'text', maxlength: MAX_SMS_TEXT, placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; + input.toggleAttribute('disabled', asleep); + const sendBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); + sendBtn.toggleAttribute('disabled', asleep); + const mic = dictateInput(input); + const form = h('form.sp-compose', {}, mic.el, sendBtn); + const msgs = h( + 'div.sp-thread', + {}, + ...thread.map((m) => (m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text))), + ); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const now = phone.worker(id); + if (!now) { + phone.go({ t: 'contacts' }); + return; + } + if (now.lost) { + if (!now.worktree) { + toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); + return; + } + phone.close(); + phone.deps.fixLostWorktree(now); + return; + } + if (isAsleep(now.status)) { + toast(`${now.name} is asleep — wake it (R at its desk, or call) before texting`, 'warn'); + return; + } + phone.deps.net.send({ t: 'worker.prompt', workerId: now.id, prompt: text }); + const st = store.smartphone; + st.threads[key] = appendSms(st.threads[key], text); + st.threads = pruneThreadKeys(st.threads); + saveThread(key, st.threads[key] ?? []); + st.recents = logRecent(st.recents, { kind: 'sms', workerId: now.id, name: now.name, at: Date.now() }); + store.emit('smartphone'); + phone.deps.sound.smsSwoosh(); + toast(`📩 SMS sent to ${now.name}`); + // By hand, not a redraw: the composer (and its mic) stay put, and the tab counts follow. + msgs.append(h('div.sp-bubble sp-out', {}, text)); + msgs.scrollTop = msgs.scrollHeight; + input.value = ''; + input.focus(); + phone.refreshTabs(); + }); + phone.setLive({ id, hint, input, send: sendBtn, msgs, dropMic: mic.drop }); + // Keep the focus timer tracked, so it can't fire after close or a view change. + phone.after(30, () => input.focus()); + return [h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, hint, form]; +} + +/** Follows the worker without a redraw; a worker turning lost switches to the Fix it UI. */ +export function liveThread(phone: Phone, live: ThreadLive | null) { + if (!live) return phone.draw(); + const w = phone.worker(live.id); + if (!w || w.lost) return phone.draw(); + live.hint.replaceChildren(statusNote(w)); + const asleep = isAsleep(w.status); + live.input.toggleAttribute('disabled', asleep); + live.input.placeholder = asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`; + live.send.toggleAttribute('disabled', asleep); +} diff --git a/src/client/features/smartphone/ui.ts b/src/client/features/smartphone/ui.ts index 54c2a457d..e1a7daad4 100644 --- a/src/client/features/smartphone/ui.ts +++ b/src/client/features/smartphone/ui.ts @@ -1,12 +1,11 @@ import './ui.css'; import type { WorkerInfo } from '../../../shared/protocol'; -import { appendSms, logRecent, MAX_SMS_TEXT, saveThreads, threadKey } from '../../../shared/smartphone'; -import { isAsleep } from '../../../shared/status'; import { byUrgency } from '../../nextup'; import { store } from '../../state'; -import { clip, h, openModal, STATUS_LABEL, timeAgo, toast } from '../../ui/dom'; -import { dictateField } from '../../ui/dictate'; -import { contactSub, dotColor, kindIcon, statusNote } from './logic'; +import { h, openModal } from '../../ui/dom'; +import { pruneRecents, renderActions, renderContacts, renderRecents } from './contacts'; +import { renderCalling } from './call'; +import { liveThread, renderThread, type ThreadLive } from './sms'; export interface SmartphoneDeps { net: { send(msg: { t: 'worker.prompt'; workerId: string; prompt: string }): void }; @@ -17,22 +16,39 @@ export interface SmartphoneDeps { sound: { phoneRing(): void; smsSwoosh(): void; dialBlip(): void }; } -type View = { t: 'contacts' } | { t: 'recents' } | { t: 'actions'; id: string } | { t: 'calling'; id: string } | { t: 'thread'; id: string }; +export type PhoneView = { t: 'contacts' } | { t: 'recents' } | { t: 'actions'; id: string } | { t: 'calling'; id: string } | { t: 'thread'; id: string }; -/** A placed call connects once the ringback has rung twice. */ -const CONNECT_MS = 2600; +/** What the phone's views share: the shell owns the modal, the view state and the timers. */ +export interface Phone { + deps: SmartphoneDeps; + worker(id: string): WorkerInfo | undefined; + go(view: PhoneView): void; + draw(): void; + /** Tabs only (a text sent only renames counts outside Recents). */ + refreshTabs(): void; + /** Hanging up from anywhere: back to the contact's actions, never a silent cancel. */ + hangUp(): boolean; + /** A view-scoped timeout, dropped on every redraw and on close (the call screen's phase). */ + after(ms: number, fn: () => void): void; + /** The placed call's connect timer: separate from view timers, dropped on hang-up, close or fire. */ + setConnect(ms: number, fn: () => void): void; + clearConnect(): void; + setLive(live: ThreadLive | null): void; + close(): void; +} /** * The player's GTA-style smartphone: contacts are the workers on this floor, a call puts you at - * their desk with their terminal open, and an SMS is a prompt sent to their session. + * their desk with their terminal open, and an SMS is a prompt sent to their session. The views live + * beside this shell (contacts, call, sms); this owns the modal, the tabs and the timers. */ export function openSmartphone(deps: SmartphoneDeps) { - let view: View = { t: 'contacts' }; - let timers: ReturnType[] = []; - const clearTimers = () => { - for (const t of timers) clearTimeout(t); - timers = []; - }; + let view: PhoneView = { t: 'contacts' }; + let viewTimers: ReturnType[] = []; + let connectTimer: ReturnType | null = null; + let live: ThreadLive | null = null; + let raf = 0; + let lastSig = ''; const close = h('button.btn.close', { type: 'button', 'aria-label': 'Close', title: 'Close (Esc)' }, '✕'); const back = h('button.btn.sp-back', { type: 'button', 'aria-label': 'Back', title: 'Back' }, '‹'); @@ -47,350 +63,131 @@ export function openSmartphone(deps: SmartphoneDeps) { body, ); - const worker = (id: string) => store.workers.get(id); + const clearViewTimers = () => { + for (const t of viewTimers) clearTimeout(t); + viewTimers = []; + }; + const clearConnect = () => { + if (connectTimer) clearTimeout(connectTimer); + connectTimer = null; + }; - function draw() { - clearTimers(); - drawTabs(); - drawBody(); + const phone: Phone = { + deps, + worker: (id) => store.workers.get(id), + go: (v) => { + view = v; + draw(); + }, + draw: () => draw(), + refreshTabs: () => drawTabs(store.workers.size), + hangUp: () => { + if (view.t !== 'calling') return false; + clearConnect(); + view = { t: 'actions', id: view.id }; + deps.sound.dialBlip(); + draw(); + return true; + }, + after: (ms, fn) => void viewTimers.push(setTimeout(fn, ms)), + setConnect: (ms, fn) => { + clearConnect(); + connectTimer = setTimeout(() => { + connectTimer = null; + fn(); + }, ms); + }, + clearConnect, + setLive: (l) => { + live?.dropMic(); + live = l; + }, + close: () => modal.close(), + }; + + /** What the open phone shows, for skipping redraws whose inputs didn't change. */ + function sig(contacts: WorkerInfo[]): string { + return `${contacts.map((w) => [w.id, w.name, w.color, w.status, w.activity ?? '', w.pr?.number ?? 0].join('|')).join('~')}#${store.smartphone.recents.length}`; } - function drawTabs() { + function draw() { + if (raf) { + cancelAnimationFrame(raf); + raf = 0; + } + clearViewTimers(); + pruneRecents(); const contacts = byUrgency(store.workers.values()); - const recents = store.smartphone.recents; + drawTabs(contacts.length); + drawBody(contacts); + lastSig = sig(contacts); + } + + function drawTabs(count: number) { back.classList.toggle('hidden', view.t === 'contacts' || view.t === 'recents'); tabs.replaceChildren( - tab('Contacts', contacts.length, view.t !== 'recents', () => { - if (hangUp()) return; - view = { t: 'contacts' }; + tab('Contacts', count, view.t !== 'recents', () => { + if (phone.hangUp()) return; + phone.go({ t: 'contacts' }); deps.sound.dialBlip(); - draw(); }), - tab('Recents', recents.length, view.t === 'recents', () => { - if (hangUp()) return; - view = { t: 'recents' }; + tab('Recents', store.smartphone.recents.length, view.t === 'recents', () => { + if (phone.hangUp()) return; + phone.go({ t: 'recents' }); deps.sound.dialBlip(); - draw(); }), ); } - function drawBody() { - live = null; - if (view.t === 'contacts') renderContacts(byUrgency(store.workers.values())); - else if (view.t === 'recents') renderRecents(); - else if (view.t === 'actions') renderActions(view.id); - else if (view.t === 'calling') renderCalling(view.id); - else renderThread(view.id); - } - - /** Hanging up from anywhere: back to the contact's actions, never a silent cancel. */ - function hangUp(): boolean { - if (view.t !== 'calling') return false; - view = { t: 'actions', id: view.id }; - deps.sound.dialBlip(); - draw(); - return true; + function drawBody(contacts: WorkerInfo[]) { + phone.setLive(null); + if (view.t === 'contacts') body.replaceChildren(...renderContacts(phone, contacts)); + else if (view.t === 'recents') body.replaceChildren(...renderRecents(phone)); + else if (view.t === 'actions') body.replaceChildren(...renderActions(phone, view.id)); + else if (view.t === 'calling') body.replaceChildren(...renderCalling(phone, view.id)); + else body.replaceChildren(...renderThread(phone, view.id)); } const tab = (label: string, n: number, on: boolean, go: () => void) => h('button.sp-tab', { type: 'button', class: on ? 'on' : '', 'aria-pressed': String(on), onclick: go }, label, n ? h('span.sp-count', {}, String(n)) : null); - function pill(w: WorkerInfo) { - const cls = w.status === 'needs_input' ? 'sp-pill sp-needs' : w.status === 'done' ? 'sp-pill sp-done' : 'sp-pill'; - return h('span', { class: cls }, STATUS_LABEL[w.status] ?? w.status); - } - - function renderContacts(contacts: WorkerInfo[]) { - if (!contacts.length) { - body.replaceChildren(h('p.sp-empty', {}, '📵 No contacts — hire someone first (E at an empty desk).')); - return; - } - body.replaceChildren( - ...contacts.map((w) => { - const sub = contactSub(w); - return h( - 'button.sp-contact', - { - type: 'button', - onclick: () => { - view = { t: 'actions', id: w.id }; - deps.sound.dialBlip(); - draw(); - }, - }, - h('span.sp-dot', { style: `background:${dotColor(w)}` }), - h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr && w.activity ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), - pill(w), - ); - }), - ); - } - - function renderRecents() { - const st = store.smartphone; - // Names are resolved live (a renamed worker shows its new name), and rows whose worker went - // home are dropped instead of lingering until the cap evicts them. - const alive = st.recents.filter((r) => worker(r.workerId)); - if (alive.length !== st.recents.length) st.recents = alive; - if (!alive.length) { - body.replaceChildren(h('p.sp-empty', {}, 'No calls or texts yet — tap a contact to ring them.')); - return; - } - body.replaceChildren( - ...alive.map((r) => { - const w = worker(r.workerId)!; - return h( - 'button.sp-contact', - { - type: 'button', - onclick: () => { - view = { t: 'actions', id: w.id }; - deps.sound.dialBlip(); - draw(); - }, - }, - h('span.sp-dot', {}, r.kind === 'call' ? '📞' : '💬'), - h('div.sp-main', {}, h('div.sp-name', {}, w.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), - h('span.sp-when', { title: new Date(r.at).toLocaleString() }, timeAgo(r.at)), - ); - }), - h( - 'button.btn.sp-clear', - { - type: 'button', - title: 'Forget every kept text (texts are kept in this browser only)', - onclick: () => { - const s = store.smartphone; - s.threads = {}; - saveThreads(s.threads); - store.emit('smartphone'); - toast('🗑 Message history cleared'); - }, - }, - '🗑 Clear message history', - ), - ); - } - - function renderActions(id: string) { - const w = worker(id); - if (!w) { - view = { t: 'contacts' }; - return draw(); - } - const sub = contactSub(w); - body.replaceChildren( - h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), - h( - 'div.sp-actions', - {}, - h('button.btn.primary.sp-call', { type: 'button', onclick: () => startCall(w) }, '📞 Call'), - h( - 'button.btn.sp-sms', - { - type: 'button', - onclick: () => { - view = { t: 'thread', id: w.id }; - deps.sound.dialBlip(); - draw(); - }, - }, - '💬 SMS', - ), - h( - 'button.btn', - { - type: 'button', - title: 'Walk over without opening anything', - onclick: () => { - if (!deps.goToWorker(w.id)) toast(`Couldn't get to ${w.name}'s desk`, 'warn'); - }, - }, - '🚶 Go to desk', - ), - ), - ); - } - - function startCall(w: WorkerInfo) { - view = { t: 'calling', id: w.id }; - draw(); - deps.sound.phoneRing(); - timers.push( - setTimeout(() => { - const now = worker(w.id); - if (!now) { - toast(`${w.name} went home`, 'warn'); - view = { t: 'contacts' }; - return draw(); - } - if (now.lost) { - // No worktree to rebuild (a workspace gone with it): say so where the phone still is. - if (!now.worktree) { - toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); - view = { t: 'contacts' }; - return draw(); - } - modal.close(); - deps.fixLostWorktree(now); - return; - } - if (!deps.goToWorker(now.id)) { - toast(`Couldn't get to ${now.name}'s desk`, 'warn'); - view = { t: 'contacts' }; - return draw(); - } - // Logged only once the call is really placed: no phantoms for failed ones. - const st = store.smartphone; - st.recents = logRecent(st.recents, { kind: 'call', workerId: now.id, name: now.name, at: Date.now() }); - store.emit('smartphone'); - deps.openWorkerTerminal(now.id); - }, CONNECT_MS), - ); - } - - function renderCalling(id: string) { - const w = worker(id); - if (!w) { - view = { t: 'contacts' }; - return draw(); - } - const phase = h('div.sp-phase', {}, 'Dialing…'); - timers.push(setTimeout(() => phase.replaceChildren('Ringing…'), 800)); - body.replaceChildren( - h('div.sp-calling', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-name', {}, w.name), phase, h('div.sp-sub', {}, `${STATUS_LABEL[w.status] ?? w.status} · connects into their terminal`)), - h( - 'div.sp-actions', - {}, - h( - 'button.btn.danger.sp-hang', - { - type: 'button', - onclick: () => { - hangUp(); - }, - }, - '📵 End', - ), - ), - ); - } - - function renderThread(id: string) { - const w = worker(id); - if (!w) { - view = { t: 'contacts' }; - return draw(); - } - if (w.lost) { - // Like prompting at the desk: a lost worker gets fixed, not texted. - body.replaceChildren( - h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), - h('p.sp-empty', {}, '🌿 Its worktree is gone — put it back before texting.'), - h( - 'div.sp-actions', - {}, - h( - 'button.btn.primary', - { - type: 'button', - onclick: () => { - modal.close(); - deps.fixLostWorktree(w); - }, - }, - '🌿 Fix it', - ), - ), - ); - return; - } - const key = threadKey(store.floor, id); - const thread = store.smartphone.threads[key] ?? []; - const asleep = isAsleep(w.status); - const hint = h('div.sp-hint', {}, statusNote(w)); - const input = h('input', { type: 'text', maxlength: MAX_SMS_TEXT, placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; - input.toggleAttribute('disabled', asleep); - const sendBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); - sendBtn.toggleAttribute('disabled', asleep); - const form = h('form.sp-compose', {}, dictateField(input), sendBtn); - form.addEventListener('submit', (e) => { - e.preventDefault(); - const text = input.value.trim(); - if (!text) return; - const now = worker(id); - if (!now) { - view = { t: 'contacts' }; - return draw(); - } - if (isAsleep(now.status)) { - toast(`${now.name} is asleep — wake it (R at its desk, or call) before texting`, 'warn'); - return; - } - deps.net.send({ t: 'worker.prompt', workerId: now.id, prompt: text }); - const st = store.smartphone; - st.threads[key] = appendSms(st.threads[key], text); - saveThreads(st.threads); - st.recents = logRecent(st.recents, { kind: 'sms', workerId: now.id, name: now.name, at: Date.now() }); - store.emit('smartphone'); - deps.sound.smsSwoosh(); - toast(`📩 SMS sent to ${now.name}`); - input.value = ''; - draw(); - (body.querySelector('input') as HTMLInputElement | null)?.focus(); - }); - const msgs = h( - 'div.sp-thread', - {}, - ...thread.map((m) => (m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text))), - ); - body.replaceChildren(h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, hint, form); - live = { id, hint, input, send: sendBtn }; - msgs.scrollTop = msgs.scrollHeight; - setTimeout(() => input.focus(), 30); - } - back.addEventListener('click', () => { - if (hangUp()) return; - if (view.t === 'actions' || view.t === 'thread') view = view.t === 'thread' && worker(view.id) ? { t: 'actions', id: view.id } : { t: 'contacts' }; + if (phone.hangUp()) return; + if (view.t === 'actions' || view.t === 'thread') view = view.t === 'thread' && phone.worker(view.id) ? { t: 'actions', id: view.id } : { t: 'contacts' }; else view = { t: 'contacts' }; deps.sound.dialBlip(); draw(); }); - /** The thread's live bits: the hint and composer follow the worker without a redraw, so the input keeps focus. */ - let live: { id: string; hint: HTMLElement; input: HTMLInputElement; send: HTMLElement } | null = null; - - function liveThread() { - const cur = live; - const w = cur && worker(cur.id); - if (!cur || !w) return draw(); - cur.hint.replaceChildren(statusNote(w)); - const asleep = isAsleep(w.status); - cur.input.toggleAttribute('disabled', asleep); - cur.input.placeholder = asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`; - cur.send.toggleAttribute('disabled', asleep); - } - - // The workers change under the phone (a hire, a status flip): redraw the lists, but never a view - // with focus or timers in it (the thread's composer follows along live, or the call being placed). + // The workers change under the phone (a hire, a status flip, an activity tick): coalesced to one + // redraw per frame, skipped when nothing visible changed — but never a view with focus or timers + // in it (the thread's composer follows along live, or the call being placed). const onWorkers = () => { if (view.t === 'calling') return; - if (view.t === 'thread') return liveThread(); - draw(); + if (view.t === 'thread') return liveThread(phone, live); + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + if (!el.isConnected || view.t === 'calling' || view.t === 'thread') return; + const contacts = byUrgency(store.workers.values()); + if (sig(contacts) === lastSig) return; + draw(); + }); }; // A text sent (or history cleared) only renames tab counts outside Recents; Recents redraws. const onPhone = () => { if (view.t === 'thread' || view.t === 'calling') return; if (view.t === 'recents') return draw(); - drawTabs(); + drawTabs(store.workers.size); }; const offWorkers = store.on('workers', onWorkers); const offPhone = store.on('smartphone', onPhone); const modal = openModal(el, { doing: '📱 checking contacts', onClose: () => { - clearTimers(); + clearConnect(); + clearViewTimers(); + if (raf) cancelAnimationFrame(raf); + phone.setLive(null); offWorkers(); offPhone(); }, diff --git a/src/shared/smartphone.ts b/src/shared/smartphone.ts index ba7dc18e2..9a421683b 100644 --- a/src/shared/smartphone.ts +++ b/src/shared/smartphone.ts @@ -17,15 +17,16 @@ export interface RecentEntry { at: number; } -/** How many messages a thread keeps per worker, and how many lines Recents keeps. */ -export const MAX_THREAD = 100; +/** How many messages a thread keeps, and how many lines Recents keeps. */ +export const MAX_THREAD = 50; export const MAX_RECENTS = 20; -/** How many threads are kept in the browser: keys (floor/worker) accumulate, so the count is capped. */ -export const MAX_KEYS = 50; +/** How many threads are kept, in memory and in the browser: the quietest go first. */ +export const MAX_KEYS = 20; /** The longest SMS kept or sent: the server truncates `worker.prompt` past this (`ws/handlers/workers.ts`). */ export const MAX_SMS_TEXT = 20000; -const THREAD_KEY = 'agent-office.smartphone.threads'; +/** One entry per thread, so sending a text writes one small entry, never the whole map. */ +const THREAD_PREFIX = 'agent-office.smartphone.thread:'; /** Threads by `floor/worker`, so each floor keeps its own conversations. */ export function threadKey(floor: string | null, workerId: string): string { @@ -49,38 +50,89 @@ function isSmsMsg(m: unknown): m is SmsMsg { return (o.dir === 'out' || o.dir === 'note') && typeof o.text === 'string' && typeof o.at === 'number'; } -/** The threads the browser kept, or none (private mode, or nothing sent yet). */ +/** Never a storage key that would mutate a prototype when it becomes an object key. */ +function isKeySafe(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'; +} + +/** The newest line's time, for evicting the quietest threads first (computed once per key). */ +function latest(thread: SmsMsg[]): number { + let m = 0; + for (const x of thread) if (x.at > m) m = x.at; + return m; +} + +/** A stored value as a thread, or null when it isn't one (stale, truncated, or crafted). */ +function cleanThread(v: unknown): SmsMsg[] | null { + if (!Array.isArray(v)) return null; + const kept = v.filter(isSmsMsg).map((m) => ({ ...m, text: m.text.slice(0, MAX_SMS_TEXT) })); + return kept.length ? kept.slice(-MAX_THREAD) : null; +} + +/** The threads the browser kept, or none (private mode, or nothing sent yet). Whatever is dropped + * (malformed, unsafe, or past the cap) is forgotten from storage too, so the map stays bounded. */ export function loadThreads(): Record { try { if (typeof localStorage === 'undefined') return {}; - const raw = localStorage.getItem(THREAD_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - const out: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - if (!Array.isArray(v)) continue; - const kept = v.filter(isSmsMsg).map((m) => ({ ...m, text: m.text.slice(0, MAX_SMS_TEXT) })); - if (kept.length) out[k] = kept.slice(-MAX_THREAD); + const names: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(THREAD_PREFIX)) names.push(k); } - return out; + const found: [string, SmsMsg[]][] = []; + const drop: string[] = []; + for (const storageKey of names) { + const key = storageKey.slice(THREAD_PREFIX.length); + let thread: SmsMsg[] | null = null; + try { + thread = isKeySafe(key) ? cleanThread(JSON.parse(localStorage.getItem(storageKey) ?? 'null')) : null; + } catch { + thread = null; + } + if (thread) found.push([key, thread]); + else drop.push(storageKey); + } + // Newest first, so the cap drops the quietest. + found.sort((a, b) => latest(b[1]) - latest(a[1])); + for (const [key] of found.slice(MAX_KEYS)) drop.push(THREAD_PREFIX + key); + for (const k of drop) localStorage.removeItem(k); + return Object.fromEntries(found.slice(0, MAX_KEYS)); } catch { return {}; } } -/** Keeps the threads in the browser: plaintext, like the terminal scrollback the office keeps. Never throws (private mode just doesn't keep them). */ -export function saveThreads(threads: Record): void { +/** Keeps one thread in the browser: plaintext, like the terminal scrollback the office keeps. Never throws. */ +export function saveThread(key: string, thread: SmsMsg[]): void { + try { + if (typeof localStorage === 'undefined' || !isKeySafe(key)) return; + if (!thread.length) localStorage.removeItem(THREAD_PREFIX + key); + else localStorage.setItem(THREAD_PREFIX + key, JSON.stringify(thread.slice(-MAX_THREAD))); + } catch { + // storage blocked or full: the threads still last the session + } +} + +/** Forgets every kept text (kept in this browser only). Never throws. */ +export function clearThreads(): void { try { if (typeof localStorage === 'undefined') return; - const keys = Object.keys(threads).filter((k) => (threads[k]?.length ?? 0) > 0); - let kept = threads; - if (keys.length > MAX_KEYS) { - // Evict the threads quietest the longest. - const latest = (k: string) => threads[k]?.reduce((m, x) => Math.max(m, x.at), 0) ?? 0; - kept = Object.fromEntries(keys.sort((a, b) => latest(b) - latest(a)).slice(0, MAX_KEYS).map((k) => [k, threads[k]])); + const gone: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(THREAD_PREFIX)) gone.push(k); } - localStorage.setItem(THREAD_KEY, JSON.stringify(kept)); + for (const k of gone) localStorage.removeItem(k); } catch { - // storage blocked or full: the threads still last the session + // storage blocked: nothing kept to forget } } + +/** The in-memory map kept to the same cap (callers keep the returned map): the quietest go first. */ +export function pruneThreadKeys(threads: Record): Record { + const all = Object.keys(threads); + const keys = all.filter((k) => isKeySafe(k) && threads[k].length > 0); + if (keys.length === all.length && keys.length <= MAX_KEYS) return threads; + const byLatest = new Map(keys.map((k) => [k, latest(threads[k])] as const)); + return Object.fromEntries(keys.sort((a, b) => (byLatest.get(b) ?? 0) - (byLatest.get(a) ?? 0)).slice(0, MAX_KEYS).map((k) => [k, threads[k]])); +} diff --git a/tests/smartphone.test.ts b/tests/smartphone.test.ts index d55bd5df8..7cc831c98 100644 --- a/tests/smartphone.test.ts +++ b/tests/smartphone.test.ts @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { appendSms, loadThreads, logRecent, MAX_KEYS, MAX_RECENTS, MAX_SMS_TEXT, MAX_THREAD, saveThreads, threadKey } from '../src/shared/smartphone.js'; +import { appendSms, clearThreads, loadThreads, logRecent, MAX_KEYS, MAX_RECENTS, MAX_SMS_TEXT, MAX_THREAD, pruneThreadKeys, saveThread, threadKey } from '../src/shared/smartphone.js'; +import type { SmsMsg } from '../src/shared/smartphone.js'; +import type { WorkerInfo } from '../src/shared/protocol.js'; import { contactSub, dotColor, kindIcon, statusNote } from '../src/client/features/smartphone/logic.js'; test('threads are keyed by floor and worker, and capped', () => { @@ -42,7 +44,8 @@ test('recents put the latest first, one line per worker and kind, and are capped test('without a browser there are no kept threads, and keeping them never throws', () => { assert.deepEqual(loadThreads(), {}); - saveThreads({ 'f1/w1': [{ dir: 'out', text: 'hi', at: 0 }] }); + saveThread('f1/w1', [{ dir: 'out', text: 'hi', at: 0 }]); + clearThreads(); }); test('only plain hex colors reach the stylesheet, anything else falls back', () => { @@ -55,7 +58,15 @@ function withStorage(run: (kept: Map) => void) { const kept = new Map(); const g = globalThis as unknown as Record; const had = g.localStorage; - g.localStorage = { getItem: (k: string) => kept.get(k) ?? null, setItem: (k: string, v: string) => void kept.set(k, v) }; + g.localStorage = { + get length() { + return kept.size; + }, + key: (i: number) => [...kept.keys()][i] ?? null, + getItem: (k: string) => kept.get(k) ?? null, + setItem: (k: string, v: string) => void kept.set(k, v), + removeItem: (k: string) => void kept.delete(k), + }; try { run(kept); } finally { @@ -66,49 +77,57 @@ function withStorage(run: (kept: Map) => void) { test('loading keeps only well-formed lines, capped in length and count', () => { withStorage((kept) => { - kept.set( - 'agent-office.smartphone.threads', - JSON.stringify({ - good: [ - { dir: 'out', text: 'hi', at: 1 }, - { dir: 'note', text: 'n', at: 2 }, - null, - 'text', - 7, - { dir: 'in', text: 'wrong dir', at: 3 }, - { dir: 'out', text: 'x'.repeat(MAX_SMS_TEXT + 10), at: 4 }, - { dir: 'out', at: 5 }, - ], - empty: [{ dir: 'out', text: 'x', at: 1 }].slice(1), - nothread: 'nope', - }), - ); + const put = (k: string, v: string) => kept.set(`agent-office.smartphone.thread:${k}`, v); + put('f1/good', JSON.stringify([{ dir: 'out', text: 'hi', at: 1 }])); + put('f1/shapes', JSON.stringify([null, 'text', 7, { dir: 'in', text: 'x', at: 3 }, { dir: 'out', at: 5 }])); + put('f1/long', JSON.stringify([{ dir: 'out', text: 'x'.repeat(MAX_SMS_TEXT + 10), at: 1000 }])); + put('f1/notarray', '"nope"'); + put('f1/broken', '{oops'); + put('__proto__', JSON.stringify([{ dir: 'out', text: 'evil', at: 7 }])); + kept.set('other.key', '"hi"'); + for (let i = 0; i < MAX_KEYS + 3; i++) put(`f9/old${i}`, JSON.stringify([{ dir: 'out', text: 'old', at: 100 + i }])); const threads = loadThreads(); - assert.deepEqual(Object.keys(threads), ['good']); - assert.deepEqual( - threads.good.map((m) => [m.dir, m.text.length]), - [ - ['out', 2], - ['note', 1], - ['out', MAX_SMS_TEXT], - ], - ); + // Malformed and unsafe keys are dropped; the cap then evicts the quietest (good, old0-3). + assert.ok(!('f1/shapes' in threads) && !('f1/notarray' in threads) && !('f1/broken' in threads)); + assert.ok(!('f1/good' in threads)); + assert.ok('f1/long' in threads); + assert.equal(threads['f1/long'][0].text.length, MAX_SMS_TEXT); + assert.ok(`f9/old${MAX_KEYS + 2}` in threads); + assert.equal(Object.keys(threads).length, MAX_KEYS); + // Forgotten from storage too (malformed, unsafe, over cap); other keys untouched. + const left = [...kept.keys()]; + assert.ok(!left.some((k) => k.endsWith('shapes') || k.endsWith('notarray') || k.endsWith('broken') || k.endsWith('__proto__'))); + assert.ok(!left.some((k) => k.endsWith('/good') || k.endsWith('old0') || k.endsWith('old1') || k.endsWith('old2') || k.endsWith('old3'))); + assert.ok(kept.has('other.key')); + assert.deepEqual(({}).hasOwnProperty.call(threads, 'toString'), false); }); }); -test('saving caps the thread count, quietest threads go first', () => { +test('saving writes one key; empties remove theirs; clearing forgets threads only', () => { withStorage((kept) => { - const threads: Record = {}; - for (let i = 0; i < MAX_KEYS + 5; i++) threads[`f1/w${i}`] = [{ dir: 'out', text: `m${i}`, at: i }]; - threads.empty = []; - saveThreads(threads); - const saved = JSON.parse(kept.get('agent-office.smartphone.threads')!) as Record; - assert.equal(Object.keys(saved).length, MAX_KEYS); - assert.ok(!('empty' in saved)); - assert.ok(!('f1/w0' in saved) && !('f1/w4' in saved) && 'f1/w5' in saved && `f1/w${MAX_KEYS + 4}` in saved); + saveThread('f1/w1', [{ dir: 'out', text: 'hi', at: 1 }]); + assert.equal(kept.get('agent-office.smartphone.thread:f1/w1'), JSON.stringify([{ dir: 'out', text: 'hi', at: 1 }])); + saveThread('f1/w1', []); + assert.ok(!kept.has('agent-office.smartphone.thread:f1/w1')); + saveThread('__proto__', [{ dir: 'out', text: 'evil', at: 2 }]); + assert.ok(![...kept.keys()].some((k) => k.endsWith('__proto__'))); + kept.set('other.key', '1'); + saveThread('f1/w2', [{ dir: 'out', text: 'x', at: 2 }]); + clearThreads(); + assert.deepEqual([...kept.keys()], ['other.key']); }); }); +test('the in-memory map is kept to the same cap, quietest first', () => { + const threads: Record = {}; + for (let i = 0; i < MAX_KEYS + 5; i++) threads[`f1/w${i}`] = [{ dir: 'out', text: `m${i}`, at: i }]; + const pruned = pruneThreadKeys(threads); + assert.equal(Object.keys(pruned).length, MAX_KEYS); + assert.ok(!('f1/w0' in pruned) && !('f1/w4' in pruned) && 'f1/w5' in pruned); + const few: Record = { a: [{ dir: 'out', text: 'x', at: 1 }] }; + assert.equal(pruneThreadKeys(few), few); +}); + test("a contact row names what it's on, and a lost worktree says how to fix it", () => { assert.equal(kindIcon({ kind: 'agent' }), '🤖'); assert.equal(kindIcon({ kind: 'shell' }), '🐚'); @@ -127,3 +146,8 @@ test('the SMS view promises only what the status allows', () => { assert.equal(statusNote({ name: 'Byte', status: 'exited' }), '💤 Byte is asleep — wake it (R at its desk, or call) before texting'); assert.equal(statusNote({ name: 'Byte', status: 'offline' }), '💤 Byte is asleep — wake it (R at its desk, or call) before texting'); }); + +test('an unknown future status gets a neutral line, not a sleep lecture', () => { + const future = { name: 'Byte', status: 'mystery' } as unknown as Pick; + assert.equal(statusNote(future), '📩 Byte · mystery'); +}); From 953a5a3ea9dd803c0e08038529832a42f37eae46 Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 07:15:51 +0200 Subject: [PATCH 6/7] Unify PR display: badge always, sub never The badge was gated on w.pr && w.activity while contactSub showed PR #n with no activity, so the signal moved places. Now contactSub never mentions PRs and both contact rows and the actions view always show the badge when w.pr is set. --- src/client/features/smartphone/contacts.ts | 4 ++-- src/client/features/smartphone/logic.ts | 5 ++--- tests/smartphone.test.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/client/features/smartphone/contacts.ts b/src/client/features/smartphone/contacts.ts index fdca48de5..a24ea7eba 100644 --- a/src/client/features/smartphone/contacts.ts +++ b/src/client/features/smartphone/contacts.ts @@ -35,7 +35,7 @@ export function renderContacts(phone: Phone, contacts: WorkerInfo[]): HTMLElemen }, }, h('span.sp-dot', { style: `background:${dotColor(w)}` }), - h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr && w.activity ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), + h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 48)) : null), pill(w), ); }); @@ -49,7 +49,7 @@ export function renderActions(phone: Phone, id: string): HTMLElement[] { } const sub = contactSub(w); return [ - h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), + h('div.sp-who', {}, h('span.sp-bigdot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `${kindIcon(w)} ${w.name}`, w.pr ? h('span.sp-pr', {}, `🔀 #${w.pr.number}`) : null), sub ? h('div.sp-sub', {}, clip(sub, 60)) : null), pill(w)), h( 'div.sp-actions', {}, diff --git a/src/client/features/smartphone/logic.ts b/src/client/features/smartphone/logic.ts index 20fefdc6c..057366643 100644 --- a/src/client/features/smartphone/logic.ts +++ b/src/client/features/smartphone/logic.ts @@ -15,11 +15,10 @@ export function dotColor(w: Pick): string { return HEX_COLOR.test(w.color) ? w.color : '#888888'; } -/** The line under a contact's name: what it's doing now, or where its work stands. */ -export function contactSub(w: Pick): string | undefined { +/** The line under a contact's name: what it's doing now. Work standing (a PR) is the badge's job, so it never doubles up here. */ +export function contactSub(w: Pick): string | undefined { if (w.lost) return '🌿 worktree deleted — tap to fix it'; if (w.activity) return w.activity; - if (w.pr) return `🔀 PR #${w.pr.number}`; return undefined; } diff --git a/tests/smartphone.test.ts b/tests/smartphone.test.ts index 7cc831c98..42181f381 100644 --- a/tests/smartphone.test.ts +++ b/tests/smartphone.test.ts @@ -132,7 +132,7 @@ test("a contact row names what it's on, and a lost worktree says how to fix it", assert.equal(kindIcon({ kind: 'agent' }), '🤖'); assert.equal(kindIcon({ kind: 'shell' }), '🐚'); assert.equal(contactSub({ activity: 'Wants permission: npm test' }), 'Wants permission: npm test'); - assert.equal(contactSub({ pr: { number: 12, url: 'https://x' } }), '🔀 PR #12'); + // Work standing is the badge's job, never the sub line's (no double 🔀). assert.equal(contactSub({}), undefined); assert.equal(contactSub({ lost: { branch: 'here' } }), '🌿 worktree deleted — tap to fix it'); }); From 388dd8141688c8ecfd9c22f385e837ff5080f2ec Mon Sep 17 00:00:00 2001 From: iptoux Date: Mon, 5 Oct 2026 08:35:14 +0200 Subject: [PATCH 7/7] Fix review bugs: blank-body redirect, stoppable ring, thread window, doing line - drawBody snapshots the view and skips replaceChildren when a renderer redirected (worker gone mid-view no longer wipes to a blank body) - sig covers lost and kind, activity as length+prefix (cheap skip check) - thread renders last 20 with a show-earlier expander; sent texts join the live snapshot so expanding stays complete - phoneRing returns a stop handle, silenced on hang-up, failed connects and close; OfficeSound passes it through - live thread header follows renames; recents timestamps memoized - connect at 1.8s, just as the double ring ends (was 2.6s dead air) - call opens the terminal with doing 'on a call with ' via a new optional TerminalOptions.doing threaded through openWorkerTerminal - Phone split kept: the previous review required the module split for the size rule; collapsing it now would churn back --- src/client/features/smartphone/call.ts | 11 ++++-- src/client/features/smartphone/contacts.ts | 14 ++++++- src/client/features/smartphone/sms.ts | 37 ++++++++++++++---- src/client/features/smartphone/sound.ts | 27 +++++++++++-- src/client/features/smartphone/ui.ts | 44 +++++++++++++++++----- src/client/features/waiting/index.ts | 4 +- src/client/lab/phone.ts | 2 +- src/client/sound/index.ts | 6 +-- src/client/ui/terminal.ts | 4 +- 9 files changed, 117 insertions(+), 32 deletions(-) diff --git a/src/client/features/smartphone/call.ts b/src/client/features/smartphone/call.ts index 37cdbd18d..834f81a3d 100644 --- a/src/client/features/smartphone/call.ts +++ b/src/client/features/smartphone/call.ts @@ -7,15 +7,16 @@ import { h, STATUS_LABEL, toast } from '../../ui/dom'; import { dotColor } from './logic'; import type { Phone } from './ui'; -/** A placed call connects once the ringback has rung twice. */ -const CONNECT_MS = 2600; +/** A placed call connects just as the double ringback ends (~1.55s), with a beat to spare. */ +const CONNECT_MS = 1800; export function startCall(phone: Phone, w: WorkerInfo) { phone.go({ t: 'calling', id: w.id }); - phone.deps.sound.phoneRing(); + phone.setRing(phone.deps.sound.phoneRing()); phone.setConnect(CONNECT_MS, () => { const now = phone.worker(w.id); if (!now) { + phone.setRing(null); toast(`${w.name} went home`, 'warn'); phone.go({ t: 'contacts' }); return; @@ -23,6 +24,7 @@ export function startCall(phone: Phone, w: WorkerInfo) { if (now.lost) { // No worktree to rebuild (a workspace gone with it): say so where the phone still is. if (!now.worktree) { + phone.setRing(null); toast(`${now.name}'s workspace is gone and there is nothing to rebuild — send it home from its desk`, 'warn'); phone.go({ t: 'contacts' }); return; @@ -32,6 +34,7 @@ export function startCall(phone: Phone, w: WorkerInfo) { return; } if (!phone.deps.goToWorker(now.id)) { + phone.setRing(null); toast(`Couldn't get to ${now.name}'s desk`, 'warn'); phone.go({ t: 'contacts' }); return; @@ -45,7 +48,7 @@ export function startCall(phone: Phone, w: WorkerInfo) { const st = store.smartphone; st.recents = logRecent(st.recents, { kind: 'call', workerId: open.id, name: open.name, at: Date.now() }); store.emit('smartphone'); - phone.deps.openWorkerTerminal(open.id); + phone.deps.openWorkerTerminal(open.id, `📱 on a call with ${open.name}`); }); } diff --git a/src/client/features/smartphone/contacts.ts b/src/client/features/smartphone/contacts.ts index a24ea7eba..5890fadd9 100644 --- a/src/client/features/smartphone/contacts.ts +++ b/src/client/features/smartphone/contacts.ts @@ -13,6 +13,18 @@ function pill(w: WorkerInfo) { return h('span', { class: cls }, STATUS_LABEL[w.status] ?? w.status); } +/** Exact timestamps, memoized per instant: `toLocaleString` is slow for a tooltip per row per draw. */ +const whenTitles = new Map(); +function whenTitle(at: number): string { + let s = whenTitles.get(at); + if (s === undefined) { + s = new Date(at).toLocaleString(); + if (whenTitles.size > 200) whenTitles.clear(); + whenTitles.set(at, s); + } + return s; +} + /** Drops recents rows whose worker went home. Runs at the top of every draw, before anything is * painted, so renderers never mutate (every listener draws right after). */ export function pruneRecents(): void { @@ -110,7 +122,7 @@ export function renderRecents(phone: Phone): HTMLElement[] { }, h('span.sp-dot', {}, r.kind === 'call' ? '📞' : '💬'), h('div.sp-main', {}, h('div.sp-name', {}, w.name), h('div.sp-sub', {}, r.kind === 'call' ? 'outgoing call' : 'text message')), - h('span.sp-when', { title: new Date(r.at).toLocaleString() }, timeAgo(r.at)), + h('span.sp-when', { title: whenTitle(r.at) }, timeAgo(r.at)), ); }), h( diff --git a/src/client/features/smartphone/sms.ts b/src/client/features/smartphone/sms.ts index c93a1492e..130f68529 100644 --- a/src/client/features/smartphone/sms.ts +++ b/src/client/features/smartphone/sms.ts @@ -13,6 +13,7 @@ import type { Phone } from './ui'; /** The thread's live bits, for following the worker without a redraw. */ export interface ThreadLive { id: string; + who: HTMLElement; hint: HTMLElement; input: HTMLInputElement; send: HTMLElement; @@ -61,8 +62,9 @@ export function renderThread(phone: Phone, id: string): HTMLElement[] { ]; } const key = threadKey(store.floor, id); - const thread = store.smartphone.threads[key] ?? []; + let thread = store.smartphone.threads[key] ?? []; const asleep = isAsleep(w.status); + const who = h('div.sp-name', {}, `💬 ${w.name}`); const hint = h('div.sp-hint', {}, statusNote(w)); const input = h('input', { type: 'text', maxlength: MAX_SMS_TEXT, placeholder: asleep ? `${w.name} is asleep — call to wake it` : `Text ${w.name}…`, 'aria-label': 'Message', autocomplete: 'off' }) as HTMLInputElement; input.toggleAttribute('disabled', asleep); @@ -70,11 +72,28 @@ export function renderThread(phone: Phone, id: string): HTMLElement[] { sendBtn.toggleAttribute('disabled', asleep); const mic = dictateInput(input); const form = h('form.sp-compose', {}, mic.el, sendBtn); - const msgs = h( - 'div.sp-thread', - {}, - ...thread.map((m) => (m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text))), - ); + const msgs = h('div.sp-thread', {}); + // Long threads render a window, not all 50 capped messages at full length: the last + // WINDOW_MSGS with a "show earlier" expander, so opening scrolls without jank. + const WINDOW_MSGS = 20; + let showAll = false; + const paintMsgs = () => { + msgs.replaceChildren(); + const hidden = showAll ? 0 : Math.max(0, thread.length - WINDOW_MSGS); + if (hidden) { + const more = h('button.btn.sp-more', { type: 'button' }, `Show earlier (${hidden})`); + more.addEventListener('click', () => { + showAll = true; + paintMsgs(); + }); + msgs.append(more); + } + for (const m of thread.slice(thread.length - (showAll ? thread.length : WINDOW_MSGS))) { + msgs.append(m.dir === 'out' ? h('div.sp-bubble sp-out', {}, m.text) : h('div.sp-bubble sp-note', {}, m.text)); + } + msgs.scrollTop = msgs.scrollHeight; + }; + paintMsgs(); form.addEventListener('submit', (e) => { e.preventDefault(); const text = input.value.trim(); @@ -102,6 +121,7 @@ export function renderThread(phone: Phone, id: string): HTMLElement[] { st.threads[key] = appendSms(st.threads[key], text); st.threads = pruneThreadKeys(st.threads); saveThread(key, st.threads[key] ?? []); + thread = st.threads[key] ?? []; st.recents = logRecent(st.recents, { kind: 'sms', workerId: now.id, name: now.name, at: Date.now() }); store.emit('smartphone'); phone.deps.sound.smsSwoosh(); @@ -113,10 +133,10 @@ export function renderThread(phone: Phone, id: string): HTMLElement[] { input.focus(); phone.refreshTabs(); }); - phone.setLive({ id, hint, input, send: sendBtn, msgs, dropMic: mic.drop }); + phone.setLive({ id, who, hint, input, send: sendBtn, msgs, dropMic: mic.drop }); // Keep the focus timer tracked, so it can't fire after close or a view change. phone.after(30, () => input.focus()); - return [h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, h('div.sp-name', {}, `💬 ${w.name}`))), msgs, hint, form]; + return [h('div.sp-who', {}, h('span.sp-dot', { style: `background:${dotColor(w)}` }), h('div.sp-main', {}, who)), msgs, hint, form]; } /** Follows the worker without a redraw; a worker turning lost switches to the Fix it UI. */ @@ -124,6 +144,7 @@ export function liveThread(phone: Phone, live: ThreadLive | null) { if (!live) return phone.draw(); const w = phone.worker(live.id); if (!w || w.lost) return phone.draw(); + live.who.replaceChildren(`💬 ${w.name}`); live.hint.replaceChildren(statusNote(w)); const asleep = isAsleep(w.status); live.input.toggleAttribute('disabled', asleep); diff --git a/src/client/features/smartphone/sound.ts b/src/client/features/smartphone/sound.ts index e26babf22..ed6bee09a 100644 --- a/src/client/features/smartphone/sound.ts +++ b/src/client/features/smartphone/sound.ts @@ -15,10 +15,15 @@ function ready(a: AudioCore, name: string): AudioContext | null { return ctx; } -/** An old landline's two-tone ring (440 + 480 Hz), rung twice, for placing a call. */ -export function phoneRing(a: AudioCore) { +/** + * An old landline's two-tone ring (440 + 480 Hz), rung twice, for placing a call. Returns a stop + * handle: hanging up mid-dial silences the scheduled rings instead of playing them out with no + * call attached (safe to call after the ring ended on its own). + */ +export function phoneRing(a: AudioCore): () => void { const ctx = ready(a, 'phone-ring'); - if (!ctx) return; + if (!ctx) return () => {}; + const voices: { o: OscillatorNode; g: GainNode }[] = []; for (const at of [0, 0.9]) { const t0 = ctx.currentTime + at; for (const f of [440, 480]) { @@ -33,8 +38,24 @@ export function phoneRing(a: AudioCore) { o.connect(g).connect(a.alerts); o.start(t0); o.stop(t0 + 0.7); + voices.push({ o, g }); } } + let stopped = false; + return () => { + if (stopped) return; + stopped = true; + const now = ctx.currentTime; + for (const { o, g } of voices) { + try { + g.gain.cancelScheduledValues(now); + g.gain.setTargetAtTime(0.0001, now, 0.02); + o.stop(now + 0.15); + } catch { + // Already ended on its own: nothing to silence. + } + } + }; } /** An SMS going out: a short whoosh upward. */ diff --git a/src/client/features/smartphone/ui.ts b/src/client/features/smartphone/ui.ts index e1a7daad4..d795c5ad6 100644 --- a/src/client/features/smartphone/ui.ts +++ b/src/client/features/smartphone/ui.ts @@ -11,9 +11,9 @@ export interface SmartphoneDeps { net: { send(msg: { t: 'worker.prompt'; workerId: string; prompt: string }): void }; /** Puts you at the worker's desk; false when there's no getting there. */ goToWorker(id: string): boolean; - openWorkerTerminal(id: string): void; + openWorkerTerminal(id: string, doing?: string): void; fixLostWorktree(w: WorkerInfo): void; - sound: { phoneRing(): void; smsSwoosh(): void; dialBlip(): void }; + sound: { phoneRing(): () => void; smsSwoosh(): void; dialBlip(): void }; } export type PhoneView = { t: 'contacts' } | { t: 'recents' } | { t: 'actions'; id: string } | { t: 'calling'; id: string } | { t: 'thread'; id: string }; @@ -33,6 +33,8 @@ export interface Phone { /** The placed call's connect timer: separate from view timers, dropped on hang-up, close or fire. */ setConnect(ms: number, fn: () => void): void; clearConnect(): void; + /** The ringback's stop handle: hanging up mid-dial silences it instead of playing out. */ + setRing(stop: (() => void) | null): void; setLive(live: ThreadLive | null): void; close(): void; } @@ -71,6 +73,11 @@ export function openSmartphone(deps: SmartphoneDeps) { if (connectTimer) clearTimeout(connectTimer); connectTimer = null; }; + let ringStop: (() => void) | null = null; + const stopRing = () => { + ringStop?.(); + ringStop = null; + }; const phone: Phone = { deps, @@ -83,6 +90,7 @@ export function openSmartphone(deps: SmartphoneDeps) { refreshTabs: () => drawTabs(store.workers.size), hangUp: () => { if (view.t !== 'calling') return false; + stopRing(); clearConnect(); view = { t: 'actions', id: view.id }; deps.sound.dialBlip(); @@ -98,6 +106,10 @@ export function openSmartphone(deps: SmartphoneDeps) { }, ms); }, clearConnect, + setRing: (stop) => { + stopRing(); + ringStop = stop; + }, setLive: (l) => { live?.dropMic(); live = l; @@ -105,9 +117,12 @@ export function openSmartphone(deps: SmartphoneDeps) { close: () => modal.close(), }; - /** What the open phone shows, for skipping redraws whose inputs didn't change. */ + /** What the open phone shows, for skipping redraws whose inputs didn't change. Cheap stable + * fields only: activity rides as length + prefix, since its full text rebuilds the string that + * decides *not* to redraw on every activity tick. */ function sig(contacts: WorkerInfo[]): string { - return `${contacts.map((w) => [w.id, w.name, w.color, w.status, w.activity ?? '', w.pr?.number ?? 0].join('|')).join('~')}#${store.smartphone.recents.length}`; + const field = (w: WorkerInfo) => [w.id, w.kind, w.name, w.color, w.status, w.pr?.number ?? 0, w.lost ? 1 : 0, w.activity ? `${w.activity.length}:${w.activity.slice(0, 24)}` : ''].join('|'); + return `${contacts.map(field).join('~')}#${store.smartphone.recents.length}`; } function draw() { @@ -141,11 +156,21 @@ export function openSmartphone(deps: SmartphoneDeps) { function drawBody(contacts: WorkerInfo[]) { phone.setLive(null); - if (view.t === 'contacts') body.replaceChildren(...renderContacts(phone, contacts)); - else if (view.t === 'recents') body.replaceChildren(...renderRecents(phone)); - else if (view.t === 'actions') body.replaceChildren(...renderActions(phone, view.id)); - else if (view.t === 'calling') body.replaceChildren(...renderCalling(phone, view.id)); - else body.replaceChildren(...renderThread(phone, view.id)); + // A renderer whose worker vanished redirects via go() (which paints the fallback itself): + // skip the replace or the empty fallback nodes wipe what it just painted. + const at = view; + const nodes = + view.t === 'contacts' + ? renderContacts(phone, contacts) + : view.t === 'recents' + ? renderRecents(phone) + : view.t === 'actions' + ? renderActions(phone, view.id) + : view.t === 'calling' + ? renderCalling(phone, view.id) + : renderThread(phone, view.id); + if (view !== at) return; + body.replaceChildren(...nodes); } const tab = (label: string, n: number, on: boolean, go: () => void) => h('button.sp-tab', { type: 'button', class: on ? 'on' : '', 'aria-pressed': String(on), onclick: go }, label, n ? h('span.sp-count', {}, String(n)) : null); @@ -184,6 +209,7 @@ export function openSmartphone(deps: SmartphoneDeps) { const modal = openModal(el, { doing: '📱 checking contacts', onClose: () => { + stopRing(); clearConnect(); clearViewTimers(); if (raf) cancelAnimationFrame(raf); diff --git a/src/client/features/waiting/index.ts b/src/client/features/waiting/index.ts index b16e8cb21..56f324551 100644 --- a/src/client/features/waiting/index.ts +++ b/src/client/features/waiting/index.ts @@ -110,13 +110,13 @@ export function installWaiting(ctx: Ctx, core: CoreState, parts: Pick pointToWaiting(now)); /** Opening a sleeping worker's terminal wakes it, so there's nothing to press first. */ - function openWorkerTerminal(id: string, find?: TerminalFind) { + function openWorkerTerminal(id: string, find?: TerminalFind, doing?: string) { const w = store.workers.get(id); if (!w) return; const { actions } = parts; if (w.lost) return actions.fixLostWorktree(w); if (isAsleep(w.status)) actions.resumeWorker(w); - openTerminal(net, id, () => openWorkerChanges(id), find); + openTerminal(net, id, () => openWorkerChanges(id), find, doing ? { doing } : undefined); } /** 🔎 the chat and every terminal; a terminal line opens that terminal right at it. */ diff --git a/src/client/lab/phone.ts b/src/client/lab/phone.ts index ad921591d..ec20be0ac 100644 --- a/src/client/lab/phone.ts +++ b/src/client/lab/phone.ts @@ -27,7 +27,7 @@ openSmartphone({ goToWorker: () => true, openWorkerTerminal: () => {}, fixLostWorktree: () => {}, - sound: { phoneRing: () => {}, smsSwoosh: () => {}, dialBlip: () => {} }, + sound: { phoneRing: () => () => {}, smsSwoosh: () => {}, dialBlip: () => {} }, }); // The SMS thread through the real taps: first contact, then its SMS button (or its Call button). diff --git a/src/client/sound/index.ts b/src/client/sound/index.ts index c86221b71..0212b809f 100644 --- a/src/client/sound/index.ts +++ b/src/client/sound/index.ts @@ -274,9 +274,9 @@ export class OfficeSound { needsYou(this.a, again); } - /** The smartphone's double ring for a call you place. */ - phoneRing() { - phoneRing(this.a); + /** The smartphone's double ring for a call you place: returns a stop for hanging up mid-dial. */ + phoneRing(): () => void { + return phoneRing(this.a); } /** An SMS going out. */ diff --git a/src/client/ui/terminal.ts b/src/client/ui/terminal.ts index fbf36b0b1..ecfb5d396 100644 --- a/src/client/ui/terminal.ts +++ b/src/client/ui/terminal.ts @@ -71,6 +71,8 @@ export interface TerminalOptions { * take the focus as it opens either, so a phone's keyboard stays down until you tap into it. */ keypad?: boolean; + /** What having it open says you're doing, under your name tag (default: in this worker's terminal). */ + doing?: string; } /** The keypad's keys: what each types, or a function of the terminal for the ones that depend on its mode. */ @@ -335,7 +337,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, const modal = openModal(el, { backdropCloses: true, - doing: `💻 in ${info.name}'s terminal`, + doing: opts.doing ?? `💻 in ${info.name}'s terminal`, onClose: (byEsc) => { // Leaving with Esc while the program wanted one (you were in /skills, say): say how to send it one. if (byEsc && ready && screenMentionsEsc(term)) toast(`Esc left the terminal. To send ${store.workers.get(workerId)?.name ?? info.name} an Esc (to close a menu), use ⎋ Esc at the top or Ctrl+[`);