diff --git a/README.md b/README.md index b61efbc8e..bdb69aac9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A full web desktop environment with 36 bundled apps, 108 catalog apps, 47 MCP pl **Framework-agnostic by design.** taOS owns everything that matters: your agent's memory, files, communication channels, model access, and configuration. The agent framework is just a replaceable execution engine. Switch from SmolAgents to LangChain to OpenClaw and your agent keeps its entire history, all its Telegram/Discord/Slack connections, its trained LoRA adapters, its files, and its API keys. No migration, no data loss, no reconfiguration. This is possible because taOS manages the full agent lifecycle outside the framework. -**[taOSmd](https://github.com/jaylfc/taosmd) — Framework-agnostic AI memory system.** 97.0% **end-to-end Judge accuracy** on [LongMemEval-S](https://github.com/xiaowu0162/LongMemEval) — retrieve → generate → judge-with-LLM-grader, 500 questions across 50+ sessions each. For context, the most-cited open comparators — MemPalace (96.6%) and agentmemory (95.2%) — publish **Recall@5** retrieval scores on the same dataset, which measures only whether the correct session lands in the top-5 (no generation, no judge). The metrics aren't apples-to-apples until one of us re-runs end-to-end; ours is the stricter measurement. Per-category on our hybrid-plus-query-expansion config: knowledge-update 100%, multi-session 98.5%, single-session-user 97.1%, single-session-assistant 96.4%, temporal-reasoning 94.0%, single-session-preference 90.0%. Everything runs on a £170 Orange Pi 5 Plus with no cloud dependencies. The stack: temporal knowledge graph with validity windows + contradiction detection, hybrid semantic+keyword vector search with cross-encoder rerank and LLM-assisted query expansion (the "Librarian" layer), zero-loss append-only archive, automatic fact extraction, intent-aware retrieval routing, multi-layer context assembly. Any agent framework can read/write through the HTTP API. +**[taOSmd](https://github.com/jaylfc/taosmd) -- Framework-agnostic AI memory system.** 97.0% **end-to-end Judge accuracy** on [LongMemEval-S](https://github.com/xiaowu0162/LongMemEval) -- retrieve → generate → judge-with-LLM-grader, 500 questions across 50+ sessions each. For context, the most-cited open comparators -- MemPalace (96.6%) and agentmemory (95.2%) -- publish **Recall@5** retrieval scores on the same dataset, which measures only whether the correct session lands in the top-5 (no generation, no judge). The metrics aren't apples-to-apples until one of us re-runs end-to-end; ours is the stricter measurement. Per-category on our hybrid-plus-query-expansion config: knowledge-update 100%, multi-session 98.5%, single-session-user 97.1%, single-session-assistant 96.4%, temporal-reasoning 94.0%, single-session-preference 90.0%. Everything runs on a £170 Orange Pi 5 Plus with no cloud dependencies. The stack: temporal knowledge graph with validity windows + contradiction detection, hybrid semantic+keyword vector search with cross-encoder rerank and LLM-assisted query expansion (the "Librarian" layer), zero-loss append-only archive, automatic fact extraction, intent-aware retrieval routing, multi-layer context assembly. Any agent framework can read/write through the HTTP API. --- @@ -40,7 +40,7 @@ A full web desktop environment with 36 bundled apps, 108 catalog apps, 47 MCP pl App store on mobile

-

Same platform, same session — desktop, tablet, and phone.

+

Same platform, same session -- desktop, tablet, and phone.

Six agents on different frameworks chatting in a shared taOS channel @@ -91,7 +91,9 @@ taOS ships with a full browser-based desktop environment. Open it at `http://you ### 39 Bundled Desktop Apps -**Platform apps (25):** Messages (WebSocket chat), Projects (Kanban + A2A), Agents (deploy wizard + logs + skills), Store (47+ apps), Settings (multi-section with Memory capture toggles), Models, Providers (cloud LLM provider management, add/test/remove OpenAI, Anthropic, DeepSeek, and compatible APIs), Memory (User + Agent sections), MCP (plugin manager), Channels, Secrets, Tasks, Import, Images (Image Studio: Create / Library / Edit with tier-aware inpaint + upscale backends), Dashboard (Activity), Cluster (worker management + health), Library (knowledge pipeline, document library with collections and search), Reddit (subreddit browser with saved threads and memory ingest), YouTube (video library with transcript extraction), GitHub (repository browser with code search), X (feed monitor with bookmarks and memory capture), Agent Browsers (manage agent browser sessions), Files (real VFS with workspace + shared folders), taOS Agent (Agent-as-a-Model endpoint), Guides. +**Platform apps (22):** Messages (WebSocket chat), Mail (IMAP/SMTP accounts, read and send), Projects (Kanban + A2A), Agents (deploy wizard + logs + skills), Store (47+ apps), Settings (multi-section with Memory capture toggles), Models, Providers (cloud LLM provider management, add/test/remove OpenAI, Anthropic, DeepSeek, and compatible APIs), Memory (User + Agent sections), MCP (plugin manager), Channels, Secrets, Tasks, Import, Images (Image Studio: Create / Library / Edit with tier-aware inpaint + upscale backends), Dashboard (Activity), Cluster (worker management + health), Library (knowledge pipeline, document library with collections and search), Agent Browsers (manage agent browser sessions), Files (real VFS with workspace + shared folders), taOS Agent (Agent-as-a-Model endpoint), Guides. + +**Optional taOS apps (4):** Reddit (subreddit browser with saved threads and memory ingest), YouTube (video library with transcript extraction), GitHub (repository browser with code search), and X (feed monitor with bookmarks and memory capture) ship in the build but are not installed by default; install or remove them from the Store's "taOS Apps" section. **OS apps (10):** Weather, Calculator (math.js), Calendar (month view), Contacts (CRUD), Browser (URL-rewriting proxy, agent-ready), Browser (Streamed) (real WebRTC Chromium streamed from the host), Media Player (Plyr), Text Editor (CodeMirror 6 with Obsidian-style theme), Image Viewer (zoom/rotate), Terminal (real PTY + SSH client). @@ -100,10 +102,10 @@ taOS ships with a full browser-based desktop environment. Open it at `http://you The Activity app includes a Cluster overview panel showing live worker status and resource stats alongside the process monitor. The Model Browser surfaces cloud models (from configured providers) alongside local catalog models, with a provider badge per entry. The deploy wizard accepts cloud models as inference targets.

- App store — 108 catalog apps, 16 agent frameworks, hardware-filtered + App store -- 108 catalog apps, 16 agent frameworks, hardware-filtered

-

The Store — agent frameworks, models, plugins, services. One-click install, hardware-filtered.

+

The Store -- agent frameworks, models, plugins, services. One-click install, hardware-filtered.

## Key Features @@ -114,14 +116,14 @@ Full browser-based desktop OS with window manager (float + snap), dock, launchpa Auto-detects touch devices and swaps the desktop for a widget-first home screen with customisable multi-page layout (swipe or tap dots to navigate), a persistent dock with app launcher and app switcher, and desktop-style app windows with close/minimise title bars. The top bar features iOS 26-style frosted glass buttons for search and notifications, with a "taOS" home button. Installable as a fullscreen PWA on iOS and Android. A standalone Chat PWA is available at `/chat-pwa` and installs like a private Discord.

- Mobile activity view — per-core CPU, NPU, RAM + Mobile activity view -- per-core CPU, NPU, RAM   - Mobile cluster view — worker hardware, thermals, network + Mobile cluster view -- worker hardware, thermals, network   - Mobile scheduler view — worker hardware and capabilities + Mobile scheduler view -- worker hardware and capabilities

-

Full system observability on your phone — per-core stats, cluster health, and the hardware-aware scheduler.

+

Full system observability on your phone -- per-core stats, cluster health, and the hardware-aware scheduler.

### User Memory System Personal memory powered by [taOSmd](https://github.com/jaylfc/taosmd), think Pieces App but self-hosted. Temporal knowledge graph + hybrid vector search + zero-loss archive auto-captures conversations from the Message Hub, notes from the Text Editor, file activity, and search queries. Per-category capture toggles live in Settings. Available in global search (Ctrl+Space) alongside apps, with a "Save to Memory" right-click option on the desktop. Agents can optionally read user memory with explicit permission via the `TAOS_USER_MEMORY_URL` environment variable. A "My Memory" section in the Memory app sits alongside agent memories. @@ -149,7 +151,7 @@ curl -sL https://raw.githubusercontent.com/jaylfc/tinyagentos/master/tinyagentos ```powershell # Windows 10/11, one-line worker install (PowerShell, mirrors the -# Linux/macOS installer — registers a Scheduled Task so the worker +# Linux/macOS installer -- registers a Scheduled Task so the worker # starts at boot and survives logout) $env:TAOS_CONTROLLER_URL = 'http://your-server:6969' iwr -useb https://raw.githubusercontent.com/jaylfc/tinyagentos/master/scripts/install-worker.ps1 | iex @@ -192,13 +194,13 @@ One-click install for agent frameworks, AI models, and services. Hardware-aware, ### Agent Deployment 5-step wizard: pick framework → choose model → configure → deploy into an isolated container (LXC on bare metal, Docker on VPS, auto-detected). Each agent gets its own memory system (taOSmd instance), its own file storage, and its own network identity. The framework runs inside the container but taOS manages everything around it: memory, channels, secrets, model access, scheduled tasks, and inter-agent communication. This means the framework is a swappable component, not a lock-in decision. -> **Running taOS *inside* an LXC (e.g. Proxmox)?** Deploying an agent creates a *nested* container, which an **unprivileged** LXC cannot do — the kernel can't remap the nested container's filesystem, so the deploy fails with an `idmapped storage / change ownership` error. Run the taOS LXC as **privileged with nesting enabled**. On Proxmox: untick *Unprivileged container* and set Options → Features → `nesting=1` (plus `keyctl=1`, `fuse=1`), then redeploy. Bare-metal and VM installs are unaffected. (taOS detects this and surfaces the fix in the deploy error.) +> **Running taOS *inside* an LXC (e.g. Proxmox)?** Deploying an agent creates a *nested* container, which an **unprivileged** LXC cannot do -- the kernel can't remap the nested container's filesystem, so the deploy fails with an `idmapped storage / change ownership` error. Run the taOS LXC as **privileged with nesting enabled**. On Proxmox: untick *Unprivileged container* and set Options → Features → `nesting=1` (plus `keyctl=1`, `fuse=1`), then redeploy. Bare-metal and VM installs are unaffected. (taOS detects this and surfaces the fix in the deploy error.)

- Agents app empty state on mobile — one tap to deploy + Agents app empty state on mobile -- one tap to deploy

-

The Agents app on mobile — one tap from empty to your first deployed agent.

+

The Agents app on mobile -- one tap from empty to your first deployed agent.

### Channel Hub (Framework-Agnostic Messaging) Most agent frameworks force you to wire up Telegram, Discord, or Slack directly into their code. If you switch frameworks, you rebuild all those integrations from scratch. taOS flips this: the platform owns the messaging connections and routes messages to whichever framework the agent currently uses. Switch an agent from SmolAgents to LangChain and it keeps every channel, every conversation, every connection. The framework never touches the bot tokens. @@ -236,12 +238,12 @@ Two first-party studio apps turn the cluster's generation backends into somethin ### Agent Memory System ([taOSmd](https://github.com/jaylfc/taosmd)) taOSmd is installed as a Python dependency from PyPI (`pip install taosmd`, pinned to 0.3.0 in `pyproject.toml`, published via Trusted Publishing): **97.0% end-to-end Judge accuracy** on LongMemEval-S (retrieve, generate, LLM-grade against the reference answer). The most-cited open comparators (MemPalace 96.6%, agentmemory 95.2%) publish **Recall@5** retrieval scores on the same dataset, which measures only "did the right session land in the top-5" with no generation and no judge, so the numbers are not apples-to-apples until one of us re-runs end-to-end; ours is the stricter measurement. The Librarian layer's LLM-assisted query expansion adds a measured **+15.4% on the vocabulary-gap axis** (45% recall@lag25 with full pipeline + Librarian, vs 30% without) on long-horizon sessions where the cross-encoder alone isn't enough. -Two recent additions (separate measurements — not part of the LongMemEval-S headline above): +Two recent additions (separate measurements -- not part of the LongMemEval-S headline above): - **Upgraded low-tier embedding default.** Low-tier dense retrieval now defaults to `snowflake-arctic-embed-s`: **+0.057 judged retrieval quality** on the full 1540-QA LoCoMo set (0.730 vs 0.674 for MiniLM) at the same 384 dimensions and the same latency (about 13ms/embed on an Orange Pi). A free accuracy upgrade for low-end and SBC installs. MiniLM stays supported and is the model the 97.0% LongMemEval-S headline was measured on; existing installs are unchanged. -- **Provable Memory (opt-in, off by default).** Because taOSmd keeps a zero-loss archive, it can verify every extracted fact against the source spans it came from, mark it supported or unsupported, and demote (never delete) the unsupported ones at recall. That makes the extraction-hallucination rate a standing, measurable number: **18.8% of extracted facts on LoCoMo were not fully supported by their source** (cross-family verified) — something extraction-based systems that discard the source cannot measure. +- **Provable Memory (opt-in, off by default).** Because taOSmd keeps a zero-loss archive, it can verify every extracted fact against the source spans it came from, mark it supported or unsupported, and demote (never delete) the unsupported ones at recall. That makes the extraction-hallucination rate a standing, measurable number: **18.8% of extracted facts on LoCoMo were not fully supported by their source** (cross-family verified) -- something extraction-based systems that discard the source cannot measure. -Memory layers: temporal knowledge graph with validity windows + contradiction detection, hybrid semantic+keyword vector search (ONNX arctic-embed-s low-tier default / MiniLM / Nomic), zero-loss append-only archive with FTS5, session catalog over the archive, and a crystal store of compressed session digests with extracted lessons. Processing: regex + LLM fact extraction (qwen3:4b), 30-min-gap session splitter, tiered enricher (heuristic / 4B / 9B+), session crystallizer, **secret filtering with 17 regex patterns auto-redacting on every ingest**, and Ebbinghaus retention scoring with hot/warm/cold tiers. Retrieval: parallel fan-out across all layers, query expansion, intent classifier that weights an RRF merge, ms-marco-MiniLM cross-encoder reranking, BFS graph expansion, and a token-budgeted L0–L3 context assembler. +Memory layers: temporal knowledge graph with validity windows + contradiction detection, hybrid semantic+keyword vector search (ONNX arctic-embed-s low-tier default / MiniLM / Nomic), zero-loss append-only archive with FTS5, session catalog over the archive, and a crystal store of compressed session digests with extracted lessons. Processing: regex + LLM fact extraction (qwen3:4b), 30-min-gap session splitter, tiered enricher (heuristic / 4B / 9B+), session crystallizer, **secret filtering with 17 regex patterns auto-redacting on every ingest**, and Ebbinghaus retention scoring with hot/warm/cold tiers. Retrieval: parallel fan-out across all layers, query expansion, intent classifier that weights an RRF merge, ms-marco-MiniLM cross-encoder reranking, BFS graph expansion, and a token-budgeted L0-L3 context assembler. taOS wraps taOSmd with platform-specific scheduling (job queue, resource manager, worker heartbeat, gaming detection) for multi-agent coordination on resource-constrained devices. QMD (`qmd.service`, port 7832) remains as the NPU-accelerated embedding / rerank / query-expansion backend. Per-tenant isolation is handled by `dbPath` routing: each agent's index lives at `data/agent-memory/{name}/index.sqlite`. @@ -305,14 +307,14 @@ Search across agents, apps, messages, and files from a single endpoint. Finds an ### Monitoring & Management

- Activity — CPU, NPU, memory, disk, cluster + Activity -- CPU, NPU, memory, disk, cluster

- Activity scheduler — per-worker hardware and capabilities + Activity scheduler -- per-worker hardware and capabilities

-

Every core, every worker, every capability — visible at a glance.

+

Every core, every worker, every capability -- visible at a glance.

- **Dashboard**. KPIs, CPU/RAM sparklines, activity feed, quick actions, backend health, cluster stats. The Loaded Models widget unions controller-local models with each cluster worker's heartbeat-reported models, with a per-host badge on each entry. It always renders, shows an empty state when nothing is loaded rather than hiding. - **Health Debug Page.** Checks all services, backends, agents, disk, RAM with live status @@ -378,7 +380,7 @@ taOS Controller (FastAPI + htmx + React Desktop Shell) ├── App Store + Registry (108 apps + 47 MCP plugins, manifest-based) ├── Live Model Browser (HuggingFace + Ollama search) ├── Container Manager (LXC or Docker, auto-detected) -├── Agent Memory (taOSmd — temporal KG, hybrid vector search, zero-loss archive, session catalog, crystal store, librarian) +├── Agent Memory (taOSmd -- temporal KG, hybrid vector search, zero-loss archive, session catalog, crystal store, librarian) ├── Health Monitor + Notifications ├── Secrets Manager (encrypted, per-agent access) ├── Task Scheduler (cron with presets) @@ -414,7 +416,7 @@ Run `curl -fsSL https://raw.githubusercontent.com/jaylfc/tinyagentos/master/scri |---|---| | `/etc/systemd/system/tinyagentos.service` | Main controller systemd unit. Runs uvicorn on port 6969. | | `/etc/systemd/system/qmd.service` | Embedding backend (embed / rerank / query expansion) on port 7832. Used by taOSmd for vector operations. Backed by rkllama on RK3588 boards or local node-llama-cpp elsewhere. | -| `tinyagentos-sdcpp.service` (repo root) | (RK3588 only) CPU image generation backend. Manual setup only — not auto-installed by the installer. | +| `tinyagentos-sdcpp.service` (repo root) | (RK3588 only) CPU image generation backend. Manual setup only -- not auto-installed by the installer. | | `/home//tinyagentos/` | The repo checkout. All code, all configs. | | `/home//tinyagentos/.venv/` | Python virtualenv. All Python deps live here, never `pip install` to system Python. | | `/home//tinyagentos/data/` | All persistent state. **One directory to back up.** Contains: agent state YAMLs, agent memory SQLite indexes, agent workspaces, secrets DB, scheduler history, channel credentials, downloaded models, torrent settings, telemetry opt-in flag. | @@ -492,7 +494,7 @@ cd desktop && npm install && npm run build && cd .. sudo systemctl restart tinyagentos ``` -The systemd unit also runs a conditional rebuild as an `ExecStartPre` step — if you skip the manual `npm run build`, the next service restart detects the stale bundle and rebuilds it automatically (~50s startup overhead when it fires). +The systemd unit also runs a conditional rebuild as an `ExecStartPre` step -- if you skip the manual `npm run build`, the next service restart detects the stale bundle and rebuilds it automatically (~50s startup overhead when it fires). **Worker:** @@ -516,7 +518,7 @@ sudo systemctl restart tinyagentos sudo systemctl status tinyagentos ``` -On RK3588 boards with CPU image generation enabled, the sd-cpp backend ships as an additional unit file at `tinyagentos-sdcpp.service` in the repo root. This requires manual setup — it is not auto-installed by `install-server.sh`. Copy to `/etc/systemd/system/`, substitute the `TAOS_USER`/`TAOS_INSTALL_DIR`/`TAOS_PYTHON` placeholders, then enable with `sudo systemctl enable --now tinyagentos-sdcpp`. +On RK3588 boards with CPU image generation enabled, the sd-cpp backend ships as an additional unit file at `tinyagentos-sdcpp.service` in the repo root. This requires manual setup -- it is not auto-installed by `install-server.sh`. Copy to `/etc/systemd/system/`, substitute the `TAOS_USER`/`TAOS_INSTALL_DIR`/`TAOS_PYTHON` placeholders, then enable with `sudo systemctl enable --now tinyagentos-sdcpp`. ## RK3588 NPU Setup @@ -593,7 +595,7 @@ uv run exo **Cluster-wide scheduler aggregation (deferred to v2).** The cluster scheduler currently routes tasks based on individual worker heartbeats. Aggregating the full cluster view for capacity planning, bin-packing, and priority preemption across all workers is a v2 milestone. The spec is at `docs/design/resource-scheduler.md`. -**Multi-framework group chat ships via a shared SSE bridge (6 frameworks verified).** OpenClaw, Hermes, SmolAgents, Langroid, PocketFlow, and OpenAI Agents SDK all route chat through the same `/api/openclaw/sessions/{slug}/events` + `/reply` endpoints. Each non-openclaw framework ships a ~100-line Python bridge (see `tinyagentos/scripts/install_.sh`) that subscribes to the taOS event stream, runs each user message through the framework's native client, and posts replies back. The separate TAOS Framework Integration Bridge spec (OpenClaw → Hermes proxying, sessionKey routing, round-trip reply shaping) is still unimplemented — that's a deeper integration for cases where one agent wants to delegate a sub-task to another framework's runtime inline. +**Multi-framework group chat ships via a shared SSE bridge (6 frameworks verified).** OpenClaw, Hermes, SmolAgents, Langroid, PocketFlow, and OpenAI Agents SDK all route chat through the same `/api/openclaw/sessions/{slug}/events` + `/reply` endpoints. Each non-openclaw framework ships a ~100-line Python bridge (see `tinyagentos/scripts/install_.sh`) that subscribes to the taOS event stream, runs each user message through the framework's native client, and posts replies back. The separate TAOS Framework Integration Bridge spec (OpenClaw → Hermes proxying, sessionKey routing, round-trip reply shaping) is still unimplemented -- that's a deeper integration for cases where one agent wants to delegate a sub-task to another framework's runtime inline. ## Design Docs @@ -724,13 +726,13 @@ If you maintain one of the libraries above and want a different phrasing or a li taOS is better for the people testing it, filing issues, and sending fixes: -- [@hognek](https://github.com/hognek) — the first community code contributions: Hermes bridge bounded-retry + dedup ([#468](https://github.com/jaylfc/tinyagentos/pull/468)), agent button states + feedback ([#469](https://github.com/jaylfc/tinyagentos/pull/469)), and browser/PWA mobile layout ([#470](https://github.com/jaylfc/tinyagentos/pull/470)). -- [@johny-mnemonic](https://github.com/johny-mnemonic) — first to run taOS on a heterogeneous multi-GPU stack beyond our own hardware, surfacing the gaps behind the agent-deploy and UI fixes (the [#357](https://github.com/jaylfc/tinyagentos/discussions/357) thread). -- [@m13v](https://github.com/m13v) and [@redkjuegos](https://github.com/redkjuegos) — sustained feedback and discussion across issues. +- [@hognek](https://github.com/hognek) -- the first community code contributions: Hermes bridge bounded-retry + dedup ([#468](https://github.com/jaylfc/tinyagentos/pull/468)), agent button states + feedback ([#469](https://github.com/jaylfc/tinyagentos/pull/469)), and browser/PWA mobile layout ([#470](https://github.com/jaylfc/tinyagentos/pull/470)). +- [@johny-mnemonic](https://github.com/johny-mnemonic) -- first to run taOS on a heterogeneous multi-GPU stack beyond our own hardware, surfacing the gaps behind the agent-deploy and UI fixes (the [#357](https://github.com/jaylfc/tinyagentos/discussions/357) thread). +- [@m13v](https://github.com/m13v) and [@redkjuegos](https://github.com/redkjuegos) -- sustained feedback and discussion across issues. - …and everyone who's opened an issue or tested an early build. 🙏 ## License -taOS Sustainable Use License v0.1 — source-available, not open source. See [LICENSE](LICENSE). +taOS Sustainable Use License v0.1 -- source-available, not open source. See [LICENSE](LICENSE). -Free to use, modify, and self-host for personal use and for your own organisation's internal business purposes — forever. A separate commercial license from JAN LABS LTD is required to sell taOS, host it as a paid service, or build it into a product or service you monetise (contact info@taos.my). Prior releases tagged under AGPL-3.0 remain available under AGPL-3.0. +Free to use, modify, and self-host for personal use and for your own organisation's internal business purposes -- forever. A separate commercial license from JAN LABS LTD is required to sell taOS, host it as a paid service, or build it into a product or service you monetise (contact info@taos.my). Prior releases tagged under AGPL-3.0 remain available under AGPL-3.0. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index a8ca12e69..8d35fc2a6 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", + "@tldraw/assets": "4.5.12", "@tldraw/tldraw": "^4.5.10", "@tsparticles/engine": "^3.9.1", "@tsparticles/react": "^3.0.0", @@ -38,6 +39,7 @@ "highlight.js": "^11.11.1", "lucide-react": "^0.500.0", "mathjs": "^15.2.0", + "modern-screenshot": "^4.7.0", "motion": "^12.40.0", "plyr": "^3.8.4", "react": "^19.2.7", @@ -4552,6 +4554,15 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tldraw/assets": { + "version": "4.5.12", + "resolved": "https://registry.npmjs.org/@tldraw/assets/-/assets-4.5.12.tgz", + "integrity": "sha512-To3WWslMuEZ0+QCxUIgCt/AS8B8jq9nRHXRJ5xOIA1JGS2WJ9nS5w78y96Kxtcqidy1y8GqxptraMy7OLQLOFw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@tldraw/utils": "4.5.12" + } + }, "node_modules/@tldraw/editor": { "version": "4.5.12", "resolved": "https://registry.npmjs.org/@tldraw/editor/-/editor-4.5.12.tgz", @@ -7974,6 +7985,12 @@ "node": ">=4" } }, + "node_modules/modern-screenshot": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz", + "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==", + "license": "MIT" + }, "node_modules/motion": { "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", diff --git a/desktop/package.json b/desktop/package.json index e3fe96a56..cc109b2f8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", + "@tldraw/assets": "4.5.12", "@tldraw/tldraw": "^4.5.10", "@tsparticles/engine": "^3.9.1", "@tsparticles/react": "^3.0.0", @@ -42,6 +43,7 @@ "highlight.js": "^11.11.1", "lucide-react": "^0.500.0", "mathjs": "^15.2.0", + "modern-screenshot": "^4.7.0", "motion": "^12.40.0", "plyr": "^3.8.4", "react": "^19.2.7", diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index e5c1be0b2..b4805a802 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -24,6 +24,7 @@ import { LoginScreen } from "@/components/LoginScreen"; import { NotificationToasts } from "@/components/NotificationToast"; import { NotificationCentre } from "@/components/NotificationCentre"; import { useNotificationStore } from "@/stores/notification-store"; +import { useServerNotifications } from "@/hooks/use-server-notifications"; import { TaosAssistantPanel } from "@/components/TaosAssistantPanel"; import { useTaosAgentStore } from "@/stores/taos-agent-store"; import { InstallPromptBanner } from "@/shell/InstallPromptBanner"; @@ -189,6 +190,10 @@ export function App() { useSessionPersistence(); + // Sync the persistent backend notification feed into the bell (desktop and + // mobile both render NotificationCentre under this component). + useServerNotifications(); + // Re-apply the persisted active theme on app boot so a reload keeps the // user's chosen theme app-wide (not only when Settings is opened). useEffect(() => { diff --git a/desktop/src/apps/BrowserApp/AddressBar.tsx b/desktop/src/apps/BrowserApp/AddressBar.tsx index dfc78755d..f2ad5b188 100644 --- a/desktop/src/apps/BrowserApp/AddressBar.tsx +++ b/desktop/src/apps/BrowserApp/AddressBar.tsx @@ -266,7 +266,7 @@ export function AddressBar({ windowId }: AddressBarProps) { setSelectedIndex((i) => Math.max(i - 1, -1)); } }} - className={`w-full bg-shell-bg-deep text-shell-text px-2 py-0.5 rounded text-xs border border-shell-border-subtle focus:border-accent focus:outline-none ${ + className={`w-full bg-transparent text-[13px] text-shell-text placeholder:text-shell-text-tertiary focus:outline-none ${ activeTab?.readerAvailable && activeTab.url !== "about:blank" ? "pr-14" : activeTab?.readerAvailable || activeTab?.url !== "about:blank" diff --git a/desktop/src/apps/BrowserApp/AgentPresencePill.test.tsx b/desktop/src/apps/BrowserApp/AgentPresencePill.test.tsx index 24cab457d..0bf3bac8a 100644 --- a/desktop/src/apps/BrowserApp/AgentPresencePill.test.tsx +++ b/desktop/src/apps/BrowserApp/AgentPresencePill.test.tsx @@ -134,7 +134,7 @@ describe("AgentPresencePill", () => { ); await waitFor(() => { const btn = screen.getByRole("button"); - expect(btn.className).toContain("bg-shell-hover"); + expect(btn.className).toContain("bg-accent-glow"); }); }); diff --git a/desktop/src/apps/BrowserApp/AgentPresencePill.tsx b/desktop/src/apps/BrowserApp/AgentPresencePill.tsx index 3862b519f..f8ad2b2ad 100644 --- a/desktop/src/apps/BrowserApp/AgentPresencePill.tsx +++ b/desktop/src/apps/BrowserApp/AgentPresencePill.tsx @@ -125,8 +125,8 @@ export function AgentPresencePill({ aria-expanded={panelIsOpen} onClick={() => togglePanel(windowId, tabId, firstAgentId)} className={[ - "flex items-center relative p-0.5 rounded-full border border-shell-border-subtle", - panelIsOpen ? "bg-shell-hover" : "bg-shell-bg-deep hover:bg-shell-hover", + "flex items-center relative h-[32px] px-1.5 rounded-full border border-accent-line transition-colors", + panelIsOpen ? "bg-accent-glow" : "bg-accent-soft hover:bg-accent-glow", ].join(" ")} > {/* Stacked avatars */} diff --git a/desktop/src/apps/BrowserApp/BrowserApp.tsx b/desktop/src/apps/BrowserApp/BrowserApp.tsx index 52036578d..f9659536c 100644 --- a/desktop/src/apps/BrowserApp/BrowserApp.tsx +++ b/desktop/src/apps/BrowserApp/BrowserApp.tsx @@ -1,14 +1,16 @@ /** * BrowserApp v2 — top-level container. * - * Mounted by WindowContent for each browser window. Composes: - * - Chrome (browser-specific nav row + profile chip) - * - TabStrip (compact tab strip with embedded AddressBar in active tab) - * - AddressBar (URL input + suggest popover) — for now rendered ABOVE - * TabStrip; PR 5 may move it inside the active tab per - * the Q8 layout A "compact unified bar" mockup. + * Mounted by WindowContent for each browser window. Composes (top to bottom): + * - TabStrip (tab strip + Proxy/Streamed engine toggle) + * - Chrome (toolbar: nav buttons, pill omnibox with AddressBar, agent + * presence pill, settings, profile chip) + * - BookmarksBar * - TabRenderer (iframe pool + discard scheduler) * + * On mobile, a single bottom bar hosts the window switcher, the AddressBar + * omnibox, and the tab overview. + * * On mount, auto-creates the window entry in browser-store with the * default profile if it doesn't exist. Idempotent — preserves any * existing entry (e.g. restored by useSessionPersistence on app boot). @@ -119,21 +121,23 @@ export function BrowserApp({ windowId }: BrowserAppProps) { )} -
+
- +
+ +
@@ -145,12 +149,9 @@ export function BrowserApp({ windowId }: BrowserAppProps) { return (
+ -
- -
- {findOpen && ( { + useBrowserStore.setState({ windows: {} }); + useBrowserStore.getState().createWindow(WIN_ID, "personal"); + const tabId = activeTabId(); + useBrowserStore.getState().navigateTab(WIN_ID, tabId, "https://example.com/"); +}); + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + cleanup(); +}); + +describe("BrowserModeToggle - segmented engine control", () => { + it("renders Proxy and Streamed radios with Proxy selected by default", () => { + render(); + const proxy = screen.getByRole("radio", { name: /proxy browser/i }); + const streamed = screen.getByRole("radio", { name: /streamed browser/i }); + expect(proxy.getAttribute("aria-checked")).toBe("true"); + expect(streamed.getAttribute("aria-checked")).toBe("false"); + }); + + it("marks Streamed selected when the active tab has a liveSession", () => { + useBrowserStore.getState().setTabLiveSession(WIN_ID, activeTabId(), { + nekoUrl: "http://neko.local:8080/room", + streamToken: "tok-1", + }); + render(); + expect( + screen.getByRole("radio", { name: /streamed browser/i }).getAttribute("aria-checked"), + ).toBe("true"); + }); + + it("clicking Streamed posts a session and sets liveSession on a running response", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + session: { + id: "sess-1", + status: "running", + neko_url: "http://neko.local:8080/room", + stream_token: "tok-xyz", + }, + }), + } as Response); + + const tabId = activeTabId(); + render(); + fireEvent.click(screen.getByRole("radio", { name: /streamed browser/i })); + + await waitFor(() => { + const tab = useBrowserStore + .getState() + .getWindow(WIN_ID)! + .tabs.find((t) => t.id === tabId); + expect(tab?.liveSession).toEqual({ + nekoUrl: "http://neko.local:8080/room", + streamToken: "tok-xyz", + }); + }); + }); + + it("clicking Proxy clears an existing liveSession", () => { + const tabId = activeTabId(); + useBrowserStore.getState().setTabLiveSession(WIN_ID, tabId, { + nekoUrl: "http://neko.local:8080/room", + streamToken: "tok-1", + }); + render(); + fireEvent.click(screen.getByRole("radio", { name: /proxy browser/i })); + const tab = useBrowserStore + .getState() + .getWindow(WIN_ID)! + .tabs.find((t) => t.id === tabId); + expect(tab?.liveSession).toBeUndefined(); + }); + + it("shows a gate hint when the host has no capable node (409)", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ error: "no_capable_node" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("radio", { name: /streamed browser/i })); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch( + /streamed browser needs a more capable device/i, + ); + }); + }); +}); diff --git a/desktop/src/apps/BrowserApp/BrowserModeToggle.tsx b/desktop/src/apps/BrowserApp/BrowserModeToggle.tsx new file mode 100644 index 000000000..49a5e19de --- /dev/null +++ b/desktop/src/apps/BrowserApp/BrowserModeToggle.tsx @@ -0,0 +1,251 @@ +/** + * BrowserModeToggle - Proxy / Streamed segmented control for the active tab. + * + * Two browser engines back a tab: + * - Proxy: the URL-rewriting iframe browser (default). No liveSession. + * - Streamed: a real Chromium session streamed from the host over WebRTC + * (the existing Neko/liveSession path). The active tab carries a + * `liveSession` while streamed. + * + * This control surfaces that engine choice as a segmented toggle in the tab + * strip and drives the existing escalation lifecycle: + * - Streamed: POST /api/browser/sessions, poll until running, then + * store.setTabLiveSession(...) - identical to EscalateButton. + * - Proxy: store.setTabLiveSession(..., null) drops the stream and the tab + * falls back to the proxied iframe. + * + * A 409 with `no_capable_node` means the taOS has no device able to run a real + * browser; we show a small inline gate hint, matching EscalateButton. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Globe, MonitorPlay } from "lucide-react"; +import { useBrowserStore } from "@/stores/browser-store"; + +interface BrowserModeToggleProps { + windowId: string; +} + +interface BrowserSession { + id: string; + status: string; + neko_url: string | null; + stream_token?: string | null; +} + +type Phase = "idle" | "starting" | "polling" | "no_node"; + +const POLL_INTERVAL_MS = 1500; +const POLL_MAX_TRIES = 20; + +export function BrowserModeToggle({ windowId }: BrowserModeToggleProps) { + const win = useBrowserStore((s) => s.windows[windowId]); + const setTabLiveSession = useBrowserStore((s) => s.setTabLiveSession); + + const [phase, setPhase] = useState("idle"); + const pollRef = useRef | null>(null); + const triesRef = useRef(0); + const cancelledRef = useRef(false); + + const activeTab = win?.tabs.find((t) => t.id === win.activeTabId); + const isStreamed = !!activeTab?.liveSession; + + // Stop any in-flight poll if this instance unmounts (tab closed, strip + // re-rendered away). Without this the pending setTimeout keeps firing poll(), + // which fetches and calls setPhase on a dead component. + useEffect(() => { + return () => { + cancelledRef.current = true; + if (pollRef.current) { + clearTimeout(pollRef.current); + pollRef.current = null; + } + }; + }, []); + + const poll = useCallback( + (sessionId: string, tabId: string) => { + if (cancelledRef.current) return; + triesRef.current += 1; + if (triesRef.current > POLL_MAX_TRIES) { + setPhase("idle"); + return; + } + fetch(`/api/browser/sessions/${encodeURIComponent(sessionId)}`, { + credentials: "include", + }) + .then(async (resp) => { + if (cancelledRef.current) return; + if (!resp.ok) { + setPhase("idle"); + return; + } + const session: BrowserSession = await resp.json(); + if (session.status === "running" && session.neko_url && session.stream_token) { + setTabLiveSession(windowId, tabId, { + nekoUrl: session.neko_url, + streamToken: session.stream_token, + }); + setPhase("idle"); + } else { + pollRef.current = setTimeout(() => poll(sessionId, tabId), POLL_INTERVAL_MS); + } + }) + .catch(() => { + if (!cancelledRef.current) setPhase("idle"); + }); + }, + [setTabLiveSession, windowId], + ); + + const goStreamed = useCallback(async () => { + if (!activeTab || phase !== "idle") return; + const tabId = activeTab.id; + cancelledRef.current = false; + triesRef.current = 0; + setPhase("starting"); + + let resp: Response; + try { + resp = await fetch("/api/browser/sessions", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: activeTab.url }), + }); + } catch { + setPhase("idle"); + return; + } + + // The user may have clicked Proxy while the POST was in flight (goProxy sets + // cancelledRef + phase=idle). Honour that cancellation instead of forcing + // the tab back into a streamed session it no longer wants. + if (cancelledRef.current) return; + + if (resp.status === 409) { + let body: { error?: string } = {}; + try { + body = await resp.json(); + } catch { + /* ignore */ + } + setPhase(body.error === "no_capable_node" ? "no_node" : "idle"); + return; + } + + if (!resp.ok) { + setPhase("idle"); + return; + } + + let session: BrowserSession; + try { + const body = await resp.json(); + session = body.session ?? body; + } catch { + if (!cancelledRef.current) setPhase("idle"); + return; + } + + // Re-check after the JSON-parse await as well: a Proxy click or unmount + // during that await must still cancel before we commit the live session. + if (cancelledRef.current) return; + + if (session.status === "running" && session.neko_url && session.stream_token) { + setTabLiveSession(windowId, tabId, { + nekoUrl: session.neko_url, + streamToken: session.stream_token, + }); + setPhase("idle"); + return; + } + + setPhase("polling"); + poll(session.id, tabId); + }, [activeTab, phase, poll, setTabLiveSession, windowId]); + + const goProxy = useCallback(() => { + if (!activeTab) return; + cancelledRef.current = true; + if (pollRef.current) { + clearTimeout(pollRef.current); + pollRef.current = null; + } + setPhase("idle"); + if (activeTab.liveSession) { + setTabLiveSession(windowId, activeTab.id, null); + } + }, [activeTab, setTabLiveSession, windowId]); + + if (!activeTab) return null; + + const busy = phase === "starting" || phase === "polling"; + + const segBase = + "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10.5px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"; + + return ( +
+
+ + +
+ + {phase === "no_node" && ( +
+ A streamed browser needs a more capable device on your taOS. Add one to + enable this. + +
+ )} +
+ ); +} diff --git a/desktop/src/apps/BrowserApp/Chrome.tsx b/desktop/src/apps/BrowserApp/Chrome.tsx index e65915399..1c2c91422 100644 --- a/desktop/src/apps/BrowserApp/Chrome.tsx +++ b/desktop/src/apps/BrowserApp/Chrome.tsx @@ -1,25 +1,41 @@ /** - * BrowserApp v2 — Chrome. + * BrowserApp v2 - Chrome (toolbar). * - * Browser-specific nav row rendered INSIDE the window, ABOVE the tab strip. - * Contains back / forward / refresh buttons and the profile chip. + * The unified browser toolbar rendered INSIDE the window, BELOW the tab strip. + * Left to right: back / forward / reload / home nav buttons, the pill omnibox + * (AddressBar, fronted by a connection-security lock), an agent-presence pill + + * add-agent affordance, menu and settings buttons, and the profile chip whose + * dropdown lists the user's and agents' profiles. * * NOTE: The OS-level traffic lights (close / minimize / maximize) live in * `desktop/src/components/Window.tsx` — every window in taOS gets them * automatically. This component does NOT render its own traffic lights. + * + * The Proxy/Streamed engine toggle lives in the tab strip (BrowserModeToggle), + * not here. */ import { useState, useEffect, useRef } from "react"; -import { ArrowLeft, ArrowRight, RotateCw, Settings } from "lucide-react"; +import { + ArrowLeft, + ArrowRight, + RotateCw, + Home, + Lock, + ChevronDown, + Settings, +} from "lucide-react"; import { useBrowserStore } from "@/stores/browser-store"; import { useBrowserAgentStore } from "@/stores/browser-agent-store"; import { listProfiles, type Profile } from "@/lib/browser-profile-api"; +import { AddressBar } from "./AddressBar"; import { ProfileSwitcher } from "./ProfileSwitcher"; import { ProfileManager } from "./ProfileManager"; import { SettingsPanel } from "./SettingsPanel"; import { AgentPickerPopover } from "./AgentPickerPopover"; import { AgentPresencePill } from "./AgentPresencePill"; import { CoPilotBanner } from "./CoPilotBanner"; -import { EscalateButton } from "./EscalateButton"; + +import { HOME_URL } from "@/stores/browser-store"; interface ChromeProps { windowId: string; @@ -76,15 +92,21 @@ export function Chrome({ windowId }: ChromeProps) { const canGoBack = activeTab.historyIndex > 0; const canGoForward = activeTab.historyIndex < activeTab.history.length - 1; + // Connection security: the proxy serves over https. Treat an https URL as + // secure; about:blank / new-tab pages show no lock. + const isSecure = /^https:\/\//i.test(activeTab.url); + const hasUrl = !!activeTab.url && activeTab.url !== "about:blank"; const handleRefresh = () => { - // Re-navigate to the current URL — bumps the iframe to reload (TabRenderer - // listens for navigateTab in PR 4 Task 8). + // Re-navigate to the current URL to bump the iframe to reload. if (activeTab.url) { navigateTab(windowId, activeTab.id, activeTab.url); } }; + const navBtn = + "flex h-[34px] w-[34px] items-center justify-center rounded-[9px] text-shell-text-secondary transition-colors hover:bg-white/[0.06] hover:text-shell-text disabled:text-shell-text-tertiary disabled:hover:bg-transparent disabled:cursor-default"; + return (
{drivingAgentId && ( @@ -95,167 +117,184 @@ export function Chrome({ windowId }: ChromeProps) { agentId={drivingAgentId} /> )} -
- {/* Nav buttons */} - - - - - - - {/* Escalate to full browser (Neko session) */} -
- -
+ {/* Nav buttons */} + - {/* Spacer pushes the profile chip to the right */} -
+ - {/* Agent chip / picker */} -
- {activeTab.pinnedAgentIds.length > 0 && ( - - )} - {/* "+ agent" affordance — always visible (until at cap) so users can - add a 2nd/3rd/4th agent without remembering Cmd+Shift+A. */} - {activeTab.pinnedAgentIds.length < 4 && ( - - )} - {pickerOpen && ( - setPickerOpen(false)} - triggerRef={agentChipRef} - /> - )} -
+ - {/* Settings button */} -
- {settingsOpen && ( - setSettingsOpen(false)} /> - )} -
- {/* Profile chip — clicking opens the ProfileSwitcher dropdown */} -
- {(() => { - const activeProfile = profiles?.find((p) => p.profile_id === win.profileId); - const chipColor = activeProfile?.color ?? "#8b92a3"; - const chipName = activeProfile?.name ?? win.profileId; - return ( + {/* Omnibox: pill wrapper around the address bar with a security lock. */} +
+ {hasUrl && ( + + )} + +
+ + {/* Agent presence pill + "+ agent" affordance */} +
+ {activeTab.pinnedAgentIds.length > 0 && ( + + )} + {/* "+ agent" affordance: always visible (until at cap) so users can + add a 2nd/3rd/4th agent without remembering Cmd+Shift+A. */} + {activeTab.pinnedAgentIds.length < 4 && ( - ); - })()} - {switcherOpen && ( - setSwitcherOpen(false)} - onManage={() => { + )} + {pickerOpen && ( + setPickerOpen(false)} + triggerRef={agentChipRef} + /> + )} +
+ + {/* Settings button */} +
+ + {settingsOpen && ( + setSettingsOpen(false)} /> + )} +
+ + {/* Profile chip: clicking opens the ProfileSwitcher dropdown */} +
+ {(() => { + const activeProfile = profiles?.find((p) => p.profile_id === win.profileId); + const chipColor = activeProfile?.color ?? "#8b92a3"; + const chipName = activeProfile?.name ?? win.profileId; + const initial = (chipName?.[0] ?? "?").toUpperCase(); + return ( + + ); + })()} + {switcherOpen && ( + setSwitcherOpen(false)} + onManage={() => { + setSwitcherOpen(false); + setManagerOpen(true); + }} + /> + )} +
+ {managerOpen && ( + setManagerOpen(false)} /> )}
- {managerOpen && ( - setManagerOpen(false)} - /> - )} -
); } - diff --git a/desktop/src/apps/BrowserApp/ProfileSwitcher.tsx b/desktop/src/apps/BrowserApp/ProfileSwitcher.tsx index afd752417..c4dfd3fa2 100644 --- a/desktop/src/apps/BrowserApp/ProfileSwitcher.tsx +++ b/desktop/src/apps/BrowserApp/ProfileSwitcher.tsx @@ -64,19 +64,20 @@ export function ProfileSwitcher({ ref={ref} role="menu" aria-label="Switch profile" - className="absolute z-[60] min-w-[220px] rounded-md bg-shell-surface border border-shell-border shadow-lg py-1 text-xs" + className="absolute right-0 z-[60] mt-1.5 w-[262px] rounded-xl border border-shell-border-strong bg-shell-bg-glass p-1.5 text-xs shadow-window backdrop-blur-xl" > -
+
Profiles
{profiles === null ? ( -
Loading…
+
Loading…
) : profiles.length === 0 ? ( -
No profiles
+
No profiles
) : ( profiles.map((p) => { const isActive = p.profile_id === win.profileId; + const initial = (p.name?.[0] ?? "?").toUpperCase(); return ( ); }) )} -
+
{creating ? ( -
+
) : ( @@ -129,9 +139,9 @@ export function ProfileSwitcher({ type="button" role="menuitem" onClick={() => setCreating(true)} - className="w-full text-left px-2 py-1 hover:bg-shell-hover flex items-center gap-1.5" + className="flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 text-left font-semibold text-shell-text-secondary transition-colors hover:bg-white/[0.06] hover:text-shell-text" > - + New profile )} @@ -144,10 +154,10 @@ export function ProfileSwitcher({ onManage(); onClose(); }} - className="w-full text-left px-2 py-1 hover:bg-shell-hover flex items-center gap-1.5" + className="flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 text-left font-semibold text-shell-text-secondary transition-colors hover:bg-white/[0.06] hover:text-shell-text" > - - Manage profiles… + + Manage profiles )}
diff --git a/desktop/src/apps/BrowserApp/SettingsPanel.tsx b/desktop/src/apps/BrowserApp/SettingsPanel.tsx index 97d48de9c..653e2af0c 100644 --- a/desktop/src/apps/BrowserApp/SettingsPanel.tsx +++ b/desktop/src/apps/BrowserApp/SettingsPanel.tsx @@ -92,7 +92,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { ref={ref} role="dialog" aria-label="Browser settings" - className="absolute right-0 top-full mt-1 z-[60] w-72 rounded-lg border border-shell-border-subtle bg-shell-surface shadow-xl p-4 flex flex-col gap-4" + className="absolute right-0 top-full mt-1.5 z-[60] w-72 rounded-xl border border-shell-border-strong bg-shell-bg-glass shadow-window backdrop-blur-xl p-4 flex flex-col gap-4" > {/* Header */}
@@ -101,7 +101,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { type="button" aria-label="Close settings" onClick={onClose} - className="p-1 rounded hover:bg-shell-hover text-shell-text-secondary" + className="p-1 rounded hover:bg-white/[0.06] text-shell-text-secondary" > @@ -145,7 +145,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { max={50} value={maxLiveTabs} onChange={(e) => setMaxLiveTabs(Number(e.target.value))} - className="bg-shell-bg-deep text-shell-text text-xs px-2 py-1 rounded border border-shell-border-subtle focus:border-accent focus:outline-none w-20" + className="bg-shell-bg-deep text-shell-text text-xs px-2 py-1 rounded border border-shell-border focus:border-accent focus:outline-none w-20" />
@@ -158,7 +158,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { id="search-engine-select" value={searchEngine} onChange={(e) => setSearchEngine(e.target.value)} - className="bg-shell-bg-deep text-shell-text text-xs px-2 py-1 rounded border border-shell-border-subtle focus:border-accent focus:outline-none" + className="bg-shell-bg-deep text-shell-text text-xs px-2 py-1 rounded border border-shell-border focus:border-accent focus:outline-none" > {(Object.keys(SEARCH_ENGINES) as SearchEngine[]).map((engine) => (
{/* Agent capabilities */} -
+
+ {/* Proxy / Streamed segmented toggle, pushed to the right edge. */} +
+ +
+ {contextMenu && ( { for (const aid of tab.pinnedAgentIds ?? []) { if (s.drivingState[`${windowId}:${tab.id}:${aid}`] === "driving") return true; @@ -108,10 +116,12 @@ function TabItem({ windowId, tab, isActive, onActivate, onClose, onContextMenu } return false; }); - // Width per Q8 layout A. Pinned: 32px (favicon-only). Inactive: 140px. - // Active: 360px (will host the embedded AddressBar in Task 7). + const hasAgent = (tab.pinnedAgentIds?.length ?? 0) > 0; + + // Width per Q8 layout A. Pinned: 38px (favicon-only). Inactive: 140px. + // Active: 360px. const widthClass = tab.pinned - ? "w-[32px]" + ? "w-[38px] justify-center" : isActive ? "w-[360px]" : "w-[140px]"; @@ -126,23 +136,29 @@ function TabItem({ windowId, tab, isActive, onActivate, onClose, onContextMenu } onContextMenu={onContextMenu} className={[ widthClass, - "group", - "h-[28px] px-2 flex items-center gap-1.5 rounded-t cursor-pointer", - "border-t border-l border-r border-shell-border-subtle", + "group relative", + "h-[31px] px-2.5 flex items-center gap-2 rounded-t-[9px] cursor-pointer", + "border border-b-0 transition-colors", isActive - ? "bg-shell-surface text-shell-text" - : "bg-shell-bg-deep text-shell-text-secondary hover:bg-shell-hover", - tabDriving ? "border-b-2 border-green-500" : "", + ? "bg-shell-surface text-shell-text border-shell-border-strong" + : "border-transparent text-shell-text-secondary hover:bg-white/[0.06] hover:text-shell-text", + // Agent-owned session tab: slate accent line on the bottom edge, + // brighter while the agent is actively driving. + tabDriving + ? "shadow-[inset_0_-2px_0_var(--color-accent-strong)]" + : hasAgent + ? "shadow-[inset_0_-2px_0_var(--color-accent-line)]" + : "", ].join(" ")} > {/* Drag handle — Task 11 wires drag events on this child */}
-
@@ -154,9 +170,12 @@ function TabItem({ windowId, tab, isActive, onActivate, onClose, onContextMenu } e.stopPropagation(); onClose(); }} - className="opacity-0 group-hover:opacity-100 hover:opacity-100 hover:bg-shell-hover rounded p-0.5 shrink-0" + className={[ + "flex h-[17px] w-[17px] items-center justify-center rounded-[5px] shrink-0 text-shell-text-tertiary transition-opacity hover:bg-white/[0.08] hover:text-shell-text", + isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100", + ].join(" ")} > - + )}
diff --git a/desktop/src/apps/FilesApp.tsx b/desktop/src/apps/FilesApp.tsx index 1dea0a3e5..77c3a57b5 100644 --- a/desktop/src/apps/FilesApp.tsx +++ b/desktop/src/apps/FilesApp.tsx @@ -445,10 +445,13 @@ function FileRow({ export function FilesApp({ windowId: _windowId, rootPath, -}: { windowId: string; rootPath?: string }) { + path: initialPath, +}: { windowId: string; rootPath?: string; path?: string }) { const isMobile = useIsMobile(); - const [currentPath, setCurrentPath] = useState(""); + // `path` opens straight into a sub-directory (e.g. double-clicking a Desktop + // folder); the load effect fetches currentPath on mount. + const [currentPath, setCurrentPath] = useState(initialPath ?? ""); const [location, setLocation] = useState<"workspace" | string>(() => rootPath ?? "workspace"); const [files, setFiles] = useState([]); const [sharedFolders, setSharedFolders] = useState([]); @@ -462,8 +465,11 @@ export function FilesApp({ const [uploading, setUploading] = useState(false); const [sharedExpanded, setSharedExpanded] = useState(true); const [deleteConfirm, setDeleteConfirm] = useState(null); - // null = showing sidebar (list pane); non-null = showing file browser (detail pane) - const [selectedLocation, setSelectedLocation] = useState(rootPath ?? null); + // null = showing sidebar (list pane); non-null = showing file browser (detail pane). + // An initial path (or rootPath) opens straight into the browser pane. + const [selectedLocation, setSelectedLocation] = useState( + rootPath ?? (initialPath != null ? "workspace" : null), + ); // Clipboard for cut/copy/paste. Cross-location paste is out of scope for v1. const [clipboard, setClipboard] = useState< diff --git a/desktop/src/apps/MailApp/MailApp.module.css b/desktop/src/apps/MailApp/MailApp.module.css new file mode 100644 index 000000000..97b80d668 --- /dev/null +++ b/desktop/src/apps/MailApp/MailApp.module.css @@ -0,0 +1,1061 @@ +/* ================================================================== + Mail app, Store / Images / GitHub visual bar + ------------------------------------------------------------------ + All colour comes from the live semantic theme tokens + (--color-shell-*, --color-accent). The active theme overwrites those + tokens per scheme at runtime (see theme-store.applyThemeConfig), so + dark and light both resolve correctly with no hardcoded hex here. + Soft accent / status tints are mixed from the tokens with color-mix. + ================================================================== */ + +.root { + /* local aliases so the rules below read like the approved mock */ + --ml-bg: var(--color-shell-bg); + --ml-bg-deep: var(--color-shell-bg-deep); + --ml-surface: var(--color-shell-surface); + --ml-surface-hover: var(--color-shell-surface-hover); + --ml-surface-active: var(--color-shell-surface-active); + --ml-border: var(--color-shell-border); + --ml-border-strong: var(--color-shell-border-strong); + --ml-text: var(--color-shell-text); + --ml-text-2: var(--color-shell-text-secondary); + --ml-text-3: var(--color-shell-text-tertiary); + --ml-accent: var(--color-accent); + --ml-accent-strong: var(--color-accent-strong); + --ml-accent-glow: var(--color-accent-glow); + --ml-accent-soft: var(--color-accent-soft); + --ml-accent-line: var(--color-accent-line); + --ml-unread: var(--color-unread); + --ml-topbar: var(--color-topbar-bg); + --ml-flag: color-mix(in srgb, #ffb340 82%, var(--color-shell-text)); + --ml-on-accent: #15161a; + --ml-shadow: var(--shadow-card-hover); + --ml-radius-lg: 16px; + --ml-radius-md: 13px; + --ml-radius-sm: 10px; + + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--ml-bg); + color: var(--ml-text); + font-size: 13px; +} + +/* app header bar (Store/Images-style top strip) */ +.appbar { + flex: none; + height: 46px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px; + border-bottom: 1px solid var(--ml-border); +} +.appbar h1 { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.01em; +} +.appbarRight { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; +} +.composebtn { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 700; + color: var(--ml-on-accent); + background: linear-gradient(135deg, var(--ml-accent-strong), var(--ml-accent)); + border: none; + border-radius: 999px; + padding: 6px 14px; + cursor: pointer; + box-shadow: 0 6px 16px -8px var(--ml-accent-glow); + transition: all 0.15s; +} +.composebtn:hover { + transform: translateY(-1px); + filter: brightness(1.05); +} + +.body { + flex: 1; + display: flex; + min-height: 0; +} + +/* ============ LEFT SIDEBAR ============ */ +.sidebar { + width: 240px; + flex: none; + border-right: 1px solid var(--ml-border); + display: flex; + flex-direction: column; + background: var(--ml-bg-deep); +} + +/* account switcher */ +.acct { + flex: none; + border-bottom: 1px solid var(--ml-border); + padding: 10px; +} +.acctCur { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 9px; + border-radius: var(--ml-radius-md); + cursor: pointer; + transition: all 0.15s; + border: 1px solid transparent; + width: 100%; + background: none; + text-align: left; + color: inherit; + font: inherit; +} +.acctCur:hover { + background: var(--ml-surface-hover); + border-color: var(--ml-border); +} +.av { + border-radius: 50%; + flex: none; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--ml-on-accent); + letter-spacing: -0.02em; +} +.avUser { + background: linear-gradient(135deg, var(--ml-accent), var(--ml-accent-strong)); +} +.avAgent { + background: color-mix(in srgb, var(--ml-accent) 30%, var(--ml-bg-deep)); + color: var(--ml-text); +} +.acctCur .av { + width: 34px; + height: 34px; + font-size: 14px; +} +.acctBody { + min-width: 0; + flex: 1; +} +.acctName { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.acctAddr { + font-size: 11px; + color: var(--ml-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} +.acctChev { + color: var(--ml-text-3); + transition: transform 0.2s; + display: flex; +} +.open .acctChev { + transform: rotate(180deg); +} + +/* account dropdown */ +.acctMenu { + margin-top: 6px; + padding: 5px; + background: var(--ml-surface); + border: 1px solid var(--ml-border); + border-radius: var(--ml-radius-md); +} +.acctGrp { + font-size: 10px; + font-weight: 700; + color: var(--ml-text-3); + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 8px 9px 5px; +} +.acctRow { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 9px; + border-radius: var(--ml-radius-sm); + cursor: pointer; + transition: all 0.15s; + width: 100%; + background: none; + border: none; + text-align: left; + color: inherit; + font: inherit; +} +.acctRow:hover { + background: var(--ml-surface-hover); +} +.acctRow.sel { + background: var(--ml-surface-active); +} +.acctRow .av { + width: 26px; + height: 26px; + font-size: 11px; +} +.acctRowBody { + min-width: 0; + flex: 1; +} +.acctRowName { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} +.acctRowAddr { + font-size: 10.5px; + color: var(--ml-text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} +.pillAgent { + font-size: 9px; + font-weight: 700; + color: var(--ml-accent-strong); + background: var(--ml-accent-soft); + border: 1px solid var(--ml-accent-line); + border-radius: 999px; + padding: 1px 6px; + letter-spacing: 0.03em; + flex: none; +} +.acctCheck { + margin-left: auto; + color: var(--ml-accent-strong); + flex: none; + display: flex; +} +/* Phase-2 affordance: agent accounts group is disabled / coming soon */ +.acctGroupDisabled .acctRow { + cursor: default; + opacity: 0.55; +} +.acctGroupDisabled .acctRow:hover { + background: none; +} +.comingSoon { + margin-left: auto; + font-size: 9px; + font-weight: 700; + color: var(--ml-text-3); + text-transform: uppercase; + letter-spacing: 0.04em; + flex: none; +} +.acctAdd { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px; + margin-top: 3px; + border-top: 1px solid var(--ml-border); + font-size: 11.5px; + font-weight: 600; + color: var(--ml-text-2); + cursor: pointer; + border-radius: var(--ml-radius-sm); + width: 100%; + background: none; + border-left: none; + border-right: none; + border-bottom: none; + text-align: left; +} +.acctAdd:hover { + color: var(--ml-text); +} + +/* folder list */ +.folders { + flex: 1; + overflow: auto; + padding: 8px 8px 4px; +} +.fgrpLbl { + font-size: 10px; + font-weight: 700; + color: var(--ml-text-3); + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 10px 9px 6px; +} +.fold { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: var(--ml-radius-md); + cursor: pointer; + transition: all 0.15s; + margin-bottom: 1px; + color: var(--ml-text-2); + width: 100%; + background: none; + border: none; + text-align: left; + font: inherit; +} +.fold:hover { + background: var(--ml-surface-hover); + color: var(--ml-text); +} +.fold.on { + background: var(--ml-surface-active); + color: var(--ml-text); +} +.fold.on .foldIco { + color: var(--ml-accent-strong); +} +.foldIco { + flex: none; + display: flex; + color: var(--ml-text-3); +} +.foldName { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.foldCount { + font-size: 11px; + font-weight: 700; + color: var(--ml-text-2); + font-variant-numeric: tabular-nums; +} + +/* ============ MIDDLE: MESSAGE LIST ============ */ +.list { + width: 360px; + flex: none; + border-right: 1px solid var(--ml-border); + display: flex; + flex-direction: column; + background: var(--ml-bg); + min-width: 0; +} +.listHead { + flex: none; + padding: 12px 14px 0; + border-bottom: 1px solid var(--ml-border); +} +.listTitle { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 11px; +} +.listTitle h2 { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.02em; +} +.listTitle .sub { + font-size: 11.5px; + color: var(--ml-text-3); + font-variant-numeric: tabular-nums; +} +.search { + display: flex; + align-items: center; + gap: 9px; + background: var(--ml-surface); + border: 1px solid var(--ml-border); + border-radius: var(--ml-radius-md); + padding: 9px 12px; + margin-bottom: 11px; +} +.search svg { + color: var(--ml-text-3); + flex: none; +} +.search input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--ml-text); + font-size: 13px; + font-family: inherit; +} +.search input::placeholder { + color: var(--ml-text-3); +} +.tabs { + display: flex; + gap: 2px; + padding: 0 0 1px; +} +.tab { + position: relative; + font-size: 12.5px; + font-weight: 600; + color: var(--ml-text-3); + padding: 8px 12px 11px; + cursor: pointer; + white-space: nowrap; + background: none; + border: none; + transition: color 0.15s; + font-family: inherit; +} +.tab:hover { + color: var(--ml-text-2); +} +.tab.on { + color: var(--ml-text); +} +.tab.on::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 2px; + border-radius: 2px; + background: var(--ml-accent-strong); +} +.tab .n { + font-size: 10.5px; + color: var(--ml-text-3); + margin-left: 5px; + font-variant-numeric: tabular-nums; +} + +.rows { + flex: 1; + overflow: auto; +} +.row { + display: flex; + gap: 11px; + padding: 12px 14px; + cursor: pointer; + position: relative; + border-bottom: 1px solid var(--ml-border); + transition: background 0.12s; + width: 100%; + background: none; + border-left: none; + border-right: none; + border-top: none; + text-align: left; + font: inherit; + color: inherit; +} +.row:hover { + background: var(--ml-surface-hover); +} +.row.on { + background: var(--ml-surface-active); +} +.row.on::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--ml-accent-strong); +} +.row .av { + width: 38px; + height: 38px; + font-size: 14px; + flex: none; + margin-top: 2px; +} +.rowMain { + min-width: 0; + flex: 1; +} +.rowTop { + display: flex; + align-items: baseline; + gap: 8px; +} +.rowFrom { + font-size: 13.5px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ml-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} +.row.unread .rowFrom { + font-weight: 700; +} +.rowTime { + font-size: 11px; + color: var(--ml-text-3); + flex: none; + font-variant-numeric: tabular-nums; +} +.rowSubj { + font-size: 12.5px; + color: var(--ml-text); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.row.unread .rowSubj { + font-weight: 600; +} +.rowSnip { + font-size: 12px; + color: var(--ml-text-3); + margin-top: 2px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} +.rowMeta { + display: flex; + align-items: center; + gap: 7px; + margin-top: 6px; +} +.udot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ml-unread); + flex: none; +} +.mini { + color: var(--ml-text-3); + display: flex; + align-items: center; +} +.mini.flag { + color: var(--ml-flag); +} + +/* ============ RIGHT: READING PANE ============ */ +.read { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--ml-bg); +} +.readToolbar { + flex: none; + height: 48px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 16px; + border-bottom: 1px solid var(--ml-border); +} +.tbtn { + display: flex; + align-items: center; + gap: 7px; + height: 32px; + padding: 0 11px; + border-radius: 9px; + font-size: 12.5px; + font-weight: 600; + color: var(--ml-text-2); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; + font-family: inherit; +} +.tbtn:hover { + background: var(--ml-surface-hover); + color: var(--ml-text); + border-color: var(--ml-border); +} +.tbtn.pri { + color: var(--ml-text); + background: var(--ml-surface); + border-color: var(--ml-border); +} +.tbSep { + width: 1px; + height: 20px; + background: var(--ml-border); + margin: 0 4px; +} +.iconbtn { + width: 30px; + height: 30px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ml-text-2); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; +} +.iconbtn:hover { + background: var(--ml-surface-hover); + color: var(--ml-text); + border-color: var(--ml-border); +} +.readToolbar .right { + margin-left: auto; + display: flex; + gap: 2px; + position: relative; +} + +.readScroll { + flex: 1; + overflow: auto; + padding: 24px 30px; +} +.readSubj { + font-size: 21px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.25; +} +.readFrom { + display: flex; + align-items: center; + gap: 12px; + margin-top: 18px; + padding-bottom: 18px; + border-bottom: 1px solid var(--ml-border); +} +.readFrom .av { + width: 42px; + height: 42px; + font-size: 16px; + flex: none; +} +.rfBody { + min-width: 0; + flex: 1; +} +.rfName { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: 8px; +} +.rfAddr { + font-size: 12px; + color: var(--ml-text-3); + margin-top: 1px; +} +.rfTo { + font-size: 11.5px; + color: var(--ml-text-3); + margin-top: 3px; +} +.rfTo b { + color: var(--ml-text-2); + font-weight: 600; +} +.readDate { + font-size: 11.5px; + color: var(--ml-text-3); + flex: none; + text-align: right; + line-height: 1.5; + font-variant-numeric: tabular-nums; +} + +.readBody { + padding: 22px 0 8px; + font-size: 14px; + line-height: 1.65; + color: var(--ml-text); + white-space: pre-wrap; + word-break: break-word; +} + +.attach { + display: flex; + gap: 11px; + flex-wrap: wrap; + margin-top: 6px; + padding-top: 18px; + border-top: 1px solid var(--ml-border); +} +.attLbl { + flex-basis: 100%; + font-size: 11px; + font-weight: 700; + color: var(--ml-text-3); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 2px; +} +.att { + display: flex; + align-items: center; + gap: 10px; + background: var(--ml-surface); + border: 1px solid var(--ml-border); + border-radius: var(--ml-radius-md); + padding: 9px 13px 9px 10px; +} +.attIco { + width: 34px; + height: 34px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + background: color-mix(in srgb, var(--ml-accent) 30%, var(--ml-bg-deep)); + color: var(--ml-text); +} +.attName { + font-size: 12.5px; + font-weight: 600; +} +.attSize { + font-size: 11px; + color: var(--ml-text-3); + margin-top: 1px; + font-variant-numeric: tabular-nums; +} + +/* empty + loading states */ +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--ml-text-3); + text-align: center; + padding: 32px; +} +.empty p { + font-size: 13px; + max-width: 280px; +} +.error { + margin: 16px; + padding: 12px 14px; + border-radius: var(--ml-radius-md); + background: color-mix(in srgb, #e5484d 14%, transparent); + border: 1px solid color-mix(in srgb, #e5484d 32%, transparent); + color: color-mix(in srgb, #e5484d 72%, var(--ml-text)); + font-size: 12.5px; +} +.skel { + height: 64px; + margin: 8px 14px; + border-radius: var(--ml-radius-md); + background: var(--ml-surface); +} + +/* share / send-to stub menu */ +.menu { + position: absolute; + right: 0; + top: 36px; + min-width: 180px; + padding: 5px; + background: var(--ml-bg); + border: 1px solid var(--ml-border-strong); + border-radius: var(--ml-radius-md); + box-shadow: var(--ml-shadow); + z-index: 20; +} +.menuItem { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 8px 10px; + border-radius: var(--ml-radius-sm); + background: none; + border: none; + color: var(--ml-text-2); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.menuItem:hover { + background: var(--ml-surface-hover); + color: var(--ml-text); +} +.menuNote { + padding: 6px 10px 4px; + font-size: 10.5px; + color: var(--ml-text-3); +} + +/* ============ COMPOSE OVERLAY ============ */ +.scrim { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 0 22px 22px; + z-index: 10; +} +.compose { + width: 620px; + max-width: 100%; + height: 560px; + max-height: 100%; + background: var(--ml-bg); + border: 1px solid var(--ml-border-strong); + border-radius: var(--ml-radius-lg); + box-shadow: var(--ml-shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} +.cmpHead { + flex: none; + height: 46px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px 0 18px; + background: var(--ml-topbar); + border-bottom: 1px solid var(--ml-border); +} +.cmpHead h3 { + font-size: 13.5px; + font-weight: 700; + letter-spacing: -0.01em; +} +.cmpHead .right { + margin-left: auto; + display: flex; + gap: 2px; +} +.cmpFields { + flex: none; + display: flex; + flex-direction: column; +} +.cmpField { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 18px; + border-bottom: 1px solid var(--ml-border); +} +.cmpField .k { + font-size: 12px; + font-weight: 600; + color: var(--ml-text-3); + width: 56px; + flex: none; +} +.cmpField input { + flex: 1; + min-width: 0; + background: none; + border: none; + outline: none; + color: var(--ml-text); + font-size: 13px; + font-family: inherit; +} +.cmpField input::placeholder { + color: var(--ml-text-3); +} +.fromStatic { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + min-width: 0; +} +.fromStatic .av { + width: 24px; + height: 24px; + font-size: 10.5px; + flex: none; +} +.faName { + font-size: 12.5px; + font-weight: 600; +} +.faAddr { + font-size: 11px; + color: var(--ml-text-3); +} +.sendasHint { + font-size: 10.5px; + color: var(--ml-text-3); + padding: 8px 18px; + display: flex; + align-items: center; + gap: 7px; + background: var(--ml-accent-soft); + border-bottom: 1px solid var(--ml-border); +} +.sendasHint svg { + color: var(--ml-accent-strong); + flex: none; +} +.cmpBody { + flex: 1; + padding: 16px 18px; + font-size: 13.5px; + line-height: 1.6; + color: var(--ml-text); + background: none; + border: none; + outline: none; + resize: none; + font-family: inherit; +} +.cmpBody::placeholder { + color: var(--ml-text-3); +} +.cmpBar { + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 13px 18px; + border-top: 1px solid var(--ml-border); + background: var(--ml-bg-deep); +} +.sendBtn { + display: flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 18px; + border-radius: var(--ml-radius-md); + border: none; + font-size: 13px; + font-weight: 700; + color: var(--ml-on-accent); + background: linear-gradient(135deg, var(--ml-accent-strong), var(--ml-accent)); + cursor: pointer; + box-shadow: 0 8px 20px -8px var(--ml-accent-glow); + transition: all 0.15s; + font-family: inherit; +} +.sendBtn:hover:not(:disabled) { + transform: translateY(-1px); + filter: brightness(1.05); +} +.sendBtn:disabled { + opacity: 0.55; + cursor: default; +} +.cmpTool { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ml-text-2); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s; +} +.cmpTool:hover { + background: var(--ml-surface-hover); + color: var(--ml-text); + border-color: var(--ml-border); +} +.cmpBar .spacer { + flex: 1; +} +.cmpTool.discard:hover { + color: #ff6b63; + border-color: rgba(255, 107, 99, 0.25); +} + +/* ============ MOBILE ============ */ +.mobileBack { + display: none; +} +@media (max-width: 767px) { + .sidebar { + display: none; + } + .list { + width: 100%; + } + .read { + display: none; + } + .root.mobileReading .list { + display: none; + } + .root.mobileReading .read { + display: flex; + } + .mobileBack { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: var(--ml-text-2); + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + } + .compose { + width: 100%; + height: 100%; + } + .scrim { + padding: 0; + } +} diff --git a/desktop/src/apps/MailApp/MailApp.test.tsx b/desktop/src/apps/MailApp/MailApp.test.tsx new file mode 100644 index 000000000..12f6dd312 --- /dev/null +++ b/desktop/src/apps/MailApp/MailApp.test.tsx @@ -0,0 +1,131 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MailApp } from "./index"; +import * as mailApi from "@/lib/mail"; +import * as useIsMobileModule from "@/hooks/use-is-mobile"; + +vi.mock("@/lib/mail"); +vi.mock("@/hooks/use-is-mobile"); + +const account: mailApi.MailAccount = { + id: "acct-1", + display_name: "Jay Lawrence", + email_address: "jay@taos.my", + imap_host: "imap.taos.my", + imap_port: 993, + imap_security: "ssl", + smtp_host: "smtp.taos.my", + smtp_port: 587, + smtp_security: "starttls", + username: "jay@taos.my", + created_at: 0, + updated_at: 0, +}; + +const envelopes: mailApi.MailEnvelope[] = [ + { + uid: "1", + from_name: "Dhaval Patel", + from_addr: "dhaval@example.com", + to: "jay@taos.my", + subject: "AssetOpsBench integration", + date: "Mon, 15 Jun 2026 09:24:00 +0000", + snippet: "Thanks for the quick turnaround on the connector.", + unread: true, + flagged: false, + has_attachment: true, + }, + { + uid: "2", + from_name: "Coolify", + from_addr: "noreply@coolify.io", + to: "jay@taos.my", + subject: "Deployment succeeded", + date: "Mon, 14 Jun 2026 09:24:00 +0000", + snippet: "Build finished in 42s.", + unread: false, + flagged: true, + has_attachment: false, + }, +]; + +describe("MailApp", () => { + beforeEach(() => { + vi.mocked(useIsMobileModule.useIsMobile).mockReturnValue(false); + vi.mocked(mailApi.fetchAccounts).mockResolvedValue([account]); + vi.mocked(mailApi.fetchFolders).mockResolvedValue(["INBOX", "Sent"]); + vi.mocked(mailApi.fetchMessages).mockResolvedValue(envelopes); + vi.mocked(mailApi.fetchMessage).mockResolvedValue({ + uid: "1", + from_name: "Dhaval Patel", + from_addr: "dhaval@example.com", + to: "jay@taos.my", + cc: "", + subject: "AssetOpsBench integration", + date: "Mon, 15 Jun 2026 09:24:00 +0000", + body_text: "Benchmark harness runs clean.", + body_html: "", + attachments: [{ filename: "notes.pdf", content_type: "application/pdf", size: 248000 }], + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the message list from the backend", async () => { + render(); + expect(await screen.findByText("AssetOpsBench integration")).toBeInTheDocument(); + expect(screen.getByText("Coolify")).toBeInTheDocument(); + }); + + it("filters to unread when the Unread tab is selected", async () => { + render(); + await screen.findByText("AssetOpsBench integration"); + fireEvent.click(screen.getByRole("tab", { name: /Unread/ })); + expect(screen.getByText("AssetOpsBench integration")).toBeInTheDocument(); + expect(screen.queryByText("Deployment succeeded")).not.toBeInTheDocument(); + }); + + it("opens a message into the reading pane", async () => { + render(); + fireEvent.click(await screen.findByText("AssetOpsBench integration")); + expect(await screen.findByText("Benchmark harness runs clean.")).toBeInTheDocument(); + expect(screen.getByText("notes.pdf")).toBeInTheDocument(); + }); + + it("shows the add-account form when there are no accounts", async () => { + vi.mocked(mailApi.fetchAccounts).mockResolvedValue([]); + render(); + expect(await screen.findByText("Add a mail account")).toBeInTheDocument(); + }); + + it("exposes a share / send-to entry point in the reading toolbar", async () => { + render(); + fireEvent.click(await screen.findByText("AssetOpsBench integration")); + await screen.findByText("Benchmark harness runs clean."); + const shareBtn = screen.getByTitle("Share / Send to"); + fireEvent.click(shareBtn); + expect(await screen.findByText("Send to a person or agent")).toBeInTheDocument(); + }); + + it("sends a composed message via the backend", async () => { + vi.mocked(mailApi.sendMessage).mockResolvedValue(); + render(); + await screen.findByText("AssetOpsBench integration"); + fireEvent.click(screen.getByLabelText("Compose new message")); + fireEvent.change(await screen.findByLabelText("To"), { + target: { value: "someone@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Subject"), { target: { value: "Hi" } }); + fireEvent.click(screen.getByText("Send")); + await waitFor(() => + expect(mailApi.sendMessage).toHaveBeenCalledWith("acct-1", { + to: "someone@example.com", + subject: "Hi", + body: "", + cc: "", + }), + ); + }); +}); diff --git a/desktop/src/apps/MailApp/index.tsx b/desktop/src/apps/MailApp/index.tsx new file mode 100644 index 000000000..0f5092010 --- /dev/null +++ b/desktop/src/apps/MailApp/index.tsx @@ -0,0 +1,994 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Archive, + ChevronDown, + CornerUpLeft, + CornerUpRight, + FileText, + Forward, + Inbox, + Mail, + MoreHorizontal, + Paperclip, + Plus, + Search, + Send, + Share2, + Star, + Trash2, + X, +} from "lucide-react"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { + addAccount, + deleteAccount, + fetchAccounts, + fetchFolders, + fetchMessage, + fetchMessages, + sendMessage, + type MailAccount, + type MailDetail, + type MailEnvelope, + type NewAccount, +} from "@/lib/mail"; +import styles from "./MailApp.module.css"; + +/* ------------------------------------------------------------------ */ +/* Constants + helpers */ +/* ------------------------------------------------------------------ */ + +type Filter = "all" | "unread" | "flagged"; + +// Canonical folders shown for every account. The IMAP server's own folder list +// (from fetchFolders) is also surfaced, but these five are always present and +// map to the common special-use mailboxes. +const CANONICAL_FOLDERS: { id: string; label: string }[] = [ + { id: "INBOX", label: "Inbox" }, + { id: "Sent", label: "Sent" }, + { id: "Drafts", label: "Drafts" }, + { id: "Archive", label: "Archive" }, + { id: "Trash", label: "Trash" }, +]; + +const FOLDER_ICON: Record = { + INBOX: Inbox, + Sent: Send, + Drafts: FileText, + Archive: Archive, + Trash: Trash2, +}; + +function initials(name: string, addr: string): string { + const src = (name || addr || "?").trim(); + const parts = src.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); + } + return src.slice(0, 2).toUpperCase(); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1048576).toFixed(1)} MB`; +} + +function formatTime(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) { + return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + } + const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000); + if (diffDays === 1) return "Yesterday"; + if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" }); + return d.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +/* ------------------------------------------------------------------ */ +/* Main component */ +/* ------------------------------------------------------------------ */ + +export function MailApp({ windowId: _windowId }: { windowId: string }) { + const isMobile = useIsMobile(); + + const [accounts, setAccounts] = useState([]); + const [accountsLoaded, setAccountsLoaded] = useState(false); + const [activeAccountId, setActiveAccountId] = useState(null); + const [acctMenuOpen, setAcctMenuOpen] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + const [serverFolders, setServerFolders] = useState([]); + const [activeFolder, setActiveFolder] = useState("INBOX"); + + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [listError, setListError] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + + const [selectedUid, setSelectedUid] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const [composeOpen, setComposeOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [mobileReading, setMobileReading] = useState(false); + + const activeAccount = useMemo( + () => accounts.find((a) => a.id === activeAccountId) ?? null, + [accounts, activeAccountId], + ); + + /* ---- load accounts ---- */ + const loadAccounts = useCallback(async () => { + try { + const list = await fetchAccounts(); + setAccounts(list); + setActiveAccountId((prev) => prev ?? (list[0]?.id ?? null)); + if (list.length === 0) setShowAddForm(true); + } catch { + setAccounts([]); + } finally { + setAccountsLoaded(true); + } + }, []); + + useEffect(() => { + void loadAccounts(); + }, [loadAccounts]); + + /* ---- load folders + messages for the active account/folder ---- */ + useEffect(() => { + if (!activeAccountId) return; + let cancelled = false; + void fetchFolders(activeAccountId) + .then((f) => { + if (!cancelled) setServerFolders(f); + }) + .catch(() => { + if (!cancelled) setServerFolders([]); + }); + return () => { + cancelled = true; + }; + }, [activeAccountId]); + + const loadMessages = useCallback(async () => { + if (!activeAccountId) return; + setMessagesLoading(true); + setListError(null); + try { + const list = await fetchMessages(activeAccountId, activeFolder); + setMessages(list); + } catch (e) { + setMessages([]); + setListError(e instanceof Error ? e.message : "Failed to load mail"); + } finally { + setMessagesLoading(false); + } + }, [activeAccountId, activeFolder]); + + useEffect(() => { + setSelectedUid(null); + setDetail(null); + void loadMessages(); + }, [loadMessages]); + + /* ---- load a single message ---- */ + const openMessage = useCallback( + async (uid: string) => { + if (!activeAccountId) return; + setSelectedUid(uid); + setShareOpen(false); + if (isMobile) setMobileReading(true); + setDetailLoading(true); + setDetail(null); + try { + const d = await fetchMessage(activeAccountId, uid, activeFolder); + setDetail(d); + } catch { + setDetail(null); + } finally { + setDetailLoading(false); + } + }, + [activeAccountId, activeFolder, isMobile], + ); + + /* ---- filtered list ---- */ + const visibleMessages = useMemo(() => { + const q = search.trim().toLowerCase(); + return messages.filter((m) => { + if (filter === "unread" && !m.unread) return false; + if (filter === "flagged" && !m.flagged) return false; + if (!q) return true; + return ( + m.from_name.toLowerCase().includes(q) || + m.from_addr.toLowerCase().includes(q) || + m.subject.toLowerCase().includes(q) || + m.snippet.toLowerCase().includes(q) + ); + }); + }, [messages, search, filter]); + + const unreadCount = messages.filter((m) => m.unread).length; + const flaggedCount = messages.filter((m) => m.flagged).length; + const activeFolderLabel = + CANONICAL_FOLDERS.find((f) => f.id === activeFolder)?.label ?? activeFolder; + + /* ---- compose / send ---- */ + const handleSent = useCallback(() => { + setComposeOpen(false); + void loadMessages(); + }, [loadMessages]); + + if (!accountsLoaded) { + return ( +
+
+
+
+ ); + } + + if (showAddForm || accounts.length === 0) { + return ( +
+
+
+ 0 ? () => setShowAddForm(false) : undefined} + onAdded={async () => { + setShowAddForm(false); + setActiveAccountId(null); + await loadAccounts(); + }} + /> +
+ ); + } + + const rootClass = `${styles.root} ${isMobile && mobileReading ? styles.mobileReading : ""}`; + + return ( +
+
+
+ +
+ {/* ---- sidebar ---- */} + + + {/* ---- message list ---- */} +
+
+
+

{activeFolderLabel}

+ + {unreadCount > 0 ? `${unreadCount} unread` : `${messages.length} messages`} + +
+
+
+
+ {( + [ + ["all", "All", messages.length], + ["unread", "Unread", unreadCount], + ["flagged", "Flagged", flaggedCount], + ] as [Filter, string, number][] + ).map(([id, label, n]) => ( + + ))} +
+
+ +
+ {messagesLoading ? ( + <> +
+ + {/* ---- reading pane ---- */} +
+
+ {isMobile && ( + + )} + {/* TODO(phase-2): wire Reply / Reply all / Forward to a prefilled + compose. Phase 1 ships the compose surface and send only. */} + + + +
+ + +
+ {/* Share / Send to: entry point only. Full share sheet is task #69. */} + + + {shareOpen && ( +
+
Share / Send to
+ +
Full share sheet coming soon
+
+ )} +
+
+ + {detailLoading ? ( +
+
+ ) : !detail ? ( +
+
+ ) : ( +
+
{detail.subject || "(no subject)"}
+
+
+ {initials(detail.from_name, detail.from_addr)} +
+
+
{detail.from_name || detail.from_addr}
+
{detail.from_addr}
+
+ to {detail.to} + {detail.cc ? ( + <> + , cc {detail.cc} + + ) : null} +
+
+
{detail.date}
+
+ +
{detail.body_text || detail.body_html}
+ + {detail.attachments.length > 0 && ( +
+
+ {detail.attachments.length} attachment + {detail.attachments.length === 1 ? "" : "s"} +
+ {detail.attachments.map((a, i) => ( +
+
+
+
+
{a.filename}
+
{formatBytes(a.size)}
+
+
+ ))} +
+ )} +
+ )} +
+
+ + {composeOpen && activeAccount && ( + setComposeOpen(false)} + onSent={handleSent} + /> + )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Compose overlay */ +/* ------------------------------------------------------------------ */ + +function ComposeOverlay({ + account, + onClose, + onSent, +}: { + account: MailAccount; + onClose: () => void; + onSent: () => void; +}) { + const [to, setTo] = useState(""); + const [cc, setCc] = useState(""); + const [showCc, setShowCc] = useState(false); + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + const send = useCallback(async () => { + if (!to.trim() || sending) return; + setSending(true); + setError(null); + try { + await sendMessage(account.id, { to, subject, body, cc }); + onSent(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to send"); + setSending(false); + } + }, [account.id, to, cc, subject, body, sending, onSent]); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

New message

+
+ +
+
+ +
+ {/* From: account selector is static in Phase 1 (one user account at a + time). The agent send-as switcher is a Phase 2 affordance below. */} +
+ From +
+
+ {initials(account.display_name, account.email_address)} +
+
+
{account.display_name}
+
{account.email_address}
+
+
+
+ {/* Phase-2 affordance: send as an agent. Shown, not wired. */} +
+
+
+ To + setTo(e.target.value)} + aria-label="To" + /> + {!showCc && ( + + )} +
+ {showCc && ( +
+ Cc + setCc(e.target.value)} + aria-label="Cc" + /> +
+ )} +
+ Subject + setSubject(e.target.value)} + aria-label="Subject" + /> +
+
+ +