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/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. 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..011135cd1 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) { @@ -45,13 +45,19 @@ 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( [ { 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: () => 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/call.ts b/src/client/features/smartphone/call.ts new file mode 100644 index 000000000..834f81a3d --- /dev/null +++ b/src/client/features/smartphone/call.ts @@ -0,0 +1,80 @@ +// 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 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.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; + } + 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; + } + phone.close(); + phone.deps.fixLostWorktree(now); + 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; + } + // 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, `πŸ“± on a call with ${open.name}`); + }); +} + +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..5890fadd9 --- /dev/null +++ b/src/client/features/smartphone/contacts.ts @@ -0,0 +1,146 @@ +// 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); +} + +/** 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 { + 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 ? 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}`, 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', + {}, + 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: whenTitle(r.at) }, 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/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..057366643 --- /dev/null +++ b/src/client/features/smartphone/logic.ts @@ -0,0 +1,47 @@ +// 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' ? '🐚' : 'πŸ€–'; +} + +/** 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. 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; + 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'; + 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..130f68529 --- /dev/null +++ b/src/client/features/smartphone/sms.ts @@ -0,0 +1,153 @@ +// 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; + who: HTMLElement; + 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); + 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); + 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', {}); + // 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(); + 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] ?? []); + 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(); + 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, 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', {}, who)), 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.who.replaceChildren(`πŸ’¬ ${w.name}`); + 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/sound.ts b/src/client/features/smartphone/sound.ts new file mode 100644 index 000000000..ed6bee09a --- /dev/null +++ b/src/client/features/smartphone/sound.ts @@ -0,0 +1,94 @@ +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. + +/** 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 null; + if (ctx.state === 'suspended') void ctx.resume(); + a.count(name); + return ctx; +} + +/** + * 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 () => {}; + const voices: { o: OscillatorNode; g: GainNode }[] = []; + 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); + 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. */ +export function smsSwoosh(a: AudioCore) { + const ctx = ready(a, 'sms-swoosh'); + if (!ctx) return; + 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) { + const ctx = ready(a, 'dial-blip'); + if (!ctx) return; + 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..358311e4c --- /dev/null +++ b/src/client/features/smartphone/ui.css @@ -0,0 +1,40 @@ +/* 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; } +.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 new file mode 100644 index 000000000..d795c5ad6 --- /dev/null +++ b/src/client/features/smartphone/ui.ts @@ -0,0 +1,223 @@ +import './ui.css'; +import type { WorkerInfo } from '../../../shared/protocol'; +import { byUrgency } from '../../nextup'; +import { store } from '../../state'; +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 }; + /** Puts you at the worker's desk; false when there's no getting there. */ + goToWorker(id: string): boolean; + openWorkerTerminal(id: string, doing?: string): void; + fixLostWorktree(w: WorkerInfo): 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 }; + +/** 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; + /** 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; +} + +/** + * 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. 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: 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' }, 'β€Ή'); + 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 clearViewTimers = () => { + for (const t of viewTimers) clearTimeout(t); + viewTimers = []; + }; + const clearConnect = () => { + if (connectTimer) clearTimeout(connectTimer); + connectTimer = null; + }; + let ringStop: (() => void) | null = null; + const stopRing = () => { + ringStop?.(); + ringStop = null; + }; + + 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; + stopRing(); + 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, + setRing: (stop) => { + stopRing(); + ringStop = stop; + }, + setLive: (l) => { + live?.dropMic(); + live = l; + }, + close: () => modal.close(), + }; + + /** 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 { + 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() { + if (raf) { + cancelAnimationFrame(raf); + raf = 0; + } + clearViewTimers(); + pruneRecents(); + const contacts = byUrgency(store.workers.values()); + 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', count, view.t !== 'recents', () => { + if (phone.hangUp()) return; + phone.go({ t: 'contacts' }); + deps.sound.dialBlip(); + }), + tab('Recents', store.smartphone.recents.length, view.t === 'recents', () => { + if (phone.hangUp()) return; + phone.go({ t: 'recents' }); + deps.sound.dialBlip(); + }), + ); + } + + function drawBody(contacts: WorkerInfo[]) { + phone.setLive(null); + // 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); + + back.addEventListener('click', () => { + 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 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(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(store.workers.size); + }; + const offWorkers = store.on('workers', onWorkers); + const offPhone = store.on('smartphone', onPhone); + const modal = openModal(el, { + doing: 'πŸ“± checking contacts', + onClose: () => { + stopRing(); + clearConnect(); + clearViewTimers(); + if (raf) cancelAnimationFrame(raf); + phone.setLive(null); + offWorkers(); + offPhone(); + }, + }); + close.addEventListener('click', () => modal.close()); + draw(); +} 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.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..ec20be0ac --- /dev/null +++ b/src/client/lab/phone.ts @@ -0,0 +1,51 @@ +// 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(); +} +// 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' : params.has('recent') ? 'recents' : '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..0212b809f 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: returns a stop for hanging up mid-dial. */ + phoneRing(): () => void { + return 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/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, ]; 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/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+[`); diff --git a/src/shared/smartphone.ts b/src/shared/smartphone.ts new file mode 100644 index 000000000..9a421683b --- /dev/null +++ b/src/shared/smartphone.ts @@ -0,0 +1,138 @@ +// 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, and how many lines Recents keeps. */ +export const MAX_THREAD = 50; +export const MAX_RECENTS = 20; +/** 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; + +/** 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 { + 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); +} + +/** 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'; +} + +/** 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 names: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(THREAD_PREFIX)) names.push(k); + } + 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 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 gone: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(THREAD_PREFIX)) gone.push(k); + } + for (const k of gone) localStorage.removeItem(k); + } catch { + // 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/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..42181f381 --- /dev/null +++ b/tests/smartphone.test.ts @@ -0,0 +1,153 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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', () => { + 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(), {}); + saveThread('f1/w1', [{ dir: 'out', text: 'hi', at: 0 }]); + clearThreads(); +}); + +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 = { + 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 { + if (had === undefined) delete g.localStorage; + else g.localStorage = had; + } +} + +test('loading keeps only well-formed lines, capped in length and count', () => { + withStorage((kept) => { + 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(); + // 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 writes one key; empties remove theirs; clearing forgets threads only', () => { + withStorage((kept) => { + 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' }), '🐚'); + assert.equal(contactSub({ activity: 'Wants permission: npm test' }), 'Wants permission: npm test'); + // 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'); +}); + +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'); +}); + +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'); +});