diff --git a/bun.lock b/bun.lock index da859ab4..0e5d4570 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "html2canvas": "^1.4.1", "i18n": "workspace:*", "idb-keyval": "^6.2.2", + "imapflow": "^1.2.10", "injector": "workspace:*", "input-otp": "^1.4.2", "js-beautify": "^1.15.4", @@ -549,6 +550,8 @@ "@phc/format": ["@phc/format@1.0.0", "", {}, "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], @@ -1029,6 +1032,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], @@ -1583,6 +1588,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "imapflow": ["imapflow@1.2.10", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "8.0.1", "pino": "10.3.1", "socks": "2.8.7" } }, "sha512-tqmk0Gj4YBEnGCjjrUYWIf3Z4tzn4iihUcMkBRbafvHF3LqEiYNCSJAAYYbwERFxlikOJ3zzqtEcoxCUTjMv2Q=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -1959,6 +1966,8 @@ "officerdev": ["officerdev@workspace:src/workspaces/officerdev"], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -2035,6 +2044,12 @@ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], @@ -2079,6 +2094,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], @@ -2151,6 +2168,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-countup": ["react-countup@6.5.3", "", { "dependencies": { "countup.js": "^2.8.0" }, "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w=="], @@ -2193,6 +2212,8 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="], "redis": ["redis@5.11.0", "", { "dependencies": { "@redis/bloom": "5.11.0", "@redis/client": "5.11.0", "@redis/json": "5.11.0", "@redis/search": "5.11.0", "@redis/time-series": "5.11.0" } }, "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ=="], @@ -2277,6 +2298,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -2329,6 +2352,8 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "sounds": ["sounds@workspace:src/workspaces/sounds"], @@ -2413,6 +2438,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], @@ -2753,6 +2780,10 @@ "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "imapflow/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "imapflow/nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], + "mailparser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], diff --git a/docs/per-user-api-keys.md b/docs/per-user-api-keys.md new file mode 100644 index 00000000..244e56e0 --- /dev/null +++ b/docs/per-user-api-keys.md @@ -0,0 +1,92 @@ +# Per-User API Keys — Architecture Analysis + +## Current State + +- Single shared `auth.json` at `~/.pi/agent/auth.json` (server-side) +- Pi CLI supports `--api-key` flag — we already use this in `pi-bridge.ts` via `resolveApiKeyForModel()` +- User settings have unused fields: `ai.enabledModels`, `ai.enabledProviders`, `ai.disabledProviders` +- 13 providers supported: anthropic, openai, google, groq, mistral, xai, openrouter, minimax, huggingface, azure-openai-responses, opencode, zai, cerebras + +## Architecture + +### Layer 1: Key Resolution + +**Precedence:** User key > System key > fail + +- Store user keys in `user_settings.ai.apiKeys` (JSONB column, no new DB table needed) +- Format: `{ "ai": { "apiKeys": { "openai": "sk-...", "anthropic": "sk-ant-..." } } }` +- `resolveApiKeyForModel()` in `pi-bridge.ts` checks user keys first, falls back to system `auth.json` +- Key passed to Pi via `--api-key` CLI flag (already implemented for system keys) +- Keys never touch the container filesystem — all resolution is server-side + +### Layer 2: Model Visibility + +- System access policy defines base available providers/models +- User's own provider keys unlock additional providers +- Existing `enabledProviders`/`enabledModels` settings fields drive filtering +- Model listing becomes a union: system models + user's provider models + +## Key Design Decisions + +1. **No new DB table** — use existing `user_settings` JSONB column +2. **No per-user auth.json files** — all key resolution server-side via `--api-key` flag +3. **Eventually remove PI_CONFIG_DIR mount** — auth.json in container no longer needed for keys (still needed for local providers/models.json) +4. **Model listing = union** of system + user models + +## What This Enables + +- Admin deploys with their keys → all users can chat +- User adds their own key → gets access to that provider +- User's key takes priority (user pays for their own usage) +- No credential leakage (keys in DB, passed as CLI args, never on container filesystem) + +## Files to Change + +| File | Change | +|------|--------| +| `src/servers/api/pi/pi-bridge.ts` | Update `resolveApiKeyForModel()` to check user settings first | +| `src/servers/api/settings/settings.ts` | API endpoint for saving/deleting user API keys (encrypted at rest ideally) | +| `src/workspaces/state/src/useSettings.ts` | Add `ai.apiKeys` to `UserSettings` type | +| `src/servers/api/pi/list-models.ts` | Union system + user provider models | +| `src/workspaces/officerdev/src/hooks/usePiModels.ts` | Filter models based on user's available providers | +| `src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx` | UI for managing per-user API keys | +| `src/servers/api/server-settings/pi-mono.ts` | Distinguish admin vs user key management | + +## Deep-Dive: Pi CLI & Auth + +### CLI Flags +- `--provider` — force provider +- `--model` — force model (format: `provider/model-name`) +- `--api-key` — force API key (takes precedence over auth.json and env vars) +- `--system-prompt` — system prompt + +### Supported Environment Variables +`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`, etc. + +### auth.json Format +```json +{ + "provider_id": { + "type": "api_key", + "key": "sk-..." + } +} +``` + +### Key Resolution Precedence (inside Pi) +`--api-key` flag > auth.json entry > environment variable > built-in providers + +### Pi Spawn Flow (pi-bridge.ts) +1. Determine model from user settings or default +2. `resolveApiKeyForModel(model)` extracts provider from `provider/model-name` +3. Looks up key in auth.json (currently system-level only) +4. Passes `--api-key ` when spawning Pi process +5. Container never sees raw credentials + +## Implementation Steps + +1. **Add key storage** — extend `UserSettings` type, add API endpoint for CRUD +2. **Update key resolution** — `resolveApiKeyForModel(model, userId?)` checks user DB first +3. **Update model listing** — merge system models with user-unlocked provider models +4. **Build settings UI** — API key input per provider in AI settings page +5. **Remove PI_CONFIG_DIR mount** (later) — once models.json is also handled server-side diff --git a/package.json b/package.json index 6ac73aaa..705e43b5 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "html2canvas": "^1.4.1", "i18n": "workspace:*", "idb-keyval": "^6.2.2", + "imapflow": "^1.2.10", "injector": "workspace:*", "input-otp": "^1.4.2", "js-beautify": "^1.15.4", diff --git a/scripts/add-email-dock-user2.ts b/scripts/add-email-dock-user2.ts index 30942467..61b2fd47 100644 --- a/scripts/add-email-dock-user2.ts +++ b/scripts/add-email-dock-user2.ts @@ -7,7 +7,7 @@ import { getDockPaths, setDockPaths } from 'officerdb'; const USER_ID = 2; -const DEFAULT_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; +const DEFAULT_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat']; async function main() { const existing = await getDockPaths(USER_ID); diff --git a/scripts/reset-user-data.ts b/scripts/reset-user-data.ts index 8d6771a5..5d531ff3 100644 --- a/scripts/reset-user-data.ts +++ b/scripts/reset-user-data.ts @@ -4,11 +4,11 @@ * Deletes: * - DB: user_settings, user_state, user_integrations, dock_configs, * chat_sessions (cascades chat_messages), chat_groups, - * workspaces, screens, projects, + * dashboards, screens, projects, * tasks, skills, processes, tools, extensions (+ item_chats), * task_logs, queue_jobs, terminal_containers * - Filesystem: entire $DATA_PATH// directory - * (home, settings, state, workspaces, chat_sessions, emails.db, + * (home, settings, state, dashboards, chat_sessions, emails.db, * Gmail, skills, tools, tasks, processes, extensions, logs, cache, etc.) * - Queue job files: $DATA_PATH/queue/jobs/*.json owned by user * - Terminal containers map: removes user entry from terminal-containers.json @@ -49,7 +49,7 @@ if (!user) { console.log(`\nUser: ${user.email} (id: ${user.id})`); console.log(`Data dir: ${join(DATA_PATH, email)}`); -console.log('\nThis will delete ALL user data (settings, chats, workspaces, emails, home dir, etc.)'); +console.log('\nThis will delete ALL user data (settings, chats, dashboards, emails, home dir, etc.)'); console.log('Auth credentials (account, passkeys) will be preserved.\n'); if (!skipConfirm) { @@ -78,7 +78,7 @@ const tables = [ 'dock_configs', 'chat_sessions', // cascades chat_messages 'chat_groups', - 'workspaces', + 'dashboards', 'screens', 'projects', 'task_logs', diff --git a/seed/tools/TOOLS.md b/seed/tools/TOOLS.md index 748889d4..2243b288 100644 --- a/seed/tools/TOOLS.md +++ b/seed/tools/TOOLS.md @@ -175,5 +175,6 @@ The `tool-loader` extension discovers tools from directories listed in the `PI_T | `browser` | Control a Chrome browser via Browser Relay | | `apify` | Run any Apify actor (web scraping, social media data) | | `convert_audio_to_mp3` | Convert audio files to MP3 via ffmpeg | +| `ffmpeg` | Run ffmpeg/ffprobe commands for any audio/video processing | | `ocr` | Optical character recognition on images | | `email_db` | Query the synced email database | diff --git a/seed/tools/convert-audio-to-mp3/TOOL.md b/seed/tools/convert-audio-to-mp3/TOOL.md index 63db7ee8..2f60bcfe 100644 --- a/seed/tools/convert-audio-to-mp3/TOOL.md +++ b/seed/tools/convert-audio-to-mp3/TOOL.md @@ -2,7 +2,7 @@ name: convert_audio_to_mp3 label: Convert Audio to MP3 description: Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Supports single file conversion with real-time percentage progress, and batch conversion of an entire artist directory with per-file progress. Use when the user wants to convert FLAC, WAV, OGG, or other audio formats to MP3. -version: 2 +version: 3 language: typescript inputs: mode: @@ -18,6 +18,16 @@ inputs: Converts audio to MP3 320kbps CBR via libmp3lame, preserving all metadata tags. +## Prerequisites + +This tool requires `ffmpeg` and `ffprobe`. If they are not installed, install them before running: + +```bash +sudo apt-get update && sudo apt-get install -y ffmpeg +``` + +All sudo commands run without a password prompt in this environment. + ## Modes ### single diff --git a/seed/tools/ffmpeg/TOOL.md b/seed/tools/ffmpeg/TOOL.md new file mode 100644 index 00000000..703caf9d --- /dev/null +++ b/seed/tools/ffmpeg/TOOL.md @@ -0,0 +1,94 @@ +--- +name: ffmpeg +label: FFmpeg +description: Run ffmpeg and ffprobe commands for audio/video processing. Converts formats, extracts audio/video streams, trims, merges, adjusts volume, changes resolution, extracts frames, and more. Auto-installs ffmpeg if not available. Use when the user needs any audio or video manipulation. +version: 1 +language: typescript +inputs: + command: + type: enum + values: ffmpeg,ffprobe + description: "ffmpeg: process audio/video. ffprobe: inspect file metadata (duration, codecs, streams, etc.)" + args: + type: string + description: "Command-line arguments as a single string. Do NOT include the ffmpeg/ffprobe binary name — only the arguments. Example: '-i input.mp4 -vn -codec:a libmp3lame -b:a 320k output.mp3'" +--- + +# FFmpeg + +General-purpose audio/video processing via ffmpeg and ffprobe. + +## Prerequisites + +This tool requires `ffmpeg` and `ffprobe`. It will automatically install them if not available: + +```bash +sudo apt-get update && sudo apt-get install -y ffmpeg +``` + +All sudo commands run without a password prompt in this environment. + +## Usage + +### ffprobe — Inspect files + +Get duration, codecs, streams, bitrate, and other metadata: + +``` +command: ffprobe +args: -v quiet -print_format json -show_format -show_streams input.mp4 +``` + +### ffmpeg — Process files + +Always include `-y` to overwrite output files without prompting. + +**Convert video to MP4:** +``` +command: ffmpeg +args: -i input.avi -codec:v libx264 -codec:a aac output.mp4 -y +``` + +**Extract audio from video:** +``` +command: ffmpeg +args: -i video.mp4 -vn -codec:a libmp3lame -b:a 320k audio.mp3 -y +``` + +**Trim a clip:** +``` +command: ffmpeg +args: -i input.mp4 -ss 00:01:30 -to 00:03:00 -codec copy clip.mp4 -y +``` + +**Change resolution:** +``` +command: ffmpeg +args: -i input.mp4 -vf scale=1280:720 -codec:a copy output.mp4 -y +``` + +**Extract a frame as image:** +``` +command: ffmpeg +args: -i video.mp4 -ss 00:00:10 -frames:v 1 frame.png -y +``` + +**Merge audio and video:** +``` +command: ffmpeg +args: -i video.mp4 -i audio.mp3 -codec:v copy -codec:a aac -shortest merged.mp4 -y +``` + +**Convert audio format:** +``` +command: ffmpeg +args: -i input.flac -codec:a libmp3lame -b:a 320k output.mp3 -y +``` + +## Notes + +- Use absolute paths for input and output files. +- Always add `-y` to ffmpeg args to avoid interactive prompts. +- For long operations, progress is streamed in real time. +- stderr output from ffmpeg is captured and returned on failure. +- The tool has a 10-minute timeout. diff --git a/seed/tools/ffmpeg/index.ts b/seed/tools/ffmpeg/index.ts new file mode 100644 index 00000000..901fa172 --- /dev/null +++ b/seed/tools/ffmpeg/index.ts @@ -0,0 +1,173 @@ +import { execFileSync, spawn } from 'node:child_process'; + +type ToolResult = { + content: Array<{ type: string; text: string }>; + isError?: boolean; +}; + +type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void; + +function update(onUpdate: OnUpdate | undefined, text: string): void { + onUpdate?.({ content: [{ type: 'text', text }] }); +} + +function ensureFfmpeg(): boolean { + try { + execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + try { + execFileSync('sudo', ['apt-get', 'update'], { stdio: 'ignore', timeout: 60_000 }); + execFileSync('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { stdio: 'ignore', timeout: 120_000 }); + execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } + } +} + +function parseArgs(argsString: string): string[] { + const args: string[] = []; + let current = ''; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < argsString.length; i++) { + const ch = argsString[i]!; + + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } else if (ch === ' ' && !inSingle && !inDouble) { + if (current.length > 0) { + args.push(current); + current = ''; + } + } else { + current += ch; + } + } + + if (current.length > 0) args.push(current); + return args; +} + +export async function execute( + _toolCallId: string, + params: { command: 'ffmpeg' | 'ffprobe'; args: string }, + _signal: AbortSignal | undefined, + onUpdate?: OnUpdate, +): Promise { + const { command, args: argsString } = params; + + if (!argsString || argsString.trim().length === 0) { + return { + content: [{ type: 'text', text: 'No arguments provided. See tool documentation for usage examples.' }], + isError: true, + }; + } + + update(onUpdate, 'Checking ffmpeg installation...'); + + if (!ensureFfmpeg()) { + return { + content: [{ type: 'text', text: 'Failed to install ffmpeg. Try manually: sudo apt-get update && sudo apt-get install -y ffmpeg' }], + isError: true, + }; + } + + const args = parseArgs(argsString); + + // For ffprobe, run synchronously and return output + if (command === 'ffprobe') { + update(onUpdate, `Running ffprobe...`); + return new Promise((resolve) => { + const proc = spawn('ffprobe', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + + proc.on('close', (code) => { + if (code !== 0) { + resolve({ + content: [{ type: 'text', text: `ffprobe failed (exit ${code}):\n${stderr.trim()}` }], + isError: true, + }); + } else { + // ffprobe prints info to stderr by default, stdout for -print_format + const output = stdout.trim() || stderr.trim(); + resolve({ + content: [{ type: 'text', text: output || 'No output' }], + }); + } + }); + + proc.on('error', (err) => { + resolve({ + content: [{ type: 'text', text: `ffprobe error: ${err.message}` }], + isError: true, + }); + }); + }); + } + + // For ffmpeg, stream progress + update(onUpdate, `Running ffmpeg...`); + + return new Promise((resolve) => { + const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + let progressBuf = ''; + + proc.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + proc.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + stderr += text; + + // Parse ffmpeg progress from stderr (time= field) + progressBuf += text; + const lines = progressBuf.split('\r'); + progressBuf = lines.pop() ?? ''; + for (const line of lines) { + const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/); + const speedMatch = line.match(/speed=\s*([\d.]+x)/); + if (timeMatch) { + const progress = `Time: ${timeMatch[1]}${speedMatch ? ` | Speed: ${speedMatch[1]}` : ''}`; + update(onUpdate, progress); + } + } + }); + + proc.on('close', (code) => { + if (code !== 0) { + // Extract the last meaningful error line from stderr + const errLines = stderr.trim().split('\n'); + const lastLines = errLines.slice(-10).join('\n'); + resolve({ + content: [{ type: 'text', text: `ffmpeg failed (exit ${code}):\n${lastLines}` }], + isError: true, + }); + } else { + const output = stdout.trim(); + resolve({ + content: [{ type: 'text', text: output ? `Done.\n\n${output}` : 'Done.' }], + }); + } + }); + + proc.on('error', (err) => { + resolve({ + content: [{ type: 'text', text: `ffmpeg error: ${err.message}` }], + isError: true, + }); + }); + }); +} diff --git a/src/apps/landing/index.html b/src/apps/landing/index.html index e6ba0890..effdd9b3 100644 --- a/src/apps/landing/index.html +++ b/src/apps/landing/index.html @@ -4,12 +4,12 @@ Officer Dev (Alpha) - + - + @@ -21,7 +21,7 @@ - + diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 8f6709af..98a1077f 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -54,8 +54,8 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx index e568eea5..82848527 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Bootstrap.tsx @@ -29,7 +29,7 @@ export function Bootstrap() { }; return ( - + {done ? (

Check your email

diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx index 0982e907..205fa0f1 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx @@ -38,7 +38,7 @@ export function Login() { }; return ( - +
Welcome Back
Sign in to your account
diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx index 2f7dffae..2cdfd9c7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Automation/AutomationSidebar.tsx @@ -3,14 +3,14 @@ import { usePanelChannel } from 'hooks/usePanelChannel'; import type { AutomationSelection } from './AutomationRightPanel'; const capabilityItems = [ - { id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' }, - { id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' }, { id: 'skills', label: 'Skills', icon: Sparkles, kind: 'Skill', endpoint: '/skills', queryKey: 'skills' }, { id: 'tools', label: 'Tools', icon: Wrench, kind: 'Tool', endpoint: '/tools', queryKey: 'tools' }, + { id: 'tasks', label: 'Tasks', icon: ListTodo, kind: 'Task', endpoint: '/tasks', queryKey: 'tasks' }, { id: 'pipelines', label: 'Pipelines', icon: Workflow, kind: 'Pipeline', endpoint: '/pipelines', queryKey: 'pipelines' }, + { id: 'processes', label: 'Processes', icon: Cpu, kind: 'Process', endpoint: '/processes', queryKey: 'processes' }, + { id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' }, { id: 'crons', label: 'Crons', icon: Clock, kind: 'Cron', endpoint: '/crons', queryKey: 'crons' }, { id: 'services', label: 'Services', icon: Server, kind: 'Service', endpoint: '/services', queryKey: 'services' }, - { id: 'workflows', label: 'Workflows', icon: GitBranch, kind: 'Workflow', endpoint: '/workflows', queryKey: 'workflows' }, ] as const; export const AutomationSidebar = () => { diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx index e27f239e..de18584d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx @@ -3,7 +3,7 @@ import { useIsMobile } from 'hooks/useIsMobile'; import { usePanelChannel } from 'hooks/usePanelChannel'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceView } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { AutomationRightPanel } from './AutomationRightPanel'; import type { AutomationSelection } from './AutomationRightPanel'; @@ -67,7 +67,7 @@ const removeChatPanel = (layout: LayoutNode): LayoutNode => { }; export const Automation = () => { - const workspace = useWorkspacesState('screens/automation', defaultLayout); + const workspace = useDashboardState('screens/automation', defaultLayout); const [selection, setSelection] = usePanelChannel('automation:selected-capability', null); const isMobile = useIsMobile(); const editing = selection?.editing ?? false; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx index 5e1c24b8..5e4f38c0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx @@ -1,7 +1,7 @@ import { useMemo, useCallback } from 'react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceView } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { useIsMobile } from 'hooks/useIsMobile'; import { useGlobal } from 'hooks/useGlobal'; import { defaultLayout } from './defaultLayout'; @@ -13,7 +13,7 @@ const PROMPT_PREFIX = `You are a browser assistant. The user has connected Chrom export const BrowserScreen = () => { const isMobile = useIsMobile(); const [selectedId, setSelectedId] = useGlobal('BROWSER_SELECTED_TAB', null); - const workspace = useWorkspacesState('screens/browser', defaultLayout); + const workspace = useDashboardState('screens/browser', defaultLayout); const components: PanelComponents = useMemo( () => ({ diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 875ed902..66e455dd 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useIsMobile } from 'hooks/useIsMobile'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from 'state/useChatSessions'; import { defaultLayout } from './defaultLayout'; @@ -37,7 +37,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { sessionId } = useParams<{ sessionId: string }>(); const { sessions } = useChatSessions(); const [, setSelected] = usePanelChannel('chat:selected-session', null); - const rawWorkspace = useWorkspacesState('screens/chat', defaultLayout); + const rawWorkspace = useDashboardState('screens/chat', defaultLayout); const isMobile = useIsMobile(); const navigate = useNavigate(); const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; diff --git a/src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardScreen.tsx new file mode 100644 index 00000000..1fba6377 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardScreen.tsx @@ -0,0 +1,25 @@ +import { useParams, Navigate } from 'react-router'; +import { useDashboardState } from 'state/useDashboardState'; +import type { LayoutNode, DashboardDefinition } from 'officerdev'; +import { WorkspaceView, createDefaultLayout } from 'officerdev'; + +export const DashboardScreen = () => { + const { id } = useParams<{ id: string }>(); + const { value: dashboards, isLoaded } = useDashboardState('workspaces', []); + const ws = dashboards.find((w) => w.id === id); + + if (!isLoaded) return null; + if (!ws) return ; + + return ; +}; + +const DashboardScreenInner = ({ dashboard }: { dashboard: DashboardDefinition }) => { + const wsState = useDashboardState(`ws-layout-${dashboard.id}`, createDefaultLayout()); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardsScreen.tsx similarity index 69% rename from src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx rename to src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardsScreen.tsx index 67e2af6d..8df75cdf 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Dashboards/DashboardsScreen.tsx @@ -1,14 +1,14 @@ import type { LayoutNode } from 'officerdev'; -import { WorkspaceView, SELECTED_WORKSPACE_KEY } from 'officerdev'; +import { WorkspaceView, SELECTED_DASHBOARD_KEY } from 'officerdev'; import { useIsMobile } from 'hooks/useIsMobile'; import { useGlobal } from 'hooks/useGlobal'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; -export const WorkspacesScreen = () => { - const workspace = useWorkspacesState('screens/workspaces', defaultLayout); +export const DashboardsScreen = () => { + const workspace = useDashboardState('screens/dashboards', defaultLayout); const isMobile = useIsMobile(); - const [selected, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); + const [selected, setSelected] = useGlobal(SELECTED_DASHBOARD_KEY, null); const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined; return ( diff --git a/src/apps/officer-web/Screens/Dashboard/Dashboards/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Dashboards/defaultLayout.ts new file mode 100644 index 00000000..f53d2548 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Dashboards/defaultLayout.ts @@ -0,0 +1,11 @@ +import type { LayoutNode } from 'officerdev'; + +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'ws-home-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'ws-home-list', appType: 'dashboard-list' }, size: 50 }, + { node: { type: 'panel', id: 'ws-home-right', appType: 'dashboard-preview' }, size: 50 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Dashboards/index.tsx b/src/apps/officer-web/Screens/Dashboard/Dashboards/index.tsx new file mode 100644 index 00000000..1b726303 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Dashboards/index.tsx @@ -0,0 +1,2 @@ +export { DashboardsScreen } from './DashboardsScreen'; +export { DashboardScreen } from './DashboardScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index 40d8aa8e..9a1aaa41 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -20,6 +20,7 @@ const LIMIT = 50; const FOLDERS = [ { key: 'inbox', label: 'Inbox', icon: Inbox }, + { key: 'all', label: 'All', icon: Mail }, { key: 'sent', label: 'Sent', icon: Send }, { key: 'spam', label: 'Spam', icon: ShieldAlert }, { key: 'trash', label: 'Trash', icon: Trash2 }, @@ -55,6 +56,19 @@ export const EmailList = () => { client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`), }); + // Auto-switch to "all" if inbox filter returns nothing but emails exist + const { data: allCount } = useQuery({ + queryKey: ['email-messages-all-count'], + queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages?page=1&limit=1&folder=all'), + enabled: folder === 'inbox' && !isLoading && (data?.total ?? 0) === 0, + }); + + useEffect(() => { + if (folder === 'inbox' && data?.total === 0 && allCount && allCount.total > 0) { + setFolder('all'); + } + }, [folder, data?.total, allCount]); + const handleFolderChange = (newFolder: string) => { setFolder(newFolder); setPage(1); diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx index 0937966f..4af3f484 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx @@ -1,7 +1,7 @@ import { useMemo, useCallback } from 'react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceView } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { useIsMobile } from 'hooks/useIsMobile'; import { useGlobal } from 'hooks/useGlobal'; import { defaultLayout } from './defaultLayout'; @@ -13,7 +13,7 @@ const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite e export const EmailScreen = () => { const isMobile = useIsMobile(); const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null); - const workspace = useWorkspacesState('screens/email', defaultLayout); + const workspace = useDashboardState('screens/email', defaultLayout); const components: PanelComponents = useMemo( () => ({ diff --git a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx index 28dfaf85..c86c0b61 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/FilesScreen.tsx @@ -1,10 +1,10 @@ import type { LayoutNode } from 'officerdev'; import { WorkspaceView, useFileViewerPanels } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; export const FilesScreen = () => { - const workspace = useWorkspacesState('screens/files', defaultLayout); + const workspace = useDashboardState('screens/files', defaultLayout); const ephemeral = useFileViewerPanels(); return ( diff --git a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx index 1ce94d18..f0577676 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/HomeScreen.tsx @@ -1,6 +1,6 @@ import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev'; import { WorkspaceView, useFileViewerPanels } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { useSettings } from 'state/useSettings'; import { Button } from '@/components/ui/button'; import { defaultLayout } from './defaultLayout'; @@ -34,7 +34,7 @@ const components: PanelComponents = { const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' }; export const HomeScreen = () => { - const workspace = useWorkspacesState('screens/home', defaultLayout); + const workspace = useDashboardState('screens/home', defaultLayout); const ephemeral = useFileViewerPanels(); const { settings } = useSettings(); diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx index a93733e5..d9322426 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/BugReport/BugReportButton.tsx @@ -9,10 +9,10 @@ export const BugReportButton = () => { <> -
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 3aedbbf8..0575aa83 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -124,7 +124,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, - { label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' }, + { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, ]; -export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; +export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat']; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index 3f020dc1..0f8849cf 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; import { Link, useLocation } from 'react-router'; -import { Menu } from 'lucide-react'; +import { Menu, Terminal } from 'lucide-react'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import type { DockItem } from '../Dock'; import { useIsTouch } from '../useIsTouch'; import { UserMenu } from './UserMenu'; +import { BugReportButton } from '../BugReport/BugReportButton'; type HeaderProps = { dockItems?: DockItem[]; @@ -38,7 +39,17 @@ export function Header({ dockItems }: HeaderProps) { />
- +
+ + + +
{isTouch && dockItems && ( diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx index 13b986ff..427b8f27 100644 --- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { useGlobal } from 'hooks/useGlobal'; import { useIsMobile } from 'hooks/useIsMobile'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import type { LayoutNode, ProjectType } from 'officerdev'; import { WorkspaceView, @@ -20,7 +20,7 @@ import { generateSlug } from 'helpers/slug'; import { defaultLayout } from './defaultLayout'; export const ProjectListScreen = () => { - const workspace = useWorkspacesState('screens/projects', defaultLayout); + const workspace = useDashboardState('screens/projects', defaultLayout); const isMobile = useIsMobile(); const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null); const [creating] = useGlobal(CREATING_PROJECT, false); diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx index 4dfdf181..69aa95af 100644 --- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectScreen.tsx @@ -1,11 +1,11 @@ import { useParams, Navigate } from 'react-router'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import type { LayoutNode, ProjectDefinition } from 'officerdev'; import { WorkspaceView, createDefaultLayout } from 'officerdev'; export const ProjectScreen = () => { const { id } = useParams<{ id: string }>(); - const { value: projects, isLoaded } = useWorkspacesState('projects', []); + const { value: projects, isLoaded } = useDashboardState('projects', []); const project = projects.find((p) => p.id === id); if (!isLoaded) return null; @@ -15,7 +15,7 @@ export const ProjectScreen = () => { }; const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => { - const workspace = useWorkspacesState(`proj-layout-${project.id}`, createDefaultLayout()); + const workspace = useDashboardState(`proj-layout-${project.id}`, createDefaultLayout()); return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx index c581b114..2849ef6a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/GoogleAccount.tsx @@ -22,6 +22,7 @@ export const GoogleAccount = () => { const [isLoading, setIsLoading] = useState(true); const [status, setStatus] = useState({ connected: false, email: null, picture: null, configured: false }); const [lastSyncAt, setLastSyncAt] = useState(null); + const [dismissedError, setDismissedError] = useState(false); const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' }); const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running'); const lastJob = jobs[0]; @@ -143,11 +144,15 @@ export const GoogleAccount = () => {
)} - {!activeJob && lastJob?.status === 'failed' && ( -
+ {!activeJob && lastJob?.status === 'failed' && !dismissedError && ( +
+ )} {!activeJob && lastSyncAt && (
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx index 5a4eb5e6..29654dac 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/AIModels.tsx @@ -93,7 +93,7 @@ export const AIModels = () => { diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx index 1b969c06..a168d7f0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx @@ -11,14 +11,24 @@ type DockPillProps = { visible: boolean; onAction: (path: string) => void; onDragStart: (ev: DragEvent, path: string) => void; + onDropOnPill?: (draggedPath: string) => void; + dropIndicator?: 'left' | 'right' | null; + onDragOverPill?: (ev: DragEvent) => void; + onDragLeavePill?: () => void; }; -const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPillProps) => ( +const DockPill = ({ + label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill, +}: DockPillProps) => ( onDragStart(ev, path)} - className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors" + onDragOver={onDragOverPill} + onDragLeave={onDragLeavePill} + onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined} + className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`} > + {dropIndicator === 'left' && } {label} + {dropIndicator === 'right' && } ); @@ -73,6 +84,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { export const DockSettings = () => { const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); + const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null); const visiblePaths = new Set(items.map((i) => i.to)); const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to)); @@ -97,8 +109,42 @@ export const DockSettings = () => { [items, setItems], ); + const insertAt = useCallback( + (draggedPath: string, targetPath: string, side: 'left' | 'right') => { + const paths = items.filter((i) => i.to !== draggedPath).map((i) => i.to); + const targetIdx = paths.indexOf(targetPath); + if (targetIdx === -1) return; + const insertIdx = side === 'left' ? targetIdx : targetIdx + 1; + paths.splice(insertIdx, 0, draggedPath); + setItems(paths); + setDropTarget(null); + }, + [items, setItems], + ); + + const handlePillDragOver = useCallback((ev: DragEvent, pillPath: string) => { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + const midX = rect.left + rect.width / 2; + const side = ev.clientX < midX ? 'left' : 'right'; + setDropTarget((prev) => (prev?.path === pillPath && prev?.side === side ? prev : { path: pillPath, side })); + }, []); + + const handlePillDragLeave = useCallback(() => setDropTarget(null), []); + + const handlePillDrop = useCallback( + (draggedPath: string, targetPath: string) => { + if (draggedPath === targetPath) { setDropTarget(null); return; } + const side = dropTarget?.path === targetPath ? dropTarget.side : 'right'; + insertAt(draggedPath, targetPath, side); + }, + [dropTarget, insertAt], + ); + const onDropVisible = useCallback( (path: string) => { + setDropTarget(null); if (visiblePaths.has(path)) return; addItem(path); }, @@ -107,6 +153,7 @@ export const DockSettings = () => { const onDropHidden = useCallback( (path: string) => { + setDropTarget(null); if (!visiblePaths.has(path)) return; removeItem(path); }, @@ -126,6 +173,10 @@ export const DockSettings = () => { visible onAction={removeItem} onDragStart={onDragStart} + dropIndicator={dropTarget?.path === item.to ? dropTarget.side : null} + onDragOverPill={(ev) => handlePillDragOver(ev, item.to)} + onDragLeavePill={handlePillDragLeave} + onDropOnPill={(draggedPath) => handlePillDrop(draggedPath, item.to)} /> ))} diff --git a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx index 3489b233..b94b0d71 100644 --- a/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Terminal/TerminalScreen.tsx @@ -1,10 +1,10 @@ import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { defaultLayout } from './defaultLayout'; export const TerminalScreen = () => { - const workspace = useWorkspacesState('screens/terminal', defaultLayout); + const workspace = useDashboardState('screens/terminal', defaultLayout); return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx deleted file mode 100644 index c6c972cf..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceScreen.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useParams, Navigate } from 'react-router'; -import { useWorkspacesState } from 'state/useWorkspacesState'; -import type { LayoutNode, WorkspaceDefinition } from 'officerdev'; -import { WorkspaceView, createDefaultLayout } from 'officerdev'; - -export const WorkspaceScreen = () => { - const { id } = useParams<{ id: string }>(); - const { value: workspaces, isLoaded } = useWorkspacesState('workspaces', []); - const ws = workspaces.find((w) => w.id === id); - - if (!isLoaded) return null; - if (!ws) return ; - - return ; -}; - -const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => { - const wsState = useWorkspacesState(`ws-layout-${workspace.id}`, createDefaultLayout()); - - return ( -
- -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Workspaces/defaultLayout.ts deleted file mode 100644 index 98762bcf..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/defaultLayout.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { LayoutNode } from 'officerdev'; - -export const defaultLayout: LayoutNode = { - type: 'group', - id: 'ws-home-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'ws-home-list', appType: 'workspace-list' }, size: 50 }, - { node: { type: 'panel', id: 'ws-home-right', appType: 'workspace-preview' }, size: 50 }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx deleted file mode 100644 index 4dbb5a34..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { WorkspacesScreen } from './WorkspacesScreen'; -export { WorkspaceScreen } from './WorkspaceScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 8661ad14..dbad2751 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -13,7 +13,7 @@ export * from './Tasks'; export * from './Files'; export * from './ChatHistory'; -export * from './Workspaces'; +export * from './Dashboards'; export * from './Projects'; export * from './Terminal'; export * from './Email'; diff --git a/src/apps/officer-web/index.html b/src/apps/officer-web/index.html index 6ba04da3..5e135a2d 100644 --- a/src/apps/officer-web/index.html +++ b/src/apps/officer-web/index.html @@ -4,12 +4,12 @@ Officer Dev (Alpha) - + - + @@ -21,7 +21,7 @@ - + @@ -32,7 +32,7 @@ - + diff --git a/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql b/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql new file mode 100644 index 00000000..6107e76e --- /dev/null +++ b/src/databases/officer_db/migrations/0001_rename_workspaces_to_dashboards.sql @@ -0,0 +1,4 @@ +ALTER TABLE "workspaces" RENAME TO "dashboards";--> statement-breakpoint +ALTER TABLE "dashboards" DROP CONSTRAINT "workspaces_user_id_users_id_fk";--> statement-breakpoint +ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "dashboards" RENAME CONSTRAINT "uq_workspaces_user_id" TO "uq_dashboards_user_id"; diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 46f5d10b..51dcb83c 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1772121115932, "tag": "0000_clammy_meteorite", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1772265600000, + "tag": "0001_rename_workspaces_to_dashboards", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/databases/officer_db/src/schema/workspaces.ts b/src/databases/officer_db/src/schema/dashboards.ts similarity index 94% rename from src/databases/officer_db/src/schema/workspaces.ts rename to src/databases/officer_db/src/schema/dashboards.ts index b30cb141..981397a2 100644 --- a/src/databases/officer_db/src/schema/workspaces.ts +++ b/src/databases/officer_db/src/schema/dashboards.ts @@ -1,7 +1,7 @@ import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core'; import { users } from './auth'; -export const workspaces = pgTable('workspaces', { +export const dashboards = pgTable('dashboards', { id: text('id').primaryKey(), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), name: text('name').notNull(), @@ -13,7 +13,7 @@ export const workspaces = pgTable('workspaces', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - unique('uq_workspaces_user_id').on(table.userId, table.id), + unique('uq_dashboards_user_id').on(table.userId, table.id), ]); export const screens = pgTable('screens', { diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 261d4a46..09031760 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,7 +1,7 @@ export * from './auth'; export * from './user-data'; export * from './chat'; -export * from './workspaces'; +export * from './dashboards'; export * from './agent-items'; export * from './operations'; export * from './server'; diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index b29cda53..dc682d72 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -48,10 +48,10 @@ export type ChatMessageInsert = typeof Schema.chatMessages.$inferInsert; export type ChatGroupSelect = typeof Schema.chatGroups.$inferSelect; export type ChatGroupInsert = typeof Schema.chatGroups.$inferInsert; -// ── Workspaces ── +// ── Dashboards ── -export type WorkspaceSelect = typeof Schema.workspaces.$inferSelect; -export type WorkspaceInsert = typeof Schema.workspaces.$inferInsert; +export type DashboardSelect = typeof Schema.dashboards.$inferSelect; +export type DashboardInsert = typeof Schema.dashboards.$inferInsert; export type ScreenSelect = typeof Schema.screens.$inferSelect; export type ScreenInsert = typeof Schema.screens.$inferInsert; diff --git a/src/server.tsx b/src/server.tsx index 55264e9b..73dbb52f 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -11,6 +11,7 @@ import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws'; import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.html'; import { startBrowserRelay } from './servers/api/browser/relay'; +import { toShellUsername } from './servers/data-path'; const { PORT = '5000' } = process.env; @@ -126,7 +127,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined; const files = url.searchParams.get('files') ?? undefined; const ok = server.upgrade(req, { - data: { userId: user.id, email: user.email, username: user.username || user.email.split('@')[0]!, role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows, files }, + data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows, files }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } catch { diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index bdcef158..f6a566eb 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -5,6 +5,7 @@ import { sign, verify } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from './validate-password'; +import { validateUsername } from './validate-username'; export const bootstrapHandler: Handler = async function (ctx) { const body = ctx.get('body'); @@ -42,7 +43,7 @@ export const bootstrapHandler: Handler = async function (ctx) { const confirmPassword = body.confirmPassword as string; if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required'); - if (!username || !username.trim()) throw errors.BAD_REQUEST('Username is required'); + const validUsername = validateUsername(username); validatePassword(password); if (password !== confirmPassword) throw errors.BAD_REQUEST('Passwords do not match'); @@ -52,7 +53,7 @@ export const bootstrapHandler: Handler = async function (ctx) { email: payload.email, password: passwordHash, name: name.trim(), - username: username.trim(), + username: validUsername, role: 'Super Admin', status: 'Active', }); diff --git a/src/servers/api/auth/validate-username.ts b/src/servers/api/auth/validate-username.ts new file mode 100644 index 00000000..d6d75c2b --- /dev/null +++ b/src/servers/api/auth/validate-username.ts @@ -0,0 +1,23 @@ +import * as errors from '@@/custom-errors'; + +export function validateUsername(username: string | undefined): string { + if (!username || !username.trim()) { + throw errors.BAD_REQUEST('Username is required'); + } + + const trimmed = username.trim(); + + if (trimmed.includes('@')) { + throw errors.BAD_REQUEST('Username cannot be an email address'); + } + + if (trimmed.length < 2 || trimmed.length > 32) { + throw errors.BAD_REQUEST('Username must be between 2 and 32 characters'); + } + + if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) { + throw errors.BAD_REQUEST('Username can only contain letters, numbers, dots, hyphens, and underscores'); + } + + return trimmed; +} diff --git a/src/servers/api/auth/verify.ts b/src/servers/api/auth/verify.ts index 3afd6334..73435623 100644 --- a/src/servers/api/auth/verify.ts +++ b/src/servers/api/auth/verify.ts @@ -5,6 +5,7 @@ import { verify as verifyJwt, sign } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from './validate-password'; +import { validateUsername } from './validate-username'; export const verifyHandler: Handler = async function (ctx) { const { verificationCode, name, username, password, confirmPassword } = ctx.get('body'); @@ -24,7 +25,7 @@ export const verifyHandler: Handler = async function (ctx) { } if (username && typeof username === 'string' && username.trim()) { - updates.username = username.trim(); + updates.username = validateUsername(username); } if (password) { diff --git a/src/servers/api/workspaces/workspaces.ts b/src/servers/api/dashboards/dashboards.ts similarity index 83% rename from src/servers/api/workspaces/workspaces.ts rename to src/servers/api/dashboards/dashboards.ts index 4d7465a8..af47a6e7 100644 --- a/src/servers/api/workspaces/workspaces.ts +++ b/src/servers/api/dashboards/dashboards.ts @@ -2,35 +2,38 @@ import { mkdir, readdir, rm, cp } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { readdirSync } from 'node:fs'; import { createRouter } from '@@/create-router'; -import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils'; +import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils'; const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates'); -export const workspacesRouter = createRouter(); +export const dashboardsRouter = createRouter(); -// GET /workspaces -workspacesRouter.get('/', async (ctx) => { +// GET /dashboards +dashboardsRouter.get('/', async (ctx) => { const email = ctx.get('user').email; + + await migrateDashboardsDir(email); + const dirs = getDirs(email); - const dirFile = Bun.file(join(dirs.wsDir, 'index.json')); + const dirFile = Bun.file(join(dirs.dashDir, 'index.json')); if (!(await dirFile.exists())) { await migrateFromState(email, dirs); } await migrateHomepageToScreens(dirs, email); - const state = await readAllWorkspacesState(dirs); + const state = await readAllDashboardsState(dirs); return ctx.json(state); }); -// PATCH /workspaces -workspacesRouter.patch('/', async (ctx) => { +// PATCH /dashboards +dashboardsRouter.patch('/', async (ctx) => { const email = ctx.get('user').email; const body = ctx.get('body') as Record; const dirs = getDirs(email); - await mkdir(dirs.wsDir, { recursive: true }); + await mkdir(dirs.dashDir, { recursive: true }); for (const [key, value] of Object.entries(body)) { // Handle proj-meta-{slug}: create/update/delete project @@ -94,6 +97,6 @@ workspacesRouter.patch('/', async (ctx) => { await writeJsonFile(mapping.file, value); } - const state = await readAllWorkspacesState(dirs); + const state = await readAllDashboardsState(dirs); return ctx.json(state); }); diff --git a/src/servers/api/workspaces/index.ts b/src/servers/api/dashboards/index.ts similarity index 62% rename from src/servers/api/workspaces/index.ts rename to src/servers/api/dashboards/index.ts index 3552c352..0732128f 100644 --- a/src/servers/api/workspaces/index.ts +++ b/src/servers/api/dashboards/index.ts @@ -1,3 +1,3 @@ -export * from './workspaces'; +export * from './dashboards'; export * from './types'; export * from './utils'; diff --git a/src/servers/api/dashboards/types.ts b/src/servers/api/dashboards/types.ts new file mode 100644 index 00000000..722c0920 --- /dev/null +++ b/src/servers/api/dashboards/types.ts @@ -0,0 +1,3 @@ +export type KeyMapping = { file: string; dir?: string }; + +export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string }; diff --git a/src/servers/api/workspaces/utils.ts b/src/servers/api/dashboards/utils.ts similarity index 80% rename from src/servers/api/workspaces/utils.ts rename to src/servers/api/dashboards/utils.ts index 9a8ba01f..572c8e8c 100644 --- a/src/servers/api/workspaces/utils.ts +++ b/src/servers/api/dashboards/utils.ts @@ -1,14 +1,14 @@ import { mkdir, readdir, rename, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile, getUserProjectsDir } from '@@/data-path'; +import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path'; import type { KeyMapping, ResolveDirs } from './types'; const RESERVED_DIRS = new Set(['screens']); export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null { - if (key === 'workspaces') return { file: join(dirs.wsDir, 'index.json') }; - if (key === 'ws-terminals-default') return { file: join(dirs.wsDir, 'default-terminals.json') }; - if (key === 'ws-host-terminals-default') return { file: join(dirs.wsDir, 'default-host-terminals.json') }; + if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') }; + if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') }; + if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') }; // screens/{name} → screens/{name}/layout.json const screensMatch = key.match(/^screens\/(.+)$/); @@ -21,21 +21,21 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null { const layoutMatch = key.match(/^ws-layout-(.+)$/); if (layoutMatch) { const id = layoutMatch[1]!; - const dir = join(dirs.wsDir, id); + const dir = join(dirs.dashDir, id); return { file: join(dir, 'layout.json'), dir }; } const terminalsMatch = key.match(/^ws-terminals-(.+)$/); if (terminalsMatch) { const id = terminalsMatch[1]!; - const dir = join(dirs.wsDir, id); + const dir = join(dirs.dashDir, id); return { file: join(dir, 'terminals.json'), dir }; } const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/); if (hostTerminalsMatch) { const id = hostTerminalsMatch[1]!; - const dir = join(dirs.wsDir, id); + const dir = join(dirs.dashDir, id); return { file: join(dir, 'host-terminals.json'), dir }; } @@ -97,7 +97,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) { ); if (wsKeys.length === 0) return; - await mkdir(dirs.wsDir, { recursive: true }); + await mkdir(dirs.dashDir, { recursive: true }); for (const key of wsKeys) { const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key; @@ -113,8 +113,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) { } export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) { - // Migrate from old ws-homepage/ dir to workspaces/screens/homepage/ - const oldHomepageDir = getUserHomepageWorkspaceDir(email); + const oldHomepageDir = getUserHomepageDashboardDir(email); const layoutFile = join(oldHomepageDir, 'layout.json'); if (!(await Bun.file(layoutFile).exists())) return; @@ -131,8 +130,7 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) const remaining = await readdir(oldHomepageDir); if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true }); - // Also migrate from workspaces/ws-homepage/ if it exists - const oldWsHomepageDir = join(dirs.wsDir, 'ws-homepage'); + const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage'); const oldWsLayout = join(oldWsHomepageDir, 'layout.json'); if (!(await Bun.file(oldWsLayout).exists())) return; @@ -148,7 +146,24 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true }); } -async function readWorkspaceDir(dirPath: string, id: string, result: Record) { +// Migrate on-disk directory from old 'workspaces' name to 'dashboards' +export async function migrateDashboardsDir(email: string) { + const oldDir = join(DATA_PATH, email, 'workspaces'); + const newDir = getUserDashboardsDir(email); + try { + const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false); + if (oldExists) { + const newExists = await readdir(newDir).then(() => true).catch(() => false); + if (!newExists) { + await rename(oldDir, newDir); + } + } + } catch { + // ignore — old dir doesn't exist + } +} + +async function readDashboardDir(dirPath: string, id: string, result: Record) { const layout = await readJsonFile(join(dirPath, 'layout.json')); if (layout !== null) result[`ws-layout-${id}`] = layout; @@ -191,32 +206,32 @@ async function readScreensDir(screensDir: string, result: Record> { +export async function readAllDashboardsState(dirs: ResolveDirs): Promise> { const result: Record = {}; - const indexData = await readJsonFile(join(dirs.wsDir, 'index.json')); + const indexData = await readJsonFile(join(dirs.dashDir, 'index.json')); if (indexData !== null) result['workspaces'] = indexData; - const defaultTerminals = await readJsonFile(join(dirs.wsDir, 'default-terminals.json')); + const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json')); if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals; - const defaultHostTerminals = await readJsonFile(join(dirs.wsDir, 'default-host-terminals.json')); + const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json')); if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals; // Read screens await readScreensDir(dirs.screensDir, result); - // Read per-workspace subdirs + // Read per-dashboard subdirs let entries: import('node:fs').Dirent[] = []; try { - entries = await readdir(dirs.wsDir, { withFileTypes: true }); + entries = await readdir(dirs.dashDir, { withFileTypes: true }); } catch { return result; } for (const entry of entries) { if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue; - await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result); + await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result); } // Read project data — scan directories containing .officerdev/meta.json @@ -241,8 +256,8 @@ export async function readAllWorkspacesState(dirs: ResolveDirs): Promise { +async function getUserTtsVoice(userId: number): Promise { try { - const settingsFile = Bun.file(getUserSettingsFile(email)); - if (await settingsFile.exists()) { - const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } }; - return settings.tts?.voice ?? null; - } + const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } }; + return settings.tts?.voice ?? null; } catch {} return null; } @@ -387,7 +385,7 @@ router.post('/tts', async (ctx) => { const ttsConfig = await readTtsConfig(); if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech'); - const userVoice = await getUserTtsVoice(user.email); + const userVoice = await getUserTtsVoice(user.id); const voice = userVoice ?? ttsConfig.voice; const userDataDir = getUserDataDir(user.email); @@ -439,7 +437,7 @@ router.post('/tts-text', async (ctx) => { const ttsConfig = await readTtsConfig(); if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech'); - const userVoice = await getUserTtsVoice(user.email); + const userVoice = await getUserTtsVoice(user.id); const voice = userVoice ?? ttsConfig.voice; const userDataDir = getUserDataDir(user.email); @@ -906,16 +904,18 @@ router.post('/download-video', async (ctx) => { if (!path) throw errors.BAD_REQUEST('path is required'); const absPath = resolveUserPath(rootDir, path); - const args = ['yt-dlp', '-o', '%(title)s.%(ext)s']; + await mkdir(absPath, { recursive: true }); + const cookiesPath = join(DATA_PATH, '..', 'yt-dlp-cookies.txt'); + const args = ['yt-dlp', '--js-runtimes', 'bun', '--cookies', cookiesPath, '-o', '%(title)s.%(ext)s']; if (audioOnly) args.push('-x', '--audio-format', 'mp3'); args.push(url); - const proc = Bun.spawn(args, { cwd: absPath, stdout: 'pipe', stderr: 'pipe' }); + const proc = Bun.spawn(args, { cwd: absPath, stdout: 'ignore', stderr: 'pipe' }); + const stderrText = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - throw errors.BAD_REQUEST(stderr.trim() || 'yt-dlp download failed'); + throw errors.BAD_REQUEST(stderrText.trim() || 'yt-dlp download failed'); } return ctx.json({ ok: true }); @@ -932,12 +932,12 @@ router.post('/git-clone', async (ctx) => { const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); - const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' }); + const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'ignore', stderr: 'pipe' }); + const stderrText = await new Response(proc.stderr).text(); const exitCode = await proc.exited; if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed'); + throw errors.BAD_REQUEST(stderrText.trim() || 'git clone failed'); } return ctx.json({ ok: true }); diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts index f2cf8bf4..e8b3b75d 100644 --- a/src/servers/api/integrations/integrations.ts +++ b/src/servers/api/integrations/integrations.ts @@ -12,7 +12,7 @@ import { } from 'officerdb'; const GOOGLE_SCOPES = [ - 'https://www.googleapis.com/auth/gmail.readonly', + 'https://mail.google.com/', 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.email', ]; @@ -131,6 +131,15 @@ integrationsRouter.get('/google/status', async (ctx) => { integrationsRouter.delete('/google/connection', async (ctx) => { const user = ctx.get('user'); + const connection = await getUserIntegration(user.id, 'google'); + const connConfig = connection?.config as Record | undefined; + + // Revoke token at Google so the old grant is fully removed + const token = (connConfig?.refreshToken as string) || (connConfig?.accessToken as string); + if (token) { + fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, { method: 'POST' }).catch(() => {}); + } + await deleteUserIntegration(user.id, 'google'); return ctx.json({ ok: true }); }); @@ -157,6 +166,7 @@ integrationsRouter.get('/google/authorize', async (ctx) => { scope: GOOGLE_SCOPES.join(' '), access_type: 'offline', prompt: 'consent', + include_granted_scopes: 'false', state, }); @@ -244,7 +254,7 @@ export const googleCallbackHandler = async (ctx: any) => { // Auto-add /email to dock try { const existing = await getDockPaths(dbUser.id); - const paths = existing ?? ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; + const paths = existing ?? ['/', '/files', '/automation', '/projects', '/dashboards', '/chat']; if (!paths.includes('/email')) { paths.push('/email'); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 2375532b..1c2b160c 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -4,7 +4,7 @@ import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readSearxngConfig } from "../server-settings/searxng"; -import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path"; +import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path"; import { ensureDockerContainer } from "../terminal/websocket"; import { getServerIntegration, getUserIntegration } from "officerdb"; import { logger } from "./logger"; @@ -161,34 +161,6 @@ function buildResourcesEnv(): string { return JSON.stringify(result); } -async function ensureGoogleConfigFile(): Promise { - const filePath = join(SERVER_CONFIG_DIR, 'google-oauth.json'); - try { - const integration = await getServerIntegration('google'); - if (integration?.config) { - mkdirSync(SERVER_CONFIG_DIR, { recursive: true }); - writeFileSync(filePath, JSON.stringify(integration.config, null, 2)); - } - } catch { - // No google config available - } - return filePath; -} - -async function ensureGoogleTokenFile(userId: number, email: string): Promise { - const dir = join(DATA_PATH, email, 'integrations'); - const filePath = join(dir, 'google.json'); - try { - const integration = await getUserIntegration(userId, 'google'); - if (integration?.config) { - mkdirSync(dir, { recursive: true }); - writeFileSync(filePath, JSON.stringify(integration.config, null, 2)); - } - } catch { - // No user google integration available - } - return filePath; -} async function getApifyToken(): Promise { try { @@ -217,6 +189,19 @@ async function getBrowserRelayEnv(userId: number): Promise { + const provider = model.split('/')[0]; + if (!provider) return null; + try { + const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json')); + if (!(await authFile.exists())) return null; + const auth = await authFile.json() as Record; + return auth[provider]?.key?.trim() || null; + } catch { + return null; + } +} + type SandboxOptions = { userId: number; username: string; @@ -270,10 +255,12 @@ export async function spawnPi( if (model) piArgs.push('--model', model); if (options?.sessionFile) piArgs.push('--session', options.sessionFile); + // Pass API key for the model's provider so the container doesn't need auth.json + const apiKey = await resolveApiKeyForModel(model); + if (apiKey) piArgs.push('--api-key', apiKey); + const resourcesEnv = buildResourcesEnv(); - const googleConfigHost = await ensureGoogleConfigFile(); - await ensureGoogleTokenFile(sandbox.userId, sandbox.email); const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId); const apifyToken = await getApifyToken(); @@ -284,8 +271,6 @@ export async function spawnPi( '-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`, '-e', `PI_SEARXNG_URL=${searxng.url}`, '-e', `OFFICER_RESOURCES=${resourcesEnv}`, - '-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`, - '-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`, '-e', `OFFICER_EMAIL_DB=/officer/emails.db`, ...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []), ...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]), @@ -330,8 +315,6 @@ export async function spawnPi( } const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); - const googleConfigPath = await ensureGoogleConfigFile(); - const googleTokenPath = await ensureGoogleTokenFile(userId, email); const browserRelayEnv = await getBrowserRelayEnv(userId); const apifyTokenLocal = await getApifyToken(); @@ -349,8 +332,6 @@ export async function spawnPi( PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), - OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath, - OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath, OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), ...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}), ...browserRelayEnv, diff --git a/src/servers/api/pi/storage.ts b/src/servers/api/pi/storage.ts index cff473ad..83ed58c6 100644 --- a/src/servers/api/pi/storage.ts +++ b/src/servers/api/pi/storage.ts @@ -533,6 +533,9 @@ export async function listUserSessions(baseCwd: string, filter?: SessionFilter): if (filter.context === 'chat') { // 'chat' matches sessions with no context or context='chat' sessions = sessions.filter((s) => !s.context || s.context === 'chat'); + } else if (filter.context === 'dashboard') { + // 'dashboard' matches both old 'workspace' and new 'dashboard' context + sessions = sessions.filter((s) => (s.context === 'dashboard' || s.context === 'workspace') && s.contextId === filter.contextId); } else { sessions = sessions.filter((s) => s.context === filter.context && s.contextId === filter.contextId); } diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts index dfc4f790..e0371e51 100644 --- a/src/servers/api/server-settings/pi-mono.ts +++ b/src/servers/api/server-settings/pi-mono.ts @@ -350,6 +350,7 @@ export const PROVIDERS: { key: string; piId: string }[] = [ { key: 'MiniMax', piId: 'minimax' }, { key: 'Hugging Face', piId: 'huggingface' }, { key: 'Azure OpenAI', piId: 'azure-openai-responses' }, + { key: 'OpenCode', piId: 'opencode' }, { key: 'OpenCode Zen', piId: 'zai' }, { key: 'Cerebras', piId: 'cerebras' }, ]; @@ -414,6 +415,7 @@ const REMOTE_HEALTH_CONFIG: Record ({ Authorization: `Bearer ${k}` }) }, openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, + opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, }; diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index cbbf7f7d..570200d7 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -3,8 +3,8 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path'; -import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb'; +import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path'; +import { getUsers } from 'officerdb'; const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; }; @@ -140,32 +140,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number, const containerHome = `/home/${username}`; - // Write google-oauth config from DB to files for Docker mount - const googleConfigHost = join(SERVER_CONFIG_DIR, 'google-oauth.json'); - let googleMounts: string[] = []; - try { - const googleIntegration = await getServerIntegration('google'); - if (googleIntegration?.config) { - mkdirSync(SERVER_CONFIG_DIR, { recursive: true }); - writeFileSync(googleConfigHost, JSON.stringify(googleIntegration.config, null, 2)); - googleMounts = ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`]; - } - } catch { - // No google config — skip mount - } - - // Write per-user google token from DB for Docker mount - const userIntegrationsDir = join(DATA_PATH, email, 'integrations'); - try { - const userGoogle = await getUserIntegration(userId, 'google'); - if (userGoogle?.config) { - mkdirSync(userIntegrationsDir, { recursive: true }); - writeFileSync(join(userIntegrationsDir, 'google.json'), JSON.stringify(userGoogle.config, null, 2)); - } - } catch { - // No user google integration — skip - } - const run = Bun.spawnSync({ cmd: [ dockerPath, @@ -196,8 +170,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number, '-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`, '-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`, '-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`, - ...googleMounts, - '-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`, '-v', `${join(DATA_PATH, email, 'emails.db')}:/officer/emails.db`, '-w', containerHome, tag, @@ -207,7 +179,13 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number, }); if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container'); - return { dockerId }; + + // Wait for entrypoint to finish (user creation, sidecar start) + for (let i = 0; i < 20; i++) { + await new Promise((r) => setTimeout(r, 500)); + if (await sidecarAlive(port)) return { dockerId }; + } + throw new Error('Terminal sidecar did not start in time'); }; const stopDockerSidecar = (dockerId: string) => { @@ -263,8 +241,19 @@ const dockerStart = (dockerId: string) => { }; export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => { + // Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root) + // Must check BEFORE mkdirSync overwrites them + const skillsDir = getUserSkillsDir(email); + let stale = false; + try { + const s = statSync(skillsDir); + if (s.uid === 0) stale = true; + } catch { + // doesn't exist yet — not stale, will be created below + } + // Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing) - mkdirSync(getUserSkillsDir(email), { recursive: true }); + mkdirSync(skillsDir, { recursive: true }); mkdirSync(getUserToolsDir(email), { recursive: true }); mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true }); @@ -278,9 +267,9 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD const existing = map[email]; if (existing && dockerContainerRunning(existing.dockerId)) { - // Recreate if resource mounts are missing (e.g. first run after feature was added) - if (!containerHasExpectedMounts(existing.dockerId)) { - console.log(`[terminal] recreating container for ${email} — resource mounts missing`); + // Recreate if resource mounts are missing or data dir was recreated (stale mounts) + if (!containerHasExpectedMounts(existing.dockerId) || stale) { + console.log(`[terminal] recreating container for ${email} — mounts stale or missing`); stopDockerSidecar(existing.dockerId); } else { console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`); @@ -289,7 +278,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD } if (existing && dockerContainerExists(existing.dockerId)) { - if (!containerHasExpectedMounts(existing.dockerId)) { + if (!containerHasExpectedMounts(existing.dockerId) || stale) { stopDockerSidecar(existing.dockerId); } else if (dockerStart(existing.dockerId)) { return existing; @@ -366,7 +355,7 @@ export const initTerminalSidecars = async () => { mkdirSync(dirname(homeDir), { recursive: true }); mkdirSync(homeDir, { recursive: true }); try { - await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!); + await ensureDockerContainer(user.email, user.id, homeDir, toShellUsername(user.username ?? '', user.email)); console.log(`[terminal] sidecar ready for ${user.email}`); } catch (err) { console.error(`[terminal] failed to start sidecar for ${user.email}:`, err); diff --git a/src/servers/api/workspaces/types.ts b/src/servers/api/workspaces/types.ts deleted file mode 100644 index 21f43768..00000000 --- a/src/servers/api/workspaces/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type KeyMapping = { file: string; dir?: string }; - -export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string }; diff --git a/src/servers/channels/discord/handler.ts b/src/servers/channels/discord/handler.ts index 8632ad50..0a0b7344 100644 --- a/src/servers/channels/discord/handler.ts +++ b/src/servers/channels/discord/handler.ts @@ -8,6 +8,7 @@ import { enqueue } from '@@/queue/engine'; import { readJob } from '@@/queue/storage'; import { openEmailDb } from '@@/api/email/email-db'; import type { ModelInfo } from '@@/api/pi/types'; +import { toShellUsername } from '@@/data-path'; const PAIRING_CODE_PATTERN = /^[A-Z0-9]{6}$/; const TYPING_INTERVAL_MS = 8_000; @@ -255,7 +256,7 @@ export async function handleDiscordMessage(message: DiscordMessage): Promise { const result = await sendAndAwait({ userId: linked.user.id, email: linked.user.email, - username: linked.user.username ?? linked.user.email.split('@')[0]!, + username: toShellUsername(linked.user.username ?? '', linked.user.email), prompt: content, context: 'whatsapp', contextId: phone, diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 97cfc6ff..8742b107 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -41,11 +41,11 @@ export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state' export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json'); -export const getUserWorkspacesDir = (email: string) => join(DATA_PATH, email, 'workspaces'); +export const getUserDashboardsDir = (email: string) => join(DATA_PATH, email, 'dashboards'); export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects'); -export const getUserHomepageWorkspaceDir = (email: string) => join(DATA_PATH, email, 'ws-homepage'); +export const getUserHomepageDashboardDir = (email: string) => join(DATA_PATH, email, 'ws-homepage'); export const getNativeSkillsDir = () => join(SEED_PATH, 'skills'); @@ -93,4 +93,11 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails'); +/** Derive a valid Linux username from a display username or email. */ +export const toShellUsername = (username: string, email: string): string => { + const raw = username || email.split('@')[0]!; + // Replace invalid chars, lowercase, truncate to 32 chars + return raw.replace(/@.*$/, '').replace(/[^a-zA-Z0-9._-]/g, '_').toLowerCase().slice(0, 32) || 'officer'; +}; + export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 25b114ea..57709d86 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -16,7 +16,7 @@ import { sessionsRouter } from './api/sessions/sessions'; import { scrapeRouter } from './api/scrape/scrape'; import { uploadRouter } from './api/upload/upload'; import { settingsRouter } from './api/settings/settings'; -import { workspacesRouter } from './api/workspaces'; +import { dashboardsRouter } from './api/dashboards'; import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { piRestRouter } from './api/pi/rest'; @@ -78,7 +78,7 @@ protectedRouter.route('/', sessionsRouter); protectedRouter.route('/scrape', scrapeRouter); protectedRouter.route('/upload', uploadRouter); protectedRouter.route('/user', settingsRouter); -protectedRouter.route('/workspaces', workspacesRouter); +protectedRouter.route('/dashboards', dashboardsRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/dev-server', devServerRouter); diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index cda4dd8d..7438bd14 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -1,8 +1,8 @@ import type { Database } from 'bun:sqlite'; +import { ImapFlow } from 'imapflow'; import type { JobHandler } from '../types'; import { registerHandler } from '../handler-registry'; -import { DATA_PATH } from '../../data-path'; -import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta, updateEmailLabels } from '../../api/email/email-db'; +import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta } from '../../api/email/email-db'; import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb'; type GoogleCredentials = { @@ -76,267 +76,145 @@ async function getValidAccessToken(creds: GoogleCredentials): Promise { return data.access_token; } -const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; +// ── IMAP label mapping ── -async function gmailGet(token: string, path: string, params?: Record): Promise { - const url = new URL(`${GMAIL_BASE}${path}`); - if (params) { - for (const [k, v] of Object.entries(params)) { - if (v) url.searchParams.set(k, v); - } - } - - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!res.ok) { - const error = await res.text().catch(() => ''); - throw new Error(`Gmail API error (${res.status}): ${error}`); - } - - return res.json(); -} - -// ── Pre-flight message count ── - -async function countMessages(token: string, query?: string): Promise { - let count = 0; - let pageToken: string | undefined; - do { - const params: Record = { maxResults: '500' }; - if (query) params.q = query; - if (pageToken) params.pageToken = pageToken; - const list = (await gmailGet(token, '/messages', params)) as { - messages?: Array<{ id: string }>; - nextPageToken?: string; - }; - count += list.messages?.length ?? 0; - if (!list.messages?.length) break; - pageToken = list.nextPageToken; - } while (pageToken); - return count; -} - -// ── Full sync: Gmail API → SQLite directly ── - -type SyncProgress = { saved: number; skipped: number; errors: number; page: number }; -type OnProgress = (progress: SyncProgress) => void; - -type SyncResult = { - saved: number; - skipped: number; - errors: number; - labelMap: Map; - maxHistoryId: string | null; +const SYSTEM_LABEL_MAP: Record = { + '\\Inbox': 'inbox', + '\\Sent': 'sent', + '\\Trash': 'trash', + '\\Spam': 'spam', + '\\Draft': 'draft', + '\\Starred': 'starred', + '\\Important': 'important', }; -async function syncInbox( - token: string, - db: Database, +function mapImapLabels(labels: Set | undefined): string[] { + if (!labels) return []; + const mapped: string[] = []; + for (const label of labels) { + const system = SYSTEM_LABEL_MAP[label]; + if (system) { + mapped.push(system); + } else { + mapped.push(label.toLowerCase()); + } + } + return mapped; +} + +// ── IMAP sync ── + +type ImapSyncResult = { saved: number; skipped: number; errors: number }; + +// Mailboxes to sync: All Mail has everything except Trash and Spam +const SYNC_SPECIAL_USE = ['\\All', '\\Trash', '\\Junk']; + +async function syncViaImap( + accessToken: string, emailAccount: string, - query?: string, - onProgress?: OnProgress, -): Promise { - // Dedup via DB - const existingIds = new Set(); - const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>; - for (const row of rows) existingIds.add(row.id); + db: Database, + since: Date | null, + onProgress?: (saved: number, skipped: number) => void, +): Promise { + const client = new ImapFlow({ + host: 'imap.gmail.com', + port: 993, + secure: true, + auth: { user: emailAccount, accessToken }, + logger: false, + }); + + try { + await client.connect(); + } catch (err) { + console.error('[gmail-sync] IMAP connect failed:', err); + throw err; + } let saved = 0; let skipped = 0; let errors = 0; - let page = 0; - let pageToken: string | undefined; - const labelMap = new Map(); - let maxHistoryId: bigint | null = null; - do { - const params: Record = { maxResults: '100' }; - if (query) params.q = query; - if (pageToken) params.pageToken = pageToken; + // Load existing IDs for dedup (once, shared across mailboxes) + const existingIds = new Set(); + const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>; + for (const row of rows) existingIds.add(row.id); - const list = (await gmailGet(token, '/messages', params)) as { - messages?: Array<{ id: string }>; - nextPageToken?: string; - }; + try { + // Find mailboxes by specialUse flag (locale-independent) + const allMailboxes = await client.list(); + const toSync: Array<{ path: string; specialUse: string }> = []; + for (const mailbox of allMailboxes) { + if (mailbox.specialUse && SYNC_SPECIAL_USE.includes(mailbox.specialUse)) { + toSync.push({ path: mailbox.path, specialUse: mailbox.specialUse }); + } + } - const messages = list.messages ?? []; - if (messages.length === 0) break; + if (toSync.length === 0) { + console.error('[gmail-sync] No mailboxes found to sync'); + return { saved, skipped, errors }; + } - for (let i = 0; i < messages.length; i += 5) { - const batch = messages.slice(i, i + 5); - await Promise.all( - batch.map(async ({ id }) => { - if (existingIds.has(id)) { - skipped++; - return; - } + for (const mailbox of toSync) { + console.log(`[gmail-sync] Opening ${mailbox.path} (${mailbox.specialUse})...`); + const lock = await client.getMailboxLock(mailbox.path); + try { + const searchCriteria = since ? { since } : { all: true }; + const uids = await client.search(searchCriteria, { uid: true }); + + if (!uids || uids.length === 0) { + console.log(`[gmail-sync] ${mailbox.path}: no messages`); + continue; + } + + console.log(`[gmail-sync] ${mailbox.path}: ${uids.length} messages`); + + const uidRange = uids.join(','); + const messages = client.fetch(uidRange, { + source: true, + labels: true, + }, { uid: true }); + + for await (const msg of messages) { try { - const msg = (await gmailGet(token, `/messages/${id}`, { format: 'raw' })) as { - id: string; - internalDate?: string; - raw: string; - labelIds?: string[]; - historyId?: string; - }; - const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8'); - upsertFromRawEml({ db, id, raw: rawEmail, integration: 'gmail', emailAccount, labels: msg.labelIds }); - existingIds.add(id); + if (!msg.emailId || !msg.source) continue; + + const gmailId = BigInt(msg.emailId).toString(16); + + if (existingIds.has(gmailId)) { + skipped++; + if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped); + continue; + } + + const rawEmail = msg.source.toString('utf-8'); + const labels = mapImapLabels(msg.labels); + + upsertFromRawEml({ db, id: gmailId, raw: rawEmail, integration: 'gmail', emailAccount, labels }); + existingIds.add(gmailId); saved++; - if (msg.labelIds) labelMap.set(id, msg.labelIds); - if (msg.historyId) { - const hid = BigInt(msg.historyId); - if (maxHistoryId === null || hid > maxHistoryId) maxHistoryId = hid; + if ((saved + skipped) % 100 === 0) { + console.log(`[gmail-sync] Progress: saved ${saved}, skipped ${skipped}, errors ${errors}`); + onProgress?.(saved, skipped); } } catch { errors++; } - }), - ); - } - - page++; - console.log(`[gmail-sync] Page ${page}: saved ${saved}, skipped ${skipped}, errors ${errors}`); - onProgress?.({ saved, skipped, errors, page }); - - pageToken = list.nextPageToken; - } while (pageToken); - - return { saved, skipped, errors, labelMap, maxHistoryId: maxHistoryId !== null ? String(maxHistoryId) : null }; -} - -// ── Gmail History API (incremental sync) ── - -type HistoryMessage = { id: string; labelIds?: string[] }; - -type HistoryRecord = { - id: string; - messagesAdded?: Array<{ message: HistoryMessage }>; - messagesDeleted?: Array<{ message: HistoryMessage }>; - labelsAdded?: Array<{ message: HistoryMessage; labelIds: string[] }>; - labelsRemoved?: Array<{ message: HistoryMessage; labelIds: string[] }>; -}; - -type HistoryResponse = { - history?: HistoryRecord[]; - nextPageToken?: string; - historyId: string; -}; - -type IncrementalResult = - | { stale: false; added: number; deleted: number; relabeled: number; maxHistoryId: string } - | { stale: true }; - -async function syncIncremental( - token: string, - db: Database, - lastHistoryId: string, - emailAccount: string, -): Promise { - let pageToken: string | undefined; - let added = 0; - let deleted = 0; - let relabeled = 0; - let latestHistoryId = lastHistoryId; - - do { - const url = new URL(`${GMAIL_BASE}/history`); - url.searchParams.set('startHistoryId', lastHistoryId); - for (const ht of ['messageAdded', 'messageDeleted', 'labelAdded', 'labelRemoved']) { - url.searchParams.append('historyTypes', ht); - } - if (pageToken) url.searchParams.set('pageToken', pageToken); - - let data: HistoryResponse; - try { - const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` } }); - if (!res.ok) { - const error = await res.text().catch(() => ''); - throw new Error(`Gmail API error (${res.status}): ${error}`); - } - data = (await res.json()) as HistoryResponse; - } catch (err) { - // historyId too old — Gmail returns 404 - if (err instanceof Error && err.message.includes('404')) { - return { stale: true }; - } - throw err; - } - - latestHistoryId = data.historyId; - - for (const record of data.history ?? []) { - // New messages - for (const { message } of record.messagesAdded ?? []) { - try { - const msg = (await gmailGet(token, `/messages/${message.id}`, { format: 'raw' })) as { - id: string; - internalDate?: string; - raw: string; - labelIds?: string[]; - historyId?: string; - }; - const rawEmail = Buffer.from(msg.raw, 'base64url').toString('utf-8'); - upsertFromRawEml({ db, id: msg.id, raw: rawEmail, integration: 'gmail', emailAccount, labels: msg.labelIds }); - added++; - } catch { - /* skip individual failures */ - } - } - - // Deleted messages - for (const { message } of record.messagesDeleted ?? []) { - db.run('UPDATE emails SET deleted = 1 WHERE id = ?', [message.id]); - deleted++; - } - - // Labels added - for (const { message, labelIds } of record.labelsAdded ?? []) { - const row = db.query('SELECT labels FROM emails WHERE id = ?').get(message.id) as { labels: string | null } | null; - if (row) { - const existing = row.labels ? row.labels.split(',') : []; - const merged = [...new Set([...existing, ...labelIds.map((l) => l.toLowerCase())])]; - updateEmailLabels(db, message.id, merged); - relabeled++; - } - } - - // Labels removed - for (const { message, labelIds } of record.labelsRemoved ?? []) { - const row = db.query('SELECT labels FROM emails WHERE id = ?').get(message.id) as { labels: string | null } | null; - if (row) { - const removeSet = new Set(labelIds.map((l) => l.toLowerCase())); - const remaining = (row.labels ? row.labels.split(',') : []).filter((l) => !removeSet.has(l)); - updateEmailLabels(db, message.id, remaining); - relabeled++; } + } finally { + lock.release(); } } - - pageToken = data.nextPageToken; - } while (pageToken); - - return { stale: false, added, deleted, relabeled, maxHistoryId: latestHistoryId }; -} - -const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - -function buildMonthRanges(year: number): Array<{ label: string; after: string; before: string }> { - const now = new Date(); - const currentMonth = now.getFullYear() === year ? now.getMonth() : 11; - const ranges: Array<{ label: string; after: string; before: string }> = []; - - for (let m = 0; m <= currentMonth; m++) { - const after = `${year}/${m + 1}/1`; - const before = m < 11 ? `${year}/${m + 2}/1` : `${year + 1}/1/1`; - ranges.push({ label: `${MONTH_NAMES[m]!} ${year}`, after, before }); + } finally { + await client.logout(); } - return ranges; + return { saved, skipped, errors }; } +// ── Handler ── + const gmailSyncHandler: JobHandler = { type: 'gmail-sync', steps: [ @@ -356,102 +234,42 @@ const gmailSyncHandler: JobHandler = { const db = openEmailDb(ctx.job.userId); try { - // Try incremental sync first (only for non-year-scoped syncs) - if (!year) { - const lastHistoryId = getSyncMeta(db, 'last_history_id'); - if (lastHistoryId) { - await ctx.updateProgress({ current: 0, total: 0, label: 'Incremental sync' }); - console.log(`[gmail-sync] Attempting incremental sync from historyId ${lastHistoryId}`); - const result = await syncIncremental(token, db, lastHistoryId, ctx.job.userId); - if (!result.stale) { - setSyncMeta(db, 'last_history_id', result.maxHistoryId); - setSyncMeta(db, 'last_sync_at', new Date().toISOString()); - console.log(`[gmail-sync] Incremental: +${result.added} added, -${result.deleted} deleted, ~${result.relabeled} relabeled`); - await ctx.updateProgress({ current: 1, total: 1, label: 'Done (incremental)' }); - return; - } - console.log('[gmail-sync] historyId stale, falling back to full sync'); + // Bootstrap sync_meta from existing emails if DB was imported without metadata + if (!getSyncMeta(db, 'last_sync_date')) { + const newest = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null; + if (newest?.date) { + console.log(`[gmail-sync] Bootstrapping last_sync_date from existing DB: ${newest.date}`); + setSyncMeta(db, 'last_sync_date', newest.date); } } - // Full sync - let totalSaved = 0; - let totalSkipped = 0; - let totalErrors = 0; - let allLabelMaps: Map = new Map(); - let maxHistoryId: string | null = null; - + // Determine since date + let since: Date | null = null; if (year) { - // Year-scoped sync: count total emails first, then sync month by month - const yearQuery = `after:${year}/1/1 before:${year + 1}/1/1`; - await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' }); - const totalEmails = await countMessages(token, yearQuery); - console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails for ${year}`); - - const months = buildMonthRanges(year); - for (let i = 0; i < months.length; i++) { - const month = months[i]!; - await ctx.updateProgress({ current: totalSaved + totalSkipped, total: totalEmails, label: month.label }); - const query = `after:${month.after} before:${month.before}`; - const result = await syncInbox(token, db, ctx.job.userId, query, (p) => { - const current = totalSaved + p.saved + p.skipped + p.errors; - const label = `${month.label} — Saved ${(totalSaved + p.saved).toLocaleString()} of ${totalEmails.toLocaleString()}`; - ctx.updateProgress({ current, total: totalEmails, label }); - }); - totalSaved += result.saved; - totalSkipped += result.skipped; - totalErrors += result.errors; - for (const [id, labels] of result.labelMap) allLabelMaps.set(id, labels); - if (result.maxHistoryId) { - if (!maxHistoryId || BigInt(result.maxHistoryId) > BigInt(maxHistoryId)) { - maxHistoryId = result.maxHistoryId; - } - } - } - await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' }); + since = new Date(year, 0, 1); } else { const lastSyncDate = getSyncMeta(db, 'last_sync_date'); - let syncQuery: string | undefined; if (lastSyncDate) { - const d = new Date(lastSyncDate); - syncQuery = `after:${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; - console.log(`[gmail-sync] Scoping full sync with query: ${syncQuery}`); + since = new Date(lastSyncDate); + console.log(`[gmail-sync] Syncing since ${since.toISOString()}`); } - - // Count total emails for accurate progress - await ctx.updateProgress({ current: 0, total: 0, label: 'Counting emails...' }); - const totalEmails = await countMessages(token, syncQuery); - console.log(`[gmail-sync] Pre-flight: ${totalEmails} emails`); - - await ctx.updateProgress({ current: 0, total: totalEmails, label: 'Starting sync' }); - const result = await syncInbox(token, db, ctx.job.userId, syncQuery, (p) => { - const current = p.saved + p.skipped + p.errors; - const label = `Saved ${p.saved.toLocaleString()} of ${totalEmails.toLocaleString()}`; - ctx.updateProgress({ current, total: totalEmails, label }); - }); - totalSaved = result.saved; - totalSkipped = result.skipped; - totalErrors = result.errors; - allLabelMaps = result.labelMap; - maxHistoryId = result.maxHistoryId; - await ctx.updateProgress({ current: totalEmails, total: totalEmails, label: 'Done' }); } - console.log(`[gmail-sync] Full: saved ${totalSaved}, skipped ${totalSkipped}, errors ${totalErrors}`); + await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting via IMAP...' }); - // Update labels for emails that were skipped but have new label data - let labelsUpdated = 0; - for (const [id, labels] of allLabelMaps) { - updateEmailLabels(db, id, labels); - labelsUpdated++; - } - if (labelsUpdated > 0) console.log(`[gmail-sync] Updated labels for ${labelsUpdated} emails`); + const result = await syncViaImap(token, ctx.job.userId, db, since, (saved, skipped) => { + const label = year + ? `${year} — Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}` + : `Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`; + ctx.updateProgress({ current: saved + skipped, total: 0, label }); + }); - // Store sync state - if (maxHistoryId) { - setSyncMeta(db, 'last_history_id', maxHistoryId); - } + console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`); + + setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!); setSyncMeta(db, 'last_sync_at', new Date().toISOString()); + + await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` }); } finally { db.close(); } diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 34ef31c1..c3efa382 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -4,7 +4,7 @@ import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; import { appRegistryMetas as chatMetas } from '../apps/Chat'; import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; -import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces'; +import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as projectMetas } from '../apps/Projects'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as previewMetas } from '../apps/Preview'; @@ -15,7 +15,7 @@ import { createUserAppPanel } from '../apps/UserApp/UserAppPanel'; import { createUserAppHeader } from '../apps/UserApp/UserAppHeader'; import { resolveIcon } from '../utils/resolve-icon'; -const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas]; +const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas]; export const AppRegistry = () => { const { registerApp } = useAppRegistry(apps); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index 14c7df17..e51a29fd 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { getProviderDisplayName } from 'state/useModels'; type ChatSessionSelection = { sessionId: string | null; model?: string | null; - workspaceId?: string; + dashboardId?: string; }; function formatModel(model: string): string { @@ -22,13 +22,13 @@ function formatModel(model: string): string { } export const ChatHeader = () => { - const { workspaceId } = useWorkspace(); - const contextFilter = workspaceId === 'email' || workspaceId === 'screens/email' + const { dashboardId } = useWorkspace(); + const contextFilter = dashboardId === 'email' || dashboardId === 'screens/email' ? { context: 'email' as const } - : workspaceId?.startsWith('proj-layout-') - ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } - : workspaceId && !workspaceId.startsWith('screens/') - ? { context: 'workspace' as const, contextId: workspaceId } + : dashboardId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') } + : dashboardId && !dashboardId.startsWith('screens/') + ? { context: 'dashboard' as const, contextId: dashboardId } : undefined; const { sessions, clearSessions } = useChatSessions(contextFilter); const [selection, setSelection] = usePanelChannel('chat:panel-session', null); @@ -38,25 +38,25 @@ export const ChatHeader = () => { // Clear stale selection from a different workspace useEffect(() => { - if (selection && selection.workspaceId !== workspaceId) { + if (selection && selection.dashboardId !== dashboardId) { setSelection(null); autoResumedRef.current = false; } - }, [workspaceId, selection]); + }, [dashboardId, selection]); // Auto-resume the latest session for this workspace useEffect(() => { if (autoResumedRef.current) return; - if (selection && selection.workspaceId === workspaceId) return; + if (selection && selection.dashboardId === dashboardId) return; if (sessions.length > 0) { const latest = sessions[0]!; - setSelection({ sessionId: latest.id, model: latest.model ?? null, workspaceId }); + setSelection({ sessionId: latest.id, model: latest.model ?? null, dashboardId }); autoResumedRef.current = true; } - }, [sessions, selection, workspaceId]); + }, [sessions, selection, dashboardId]); const selectSession = (sessionId: string | null, model?: string | null) => { - setSelection({ sessionId, model, workspaceId }); + setSelection({ sessionId, model, dashboardId }); setOpen(false); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 54ac767e..37afd180 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -7,7 +7,7 @@ import { EmbeddableChat } from './EmbeddableChat'; type ChatSessionSelection = { sessionId: string | null; model?: string | null; - workspaceId?: string; + dashboardId?: string; }; type ChatPanelInnerProps = { @@ -45,30 +45,34 @@ const ChatPanelInner = ({ sessionId, model, scoped, sandboxed, cwdParam, promptP }; export const ChatPanelWrapper = () => { - const { workspaceId, cwd, root, promptPrefix } = useWorkspace(); + const { dashboardId, cwd, root, promptPrefix } = useWorkspace(); const scoped = cwd !== '~'; const hostRoot = root === '~' || root === 'officer.dev'; const sandboxed = !hostRoot; - const chatContext = workspaceId === 'email' || workspaceId === 'screens/email' + const chatContext = dashboardId === 'email' || dashboardId === 'screens/email' ? { context: 'email' as const } - : workspaceId?.startsWith('proj-layout-') - ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } - : workspaceId && !workspaceId.startsWith('screens/') - ? { context: 'workspace' as const, contextId: workspaceId } + : dashboardId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') } + : dashboardId && !dashboardId.startsWith('screens/') + ? { context: 'dashboard' as const, contextId: dashboardId } : {}; const [selection] = usePanelChannel('chat:panel-session', null); const [, setActiveSession] = usePanelChannel('chat:active-session', null); const [, setPreviewRefresh] = usePanelChannel('preview:refresh', 0); + const [, setFilesRefresh] = usePanelChannel('files:refresh-signal', 0); const onTurnComplete = useCallback((hadToolCalls: boolean) => { - if (hadToolCalls) setPreviewRefresh(Date.now()); - }, [setPreviewRefresh]); + if (hadToolCalls) { + setPreviewRefresh(Date.now()); + setFilesRefresh(Date.now()); + } + }, [setPreviewRefresh, setFilesRefresh]); const cwdParam = scoped ? { root, path: cwd } : undefined; - const isCurrentWorkspace = !selection?.workspaceId || selection.workspaceId === workspaceId; + const isCurrentWorkspace = !selection?.dashboardId || selection.dashboardId === dashboardId; const sessionId = isCurrentWorkspace ? selection?.sessionId ?? undefined : undefined; const model = isCurrentWorkspace ? selection?.model ?? undefined : undefined; diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx index 84dbfb0b..3a6f21aa 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/EmbeddableChat.tsx @@ -22,10 +22,11 @@ type EmbeddableChatProps = { chat?: UsePiChatType; context?: string; contextId?: string; + onMessageComplete?: () => void; }; -export const EmbeddableChat = ({ className, ...params }: EmbeddableChatProps) => { - const manager = useEmbeddableChat(params); +export const EmbeddableChat = ({ className, onMessageComplete, ...params }: EmbeddableChatProps) => { + const manager = useEmbeddableChat(params, onMessageComplete); return (
diff --git a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts index f21cd8e6..f99b5e77 100644 --- a/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts +++ b/src/workspaces/officerdev/src/apps/Chat/EmbeddableChat/useEmbeddableChat.ts @@ -25,7 +25,7 @@ type UseEmbeddableChatParams = { contextId?: string; }; -export function useEmbeddableChat(params: UseEmbeddableChatParams) { +export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) { const { initialMessage, defaultInput = '', promptPrefix, cwd, sandboxed, autoSend = false, chat: externalChat } = params; const internalChat = usePiChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false, context: params.context, contextId: params.contextId }); @@ -51,6 +51,17 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) { const attachmentManager = useAttachments({ sessionId }); const slashCommands = useSlashCommands({ sessionId }); + // Detect when agent finishes a message (isGenerating: true → false) + const wasGeneratingRef = useRef(false); + useEffect(() => { + if (isGenerating) { + wasGeneratingRef.current = true; + } else if (wasGeneratingRef.current) { + wasGeneratingRef.current = false; + onMessageComplete?.(); + } + }, [isGenerating]); + const [input, setInput] = useState(defaultInput); const [showJumpToBottom, setShowJumpToBottom] = useState(false); const [commandFeedback, setCommandFeedback] = useState(null); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index c06d3749..afe4d400 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -13,12 +13,12 @@ import { SessionContextMenu } from './SessionContextMenu'; export const SessionList = () => { const navigate = useNavigate(); - const { workspaceId } = useWorkspace(); + const { dashboardId } = useWorkspace(); // Only scope sessions for actual project/workspace contexts, not screen layout IDs like 'screens/chat' - const contextFilter = workspaceId?.startsWith('proj-layout-') - ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } - : workspaceId && !workspaceId.startsWith('screens/') - ? { context: 'workspace' as const, contextId: workspaceId } + const contextFilter = dashboardId?.startsWith('proj-layout-') + ? { context: 'project' as const, contextId: dashboardId.replace('proj-layout-', '') } + : dashboardId && !dashboardId.startsWith('screens/') + ? { context: 'dashboard' as const, contextId: dashboardId } : undefined; const { sessions, deleteSession } = useChatSessions(contextFilter); const { groups } = useChatGroups(); diff --git a/src/workspaces/officerdev/src/apps/Workspaces/WorkspaceListApp.tsx b/src/workspaces/officerdev/src/apps/Dashboards/DashboardListApp.tsx similarity index 75% rename from src/workspaces/officerdev/src/apps/Workspaces/WorkspaceListApp.tsx rename to src/workspaces/officerdev/src/apps/Dashboards/DashboardListApp.tsx index fd7bd2fe..c177dfb0 100644 --- a/src/workspaces/officerdev/src/apps/Workspaces/WorkspaceListApp.tsx +++ b/src/workspaces/officerdev/src/apps/Dashboards/DashboardListApp.tsx @@ -6,8 +6,8 @@ import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; import { generateSlug } from 'helpers/slug'; import { useUserState } from 'state/useUserState'; -import { useWorkspacesState } from 'state/useWorkspacesState'; -import type { WorkspaceDefinition } from '../../components/Workspace'; +import { useDashboardState } from 'state/useDashboardState'; +import type { DashboardDefinition } from '../../components/Workspace'; import { AlertDialog, AlertDialogAction, @@ -19,39 +19,39 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { - SELECTED_WORKSPACE_KEY, - CREATING_WORKSPACE_KEY, - EDITING_WORKSPACE_KEY, - NEW_WS_NAME_KEY, - NEW_WS_DESC_KEY, - NEW_WS_TEMPLATE_KEY, + SELECTED_DASHBOARD_KEY, + CREATING_DASHBOARD_KEY, + EDITING_DASHBOARD_KEY, + NEW_DASH_NAME_KEY, + NEW_DASH_DESC_KEY, + NEW_DASH_TEMPLATE_KEY, } from './constants'; -export const WorkspaceListApp = () => { +export const DashboardListApp = () => { const location = useLocation(); const navigate = useNavigate(); const client = useClient(); const queryClient = useQueryClient(); - const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState('workspaces', []); - const [selected, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); - const [, setCreating] = useGlobal(CREATING_WORKSPACE_KEY, false); - const [, setEditing] = useGlobal(EDITING_WORKSPACE_KEY, null); - const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); - const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); - const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const { value: dashboards, setValue: setDashboards } = useDashboardState('workspaces', []); + const [selected, setSelected] = useGlobal(SELECTED_DASHBOARD_KEY, null); + const [, setCreating] = useGlobal(CREATING_DASHBOARD_KEY, false); + const [, setEditing] = useGlobal(EDITING_DASHBOARD_KEY, null); + const [, setName] = useGlobal(NEW_DASH_NAME_KEY, ''); + const [, setDescription] = useGlobal(NEW_DASH_DESC_KEY, ''); + const [, setTemplateIdx] = useGlobal(NEW_DASH_TEMPLATE_KEY, 0); const [, setFilePath] = useUserState('files/currentPath', '/'); const [search, setSearch] = useState(''); - const [deleting, setDeleting] = useState(null); - const isWorkspacesPage = location.pathname === '/workspaces'; + const [deleting, setDeleting] = useState(null); + const isDashboardsPage = location.pathname === '/dashboards'; const filtered = search - ? workspaces.filter((ws) => { + ? dashboards.filter((ws) => { const q = search.toLowerCase(); return [ws.name, ws.id, ws.description ?? '', ws.cwd ?? ''].some((field) => field.toLowerCase().includes(q)); }) - : workspaces; + : dashboards; - const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => { + const handleEdit = (ev: React.MouseEvent, ws: DashboardDefinition) => { ev.stopPropagation(); setSelected(null); setCreating(false); @@ -63,31 +63,31 @@ export const WorkspaceListApp = () => { setFilePath(cwdPath); }; - const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => { + const handleDelete = (ev: React.MouseEvent, ws: DashboardDefinition) => { ev.stopPropagation(); setDeleting(ws); }; const confirmDelete = () => { if (!deleting) return; - setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id)); + setDashboards((prev) => prev.filter((w) => w.id !== deleting.id)); if (selected === deleting.id) setSelected(null); const layoutKey = `ws-layout-${deleting.id}`; - const currentState = queryClient.getQueryData>(['WORKSPACES_STATE']) ?? {}; + const currentState = queryClient.getQueryData>(['DASHBOARD_STATE']) ?? {}; const { [layoutKey]: _, ...rest } = currentState; - queryClient.setQueryData(['WORKSPACES_STATE'], rest); - client.patch('/workspaces', { [layoutKey]: null }).catch(() => {}); + queryClient.setQueryData(['DASHBOARD_STATE'], rest); + client.patch('/dashboards', { [layoutKey]: null }).catch(() => {}); setDeleting(null); }; - const handleClick = (ws: WorkspaceDefinition) => { - if (isWorkspacesPage) { + const handleClick = (ws: DashboardDefinition) => { + if (isDashboardsPage) { setSelected(ws.id); setCreating(false); setEditing(null); } else { - navigate(`/workspaces/${ws.id}`); + navigate(`/dashboards/${ws.id}`); } }; @@ -95,11 +95,11 @@ export const WorkspaceListApp = () => {
- Workspaces + Dashboards
@@ -125,7 +125,7 @@ export const WorkspaceListApp = () => { type="text" value={search} onChange={(ev) => setSearch(ev.target.value)} - placeholder="Search workspaces" + placeholder="Search dashboards" className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40" />
@@ -134,7 +134,7 @@ export const WorkspaceListApp = () => {
{ > {ws.name} - {isWorkspacesPage && ( + {isDashboardsPage && ( <>
@@ -172,7 +172,7 @@ export const WorkspaceListApp = () => { { if (!open) setDeleting(null); }}> - Delete workspace + Delete dashboard Are you sure you want to delete {deleting?.name}? This action cannot be undone. diff --git a/src/workspaces/officerdev/src/apps/Workspaces/WorkspacePreview.tsx b/src/workspaces/officerdev/src/apps/Dashboards/DashboardPreview.tsx similarity index 78% rename from src/workspaces/officerdev/src/apps/Workspaces/WorkspacePreview.tsx rename to src/workspaces/officerdev/src/apps/Dashboards/DashboardPreview.tsx index 9ef55793..dbb63940 100644 --- a/src/workspaces/officerdev/src/apps/Workspaces/WorkspacePreview.tsx +++ b/src/workspaces/officerdev/src/apps/Dashboards/DashboardPreview.tsx @@ -5,20 +5,20 @@ import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; import { useUserState } from 'state/useUserState'; -import { useWorkspacesState } from 'state/useWorkspacesState'; +import { useDashboardState } from 'state/useDashboardState'; import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace'; -import type { LayoutNode, WorkspaceDefinition, HomeRoot } from '../../components/Workspace'; +import type { LayoutNode, DashboardDefinition, HomeRoot } from '../../components/Workspace'; import { Button } from '@/components/ui/button'; import { generateSlug, slugify } from 'helpers/slug'; import { FileBrowserApp } from '../FileBrowser'; import { useAppRegistry } from '../../AppRegistry'; import { - SELECTED_WORKSPACE_KEY, - CREATING_WORKSPACE_KEY, - EDITING_WORKSPACE_KEY, - NEW_WS_NAME_KEY, - NEW_WS_DESC_KEY, - NEW_WS_TEMPLATE_KEY, + SELECTED_DASHBOARD_KEY, + CREATING_DASHBOARD_KEY, + EDITING_DASHBOARD_KEY, + NEW_DASH_NAME_KEY, + NEW_DASH_DESC_KEY, + NEW_DASH_TEMPLATE_KEY, } from './constants'; // --- Layout Templates --- @@ -162,7 +162,7 @@ const ThumbnailNode = ({ node }: { node: LayoutNode }) => { const TemplateCell = ({ index }: { index: number }) => { const tpl = templates[index]!; - const [selected, setSelected] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const [selected, setSelected] = useGlobal(NEW_DASH_TEMPLATE_KEY, 0); const node = tpl.layout(); const isSelected = selected === index; @@ -184,7 +184,7 @@ const TemplateCell = ({ index }: { index: number }) => { ); }; -// --- Panel: Template Picker (3x2 workspace) --- +// --- Panel: Template Picker (3x2 layout) --- const tplPanelLayout: LayoutNode = { type: 'group', @@ -231,8 +231,8 @@ const TemplatePanel = () => ( // --- Panel: Name --- const NamePanel = () => { - const [name, setName] = useGlobal(NEW_WS_NAME_KEY, ''); - const [description, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); + const [name, setName] = useGlobal(NEW_DASH_NAME_KEY, ''); + const [description, setDescription] = useGlobal(NEW_DASH_DESC_KEY, ''); return (
@@ -246,7 +246,7 @@ const NamePanel = () => { type="text" value={name} onChange={(ev) => setName(ev.target.value)} - placeholder="My Workspace" + placeholder="My Dashboard" autoFocus className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" /> @@ -259,7 +259,7 @@ const NamePanel = () => {