workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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 <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
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/<email>/ 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',
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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<ToolResult> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,12 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=overlays-content" />
|
||||
<title>Officer Dev (Alpha)</title>
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta property="og:image" content="https://officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:secure_url" content="https://static.officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta name="twitter:image" content="https://officer.dev/og-image-v3.jpg" />
|
||||
|
||||
<!-- Favicons & App Icons -->
|
||||
|
||||
@@ -54,8 +54,8 @@ export function App() {
|
||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspacesScreen />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Bootstrap() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="md:min-w-[480px]">
|
||||
{done ? (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Check your email</h2>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="md:py-6 md:px-24 flex flex-col gap-6">
|
||||
<Card className="md:py-6 md:px-24 md:min-w-[480px] flex flex-col gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Welcome Back</div>
|
||||
<div className="text-duck-dark/60">Sign in to your account</div>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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<LayoutNode>('screens/automation', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/automation', defaultLayout);
|
||||
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||
const isMobile = useIsMobile();
|
||||
const editing = selection?.editing ?? false;
|
||||
|
||||
@@ -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<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/browser', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/browser', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -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<SelectedSession>('chat:selected-session', null);
|
||||
const rawWorkspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||
|
||||
@@ -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<DashboardDefinition[]>('workspaces', []);
|
||||
const ws = dashboards.find((w) => w.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!ws) return <Navigate to="/dashboards" replace />;
|
||||
|
||||
return <DashboardScreenInner dashboard={ws} />;
|
||||
};
|
||||
|
||||
const DashboardScreenInner = ({ dashboard }: { dashboard: DashboardDefinition }) => {
|
||||
const wsState = useDashboardState<LayoutNode>(`ws-layout-${dashboard.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={wsState} cwd={dashboard.cwd} root={dashboard.root} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -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<LayoutNode>('screens/workspaces', defaultLayout);
|
||||
export const DashboardsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/dashboards', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
||||
const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined;
|
||||
|
||||
return (
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DashboardsScreen } from './DashboardsScreen';
|
||||
export { DashboardScreen } from './DashboardScreen';
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>('EMAIL_SELECTED', null);
|
||||
const workspace = useWorkspacesState<LayoutNode>('screens/email', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/email', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -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<LayoutNode>('screens/files', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/files', defaultLayout);
|
||||
const ephemeral = useFileViewerPanels();
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<LayoutNode>('screens/home', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/home', defaultLayout);
|
||||
const ephemeral = useFileViewerPanels();
|
||||
const { settings } = useSettings();
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ export const BugReportButton = () => {
|
||||
<>
|
||||
<button
|
||||
onClick={bugReport.handleOpen}
|
||||
className="fixed bottom-20 right-4 z-[90] flex h-10 w-10 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg transition-transform hover:scale-110"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-transform hover:scale-110"
|
||||
aria-label="Report a bug"
|
||||
>
|
||||
<Bug size={20} />
|
||||
<Bug size={16} />
|
||||
</button>
|
||||
<BugReportDialog
|
||||
open={bugReport.open}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||
import { useIsTouch } from './useIsTouch';
|
||||
import { BugReportButton } from './BugReport/BugReportButton';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
@@ -22,7 +21,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
<BugReportButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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) {
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<UserMenu />
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => (window as any).eruda?.show()}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110"
|
||||
aria-label="Dev console"
|
||||
>
|
||||
<Terminal size={16} />
|
||||
</button>
|
||||
<BugReportButton />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTouch && dockItems && (
|
||||
|
||||
@@ -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<LayoutNode>('screens/projects', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/projects', defaultLayout);
|
||||
const isMobile = useIsMobile();
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [creating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
|
||||
@@ -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<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects, isLoaded } = useDashboardState<ProjectDefinition[]>('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<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const workspace = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
+8
-3
@@ -22,6 +22,7 @@ export const GoogleAccount = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
{!activeJob && lastJob?.status === 'failed' && !dismissedError && (
|
||||
<button
|
||||
onClick={() => setDismissedError(true)}
|
||||
className="flex items-center gap-2 text-xs text-red-500 hover:opacity-70 transition-opacity cursor-pointer text-left"
|
||||
title="Click to dismiss"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{!activeJob && lastSyncAt && (
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
|
||||
@@ -93,7 +93,7 @@ export const AIModels = () => {
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">Default Project Model</span>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project workspace</p>
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">Used when starting a new chat inside a project dashboard</p>
|
||||
{renderModelSelect(projectModel, setProjectModel, 'Same as chat default')}
|
||||
</Label>
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(ev) => 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' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
||||
{label}
|
||||
<button
|
||||
@@ -27,6 +37,7 @@ const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPi
|
||||
>
|
||||
{visible ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
{dropIndicator === 'right' && <span className="absolute -right-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
@@ -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<LayoutNode>('screens/terminal', defaultLayout);
|
||||
const workspace = useDashboardState<LayoutNode>('screens/terminal', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
||||
@@ -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<WorkspaceDefinition[]>('workspaces', []);
|
||||
const ws = workspaces.find((w) => w.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!ws) return <Navigate to="/workspaces" replace />;
|
||||
|
||||
return <WorkspaceScreenInner workspace={ws} />;
|
||||
};
|
||||
|
||||
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const wsState = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={wsState} cwd={workspace.cwd} root={workspace.root} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { WorkspacesScreen } from './WorkspacesScreen';
|
||||
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||
@@ -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';
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Officer Dev (Alpha)</title>
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta property="og:image" content="https://alpha.officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:secure_url" content="https://static.officer.dev/og-image-v3.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable workspaces — all self-hosted." />
|
||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
||||
<meta name="twitter:image" content="https://alpha.officer.dev/og-image-v3.jpg" />
|
||||
|
||||
<!-- Favicons & App Icons -->
|
||||
@@ -32,7 +32,7 @@
|
||||
<meta name="theme-color" content="#1F2620" />
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||
<script>eruda.init();</script>
|
||||
<script>eruda.init(); eruda._entryBtn.hide();</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -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', {
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+13
-10
@@ -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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './workspaces';
|
||||
export * from './dashboards';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,3 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
|
||||
@@ -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<string, unknown>) {
|
||||
// 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<string, unknown>) {
|
||||
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<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
export async function readAllDashboardsState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
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<Record<
|
||||
|
||||
export function getDirs(email: string): ResolveDirs {
|
||||
return {
|
||||
wsDir: getUserWorkspacesDir(email),
|
||||
screensDir: join(getUserWorkspacesDir(email), 'screens'),
|
||||
dashDir: getUserDashboardsDir(email),
|
||||
screensDir: join(getUserDashboardsDir(email), 'screens'),
|
||||
projDir: getUserProjectsDir(email),
|
||||
};
|
||||
}
|
||||
@@ -8,14 +8,12 @@ import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
|
||||
async function getUserTtsVoice(email: string): Promise<string | null> {
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
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 });
|
||||
|
||||
@@ -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<string, unknown> | 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');
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
try {
|
||||
@@ -217,6 +189,19 @@ async function getBrowserRelayEnv(userId: number): Promise<Record<string, string
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApiKeyForModel(model: string): Promise<string | null> {
|
||||
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<string, { key?: string }>;
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, {
|
||||
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ 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}` }) },
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string };
|
||||
@@ -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<voi
|
||||
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: 'discord',
|
||||
contextId: discordId,
|
||||
|
||||
@@ -9,6 +9,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 = 5_000;
|
||||
@@ -267,7 +268,7 @@ export async function handleTelegramMessage(msg: TelegramBot.Message): Promise<v
|
||||
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: 'telegram',
|
||||
contextId: telegramId,
|
||||
|
||||
@@ -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 = 5_000;
|
||||
@@ -273,7 +274,7 @@ export async function handleWhatsAppMessage(msg: WAMessage): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, string>): Promise<unknown> {
|
||||
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<number> {
|
||||
let count = 0;
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const params: Record<string, string> = { 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<string, string[]>;
|
||||
maxHistoryId: string | null;
|
||||
const SYSTEM_LABEL_MAP: Record<string, string> = {
|
||||
'\\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<string> | 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<SyncResult> {
|
||||
// Dedup via DB
|
||||
const existingIds = new Set<string>();
|
||||
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<ImapSyncResult> {
|
||||
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<string, string[]>();
|
||||
let maxHistoryId: bigint | null = null;
|
||||
|
||||
do {
|
||||
const params: Record<string, string> = { 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<string>();
|
||||
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<IncrementalResult> {
|
||||
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<string, string[]> = 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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ChatSessionSelection | null>('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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ChatSessionSelection | null>('chat:panel-session', null);
|
||||
const [, setActiveSession] = usePanelChannel<string | null>('chat:active-session', null);
|
||||
const [, setPreviewRefresh] = usePanelChannel<number>('preview:refresh', 0);
|
||||
const [, setFilesRefresh] = usePanelChannel<number>('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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`flex flex-col ${className ?? ''}`}>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -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();
|
||||
|
||||
+38
-38
@@ -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<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
const { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
||||
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
||||
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
|
||||
const isWorkspacesPage = location.pathname === '/workspaces';
|
||||
const [deleting, setDeleting] = useState<DashboardDefinition | null>(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<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['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 = () => {
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||
<Link
|
||||
to="/workspaces"
|
||||
to="/dashboards"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Workspaces
|
||||
Dashboards
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,12 +111,12 @@ export const WorkspaceListApp = () => {
|
||||
setTemplateIdx(0);
|
||||
setFilePath('/');
|
||||
setCreating(true);
|
||||
if (!isWorkspacesPage) navigate('/workspaces');
|
||||
if (!isDashboardsPage) navigate('/dashboards');
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
New Workspace
|
||||
New Dashboard
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 pt-2">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export const WorkspaceListApp = () => {
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||
isWorkspacesPage && selected === ws.id
|
||||
isDashboardsPage && selected === ws.id
|
||||
? 'bg-duck-teal/10 text-duck-teal'
|
||||
: 'text-white/80 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
@@ -142,7 +142,7 @@ export const WorkspaceListApp = () => {
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{ws.name}</span>
|
||||
{isWorkspacesPage && (
|
||||
{isDashboardsPage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,7 +164,7 @@ export const WorkspaceListApp = () => {
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||
{search ? 'No matches' : 'No workspaces yet'}
|
||||
{search ? 'No matches' : 'No dashboards yet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export const WorkspaceListApp = () => {
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
+62
-62
@@ -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<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
const [selected, setSelected] = useGlobal<number>(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<string>(NEW_WS_NAME_KEY, '');
|
||||
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
@@ -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 = () => {
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="What is this workspace for?"
|
||||
placeholder="What is this dashboard for?"
|
||||
className="flex-1 min-h-0 resize-none 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"
|
||||
/>
|
||||
</div>
|
||||
@@ -274,18 +274,18 @@ const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||
const [filePath] = useUserState<string>('files/currentPath', '/');
|
||||
const [homeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||
const { value: workspaces, setValue: setWorkspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const { value: dashboards, setValue: setDashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||
|
||||
const cwd = filePath === '/' ? '~' : `~/${filePath.replace(/^\//, '')}`;
|
||||
|
||||
const [description, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
|
||||
@@ -295,13 +295,13 @@ const CreatePanel = () => {
|
||||
const desc = description.trim();
|
||||
|
||||
if (isEditing) {
|
||||
const existingIds = new Set(workspaces.filter((w) => w.id !== editingId).map((w) => w.id));
|
||||
const existingIds = new Set(dashboards.filter((w) => w.id !== editingId).map((w) => w.id));
|
||||
let newId = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
|
||||
|
||||
const idChanged = newId !== editingId;
|
||||
|
||||
setWorkspaces((prev) =>
|
||||
setDashboards((prev) =>
|
||||
prev.map((ws) =>
|
||||
ws.id === editingId
|
||||
? { ...ws, id: newId, name: trimmed, description: desc || undefined, templateIdx }
|
||||
@@ -310,31 +310,31 @@ const CreatePanel = () => {
|
||||
);
|
||||
|
||||
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
||||
const newLayoutKey = `ws-layout-${newId}`;
|
||||
|
||||
if (idChanged) {
|
||||
const oldKeys = [`ws-layout-${editingId}`, `ws-terminals-${editingId}`, `ws-host-terminals-${editingId}`];
|
||||
const cleaned = { ...currentState };
|
||||
for (const k of oldKeys) delete cleaned[k];
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...cleaned, [newLayoutKey]: wsLayout });
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], { ...cleaned, [newLayoutKey]: wsLayout });
|
||||
|
||||
const patch: Record<string, unknown> = { [newLayoutKey]: wsLayout };
|
||||
for (const k of oldKeys) patch[k] = null;
|
||||
client.patch('/workspaces', patch).catch(() => {});
|
||||
client.patch('/dashboards', patch).catch(() => {});
|
||||
} else {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
|
||||
client.patch('/workspaces', { [newLayoutKey]: wsLayout }).catch(() => {});
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], { ...currentState, [newLayoutKey]: wsLayout });
|
||||
client.patch('/dashboards', { [newLayoutKey]: wsLayout }).catch(() => {});
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
setSelected(newId);
|
||||
} else {
|
||||
const existingIds = new Set(workspaces.map((w) => w.id));
|
||||
const existingIds = new Set(dashboards.map((w) => w.id));
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
const ws: WorkspaceDefinition = {
|
||||
const ws: DashboardDefinition = {
|
||||
id,
|
||||
name: trimmed,
|
||||
cwd,
|
||||
@@ -345,15 +345,15 @@ const CreatePanel = () => {
|
||||
const wsLayout = templates[templateIdx]?.layout() ?? createDefaultLayout();
|
||||
const layoutKey = `ws-layout-${ws.id}`;
|
||||
|
||||
setWorkspaces((prev) => [...prev, ws]);
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/workspaces', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
setDashboards((prev) => [...prev, ws]);
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], { ...currentState, [layoutKey]: wsLayout });
|
||||
client.patch('/dashboards', { [layoutKey]: wsLayout }).catch(() => {});
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
navigate(`/workspaces/${ws.id}`);
|
||||
navigate(`/dashboards/${ws.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -371,16 +371,16 @@ const CreatePanel = () => {
|
||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{isEditing ? 'Update Workspace' : 'Create Workspace'}
|
||||
{isEditing ? 'Update Dashboard' : 'Create Dashboard'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button
|
||||
onClick={() => navigate(`/workspaces/${editingId}`)}
|
||||
onClick={() => navigate(`/dashboards/${editingId}`)}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 mr-1" />
|
||||
Open Workspace
|
||||
Open Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -396,9 +396,9 @@ const CwdFileBrowser = () => {
|
||||
return <FileBrowserApp basePath={basePath} />;
|
||||
};
|
||||
|
||||
// --- New Workspace Layout ---
|
||||
// --- New Dashboard Layout ---
|
||||
|
||||
const newWsLayout: LayoutNode = {
|
||||
const newDashLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'new-ws-root',
|
||||
direction: 'vertical',
|
||||
@@ -430,9 +430,9 @@ const newWsLayout: LayoutNode = {
|
||||
],
|
||||
};
|
||||
|
||||
const NewWorkspaceForm = () => {
|
||||
const NewDashboardForm = () => {
|
||||
const { registry } = useAppRegistry();
|
||||
const newWsRegistry = {
|
||||
const newDashRegistry = {
|
||||
...registry,
|
||||
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
|
||||
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
|
||||
@@ -440,20 +440,20 @@ const NewWorkspaceForm = () => {
|
||||
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
|
||||
return <WorkspaceLayout layout={newWsLayout} onLayoutChange={() => {}} registry={newWsRegistry} noHeader />;
|
||||
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
|
||||
};
|
||||
|
||||
// --- Empty State ---
|
||||
|
||||
const WorkspacePreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_WORKSPACE_KEY, false);
|
||||
const [editingId] = useGlobal<string | null>(EDITING_WORKSPACE_KEY, null);
|
||||
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
const DashboardPreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||
const [editingId] = useGlobal<string | null>(EDITING_DASHBOARD_KEY, null);
|
||||
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
||||
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||
|
||||
if (creating || editingId) return <NewWorkspaceForm />;
|
||||
if (creating || editingId) return <NewDashboardForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
@@ -467,10 +467,10 @@ const WorkspacePreviewEmpty = () => {
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<LayoutGrid className="h-10 w-10 text-white/80" />
|
||||
<p className="text-sm text-white/80">Select a workspace or create a new one</p>
|
||||
<p className="text-sm text-white/80">Select a dashboard or create a new one</p>
|
||||
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Workspace
|
||||
New Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,26 +479,26 @@ const WorkspacePreviewEmpty = () => {
|
||||
|
||||
// --- Preview Inner ---
|
||||
|
||||
const WorkspacePreviewInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||
const ws = useWorkspacesState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||
const DashboardPreviewInner = ({ dashboard }: { dashboard: DashboardDefinition }) => {
|
||||
const ws = useDashboardState<LayoutNode>(`ws-layout-${dashboard.id}`, createDefaultLayout());
|
||||
|
||||
return <WorkspaceView workspace={ws} cwd={workspace.cwd} root={workspace.root} />;
|
||||
return <WorkspaceView workspace={ws} cwd={dashboard.cwd} root={dashboard.root} />;
|
||||
};
|
||||
|
||||
// --- Main Export ---
|
||||
|
||||
export const WorkspacePreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||
const { value: workspaces } = useWorkspacesState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = selectedId ? workspaces.find((ws) => ws.id === selectedId) : null;
|
||||
export const DashboardPreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_DASHBOARD_KEY, null);
|
||||
const { value: dashboards } = useDashboardState<DashboardDefinition[]>('workspaces', []);
|
||||
const dashboard = selectedId ? dashboards.find((ws) => ws.id === selectedId) : null;
|
||||
|
||||
if (!workspace) return <WorkspacePreviewEmpty />;
|
||||
if (!dashboard) return <DashboardPreviewEmpty />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<WorkspacePreviewInner key={workspace.id} workspace={workspace} />
|
||||
<DashboardPreviewInner key={dashboard.id} dashboard={dashboard} />
|
||||
<Link
|
||||
to={`/workspaces/${workspace.id}`}
|
||||
to={`/dashboards/${dashboard.id}`}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||
>
|
||||
<ArrowRight className="h-16 w-16 text-duck-teal/0 group-hover:text-duck-teal/60 transition-colors" />
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SELECTED_DASHBOARD_KEY = 'SELECTED_DASHBOARD';
|
||||
export const CREATING_DASHBOARD_KEY = 'CREATING_DASHBOARD';
|
||||
export const EDITING_DASHBOARD_KEY = 'EDITING_DASHBOARD';
|
||||
export const NEW_DASH_NAME_KEY = 'NEW_DASH_NAME';
|
||||
export const NEW_DASH_DESC_KEY = 'NEW_DASH_DESC';
|
||||
export const NEW_DASH_TEMPLATE_KEY = 'NEW_DASH_TEMPLATE';
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { DashboardListApp } from './DashboardListApp';
|
||||
import { DashboardPreview } from './DashboardPreview';
|
||||
|
||||
export { DashboardListApp };
|
||||
export { DashboardPreview };
|
||||
export { 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 appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'dashboard-list',
|
||||
name: 'Dashboards',
|
||||
icon: LayoutGrid,
|
||||
component: DashboardListApp
|
||||
},
|
||||
{
|
||||
key: 'dashboard-preview',
|
||||
name: 'Dashboard Preview',
|
||||
icon: LayoutGrid,
|
||||
component: DashboardPreview,
|
||||
availableOnPanel: false
|
||||
},
|
||||
];
|
||||
+2
-2
@@ -68,7 +68,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
handlePlay,
|
||||
getMatchingTasks,
|
||||
handleRunTask,
|
||||
handleCreateWorkspace,
|
||||
handleCreateDashboard,
|
||||
fileScrollRef: scrollRef,
|
||||
} = fileBrowserManager;
|
||||
const { defaultSort } = fileBrowserManager;
|
||||
@@ -187,7 +187,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onPlay={handlePlay}
|
||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||
onRunTask={handleRunTask}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onCreateDashboard={handleCreateDashboard}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
+18
-14
@@ -52,7 +52,7 @@ export type FileItemProps = {
|
||||
onPlay: (entry: DirEntry) => void;
|
||||
matchingTasks: TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
onCreateWorkspace: (entry: DirEntry) => void;
|
||||
onCreateDashboard: (entry: DirEntry) => void;
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -87,7 +87,7 @@ type MenuItemsProps = {
|
||||
onCopy: () => void;
|
||||
matchingTasks: TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
onCreateWorkspace: (e: DirEntry) => void;
|
||||
onCreateDashboard: (e: DirEntry) => void;
|
||||
};
|
||||
|
||||
const DropdownMenuItems = ({
|
||||
@@ -108,7 +108,7 @@ const DropdownMenuItems = ({
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
onCreateDashboard,
|
||||
}: MenuItemsProps) => {
|
||||
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
||||
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
||||
@@ -186,9 +186,9 @@ const DropdownMenuItems = ({
|
||||
{entry.type === 'directory' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
|
||||
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Workspace here
|
||||
Create Dashboard here
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -234,7 +234,7 @@ const ContextMenuItems = ({
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
onCreateDashboard,
|
||||
}: MenuItemsProps) => {
|
||||
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
||||
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
||||
@@ -312,9 +312,9 @@ const ContextMenuItems = ({
|
||||
{entry.type === 'directory' && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Workspace here
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
@@ -367,9 +367,13 @@ const InlineRenameInput = ({
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(initialName);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const focused = useRef(false);
|
||||
|
||||
const mountRef = (node: HTMLInputElement | null) => {
|
||||
if (!node) return;
|
||||
useEffect(() => {
|
||||
const node = inputRef.current;
|
||||
if (!node || focused.current) return;
|
||||
focused.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
node.focus();
|
||||
const dotIndex = initialName.lastIndexOf('.');
|
||||
@@ -379,7 +383,7 @@ const InlineRenameInput = ({
|
||||
node.select();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
const trimmed = value.trim();
|
||||
@@ -392,7 +396,7 @@ const InlineRenameInput = ({
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={mountRef}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(ev) => setValue(ev.target.value)}
|
||||
onBlur={commit}
|
||||
@@ -459,7 +463,7 @@ export const FileItem = ({
|
||||
onPlay,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
onCreateDashboard,
|
||||
}: FileItemProps) => {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -543,7 +547,7 @@ export const FileItem = ({
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
onCreateDashboard,
|
||||
};
|
||||
|
||||
const cutOpacity = isCut ? 'opacity-50' : '';
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
@@ -26,7 +26,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
handleCopyCurrentPath,
|
||||
handleChatHere,
|
||||
handleCreateDir,
|
||||
handleCreateWorkspaceHere,
|
||||
handleCreateDashboardHere,
|
||||
setShowVideoDownload,
|
||||
} = fileBrowserManager;
|
||||
|
||||
@@ -120,14 +120,14 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={handleCreateDashboardHere} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Workspace here
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
{/* <ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download video
|
||||
</ContextMenuItem>
|
||||
</ContextMenuItem> */}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
@@ -378,9 +378,9 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
setRunningTask({ task, entry });
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = (entry: DirEntry) => {
|
||||
const handleCreateDashboard = (entry: DirEntry) => {
|
||||
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||
navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
|
||||
navigate(`/dashboards/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
|
||||
};
|
||||
|
||||
const handleCopyPath = (entry: DirEntry) => {
|
||||
@@ -402,11 +402,11 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateWorkspaceHere = () => {
|
||||
const handleCreateDashboardHere = () => {
|
||||
const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!;
|
||||
const params = new URLSearchParams({ cwd: currentPath });
|
||||
if (dirName) params.set('name', dirName);
|
||||
navigate(`/workspaces/new?${params}`);
|
||||
navigate(`/dashboards/new?${params}`);
|
||||
};
|
||||
|
||||
const handleReadAloud = async (entry: DirEntry) => {
|
||||
@@ -703,8 +703,8 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
||||
handleDownload,
|
||||
handleDownloadSelected,
|
||||
handleRunTask,
|
||||
handleCreateWorkspace,
|
||||
handleCreateWorkspaceHere,
|
||||
handleCreateDashboard,
|
||||
handleCreateDashboardHere,
|
||||
handleReadAloud,
|
||||
handleOcr,
|
||||
handleTranscribe,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuth } from 'hooks/useAuth';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import type { ProjectDefinition } from '../../components/Workspace';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { PreviewContext } from './PreviewContext';
|
||||
|
||||
type DevServerResponse = { url: string; port: number };
|
||||
@@ -17,7 +17,7 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
||||
const { cwd } = useWorkspace();
|
||||
const client = useClient();
|
||||
const { user } = useAuth();
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [port, setPort] = useState<number | null>(null);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import type { ProjectDefinition, ProjectType } from '../../components/Workspace';
|
||||
import { PublishDialog } from './PublishDialog';
|
||||
import {
|
||||
@@ -41,7 +41,7 @@ export const ProjectListApp = () => {
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
@@ -91,19 +91,19 @@ export const ProjectListApp = () => {
|
||||
const confirmDelete = () => {
|
||||
if (!deleting) return;
|
||||
// Optimistic: remove from cache immediately
|
||||
const current = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const current = queryClient.getQueryData<Record<string, unknown>>(['DASHBOARD_STATE']) ?? {};
|
||||
const optimistic = { ...current };
|
||||
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
|
||||
delete optimistic[`proj-layout-${deleting.id}`];
|
||||
delete optimistic[`proj-terminals-${deleting.id}`];
|
||||
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], optimistic);
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], optimistic);
|
||||
|
||||
if (selected === deleting.id) setSelected(null);
|
||||
setDeleting(null);
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
|
||||
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||
.patch('/dashboards', { [`proj-meta-${deleting.id}`]: null })
|
||||
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout, Loader2
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '../../components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '../../components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -373,7 +373,7 @@ const CreatePanel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
@@ -405,8 +405,8 @@ const CreatePanel = () => {
|
||||
|
||||
if (isEditing) {
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
|
||||
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||
.patch('/dashboards', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
|
||||
.then((res) => queryClient.setQueryData(['DASHBOARD_STATE'], res))
|
||||
.catch(() => {});
|
||||
|
||||
setEditingId(null);
|
||||
@@ -422,8 +422,8 @@ const CreatePanel = () => {
|
||||
: previewLayout;
|
||||
|
||||
try {
|
||||
const res = await client.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
const res = await client.patch('/dashboards', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: finalLayout });
|
||||
queryClient.setQueryData(['DASHBOARD_STATE'], res);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
@@ -576,13 +576,13 @@ const ProjectPreviewEmpty = () => {
|
||||
};
|
||||
|
||||
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const ws = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const ws = useDashboardState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
|
||||
};
|
||||
|
||||
export const ProjectPreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const { value: projects } = useDashboardState<ProjectDefinition[]>('projects', []);
|
||||
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||
|
||||
if (!project) return <ProjectPreviewEmpty />;
|
||||
|
||||
@@ -24,7 +24,6 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
name: 'Projects',
|
||||
icon: FolderKanban,
|
||||
component: ProjectListApp,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'project-preview',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { TerminalView } from './Terminal';
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
@@ -12,9 +12,9 @@ type CommandTerminalWrapperProps = {
|
||||
};
|
||||
|
||||
export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: CommandTerminalWrapperProps) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { dashboardId, cwd } = useWorkspace();
|
||||
const stateKey = dashboardId ? `ws-${statePrefix}-${dashboardId}` : `ws-${statePrefix}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { TerminalView } from './Terminal';
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const { dashboardId, cwd } = useWorkspace();
|
||||
const stateKey = dashboardId ? `ws-host-terminals-${dashboardId}` : 'ws-host-terminals-default';
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWorkspace } from '../../components/Workspace';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { TerminalView } from './Terminal';
|
||||
import { useTerminalMode } from './useTerminalMode';
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId, cwd, root } = useWorkspace();
|
||||
const { dashboardId, cwd, root } = useWorkspace();
|
||||
const { mode } = useTerminalMode(panelId);
|
||||
const hostRoot = root === '~' || root === 'officer.dev';
|
||||
const sandboxed = !hostRoot && (cwd !== '~' || mode === 'sandboxed');
|
||||
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const stateKey = dashboardId ? `ws-terminals-${mode}-${dashboardId}` : `ws-terminals-${mode}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const SELECTED_WORKSPACE_KEY = 'SELECTED_WORKSPACE';
|
||||
export const CREATING_WORKSPACE_KEY = 'CREATING_WORKSPACE';
|
||||
export const EDITING_WORKSPACE_KEY = 'EDITING_WORKSPACE';
|
||||
export const NEW_WS_NAME_KEY = 'NEW_WS_NAME';
|
||||
export const NEW_WS_DESC_KEY = 'NEW_WS_DESC';
|
||||
export const NEW_WS_TEMPLATE_KEY = 'NEW_WS_TEMPLATE';
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||
import { WorkspacePreview } from './WorkspacePreview';
|
||||
|
||||
export { WorkspaceListApp };
|
||||
export { WorkspacePreview };
|
||||
export { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'workspace-list',
|
||||
name: 'Workspaces',
|
||||
icon: LayoutGrid,
|
||||
component: WorkspaceListApp,
|
||||
availableOnPanel: false
|
||||
},
|
||||
{
|
||||
key: 'workspace-preview',
|
||||
name: 'Workspace Preview',
|
||||
icon: LayoutGrid,
|
||||
component: WorkspacePreview,
|
||||
availableOnPanel: false
|
||||
},
|
||||
];
|
||||
@@ -8,7 +8,7 @@ export type DefaultFileSort = {
|
||||
};
|
||||
|
||||
type WorkspaceContextValue = {
|
||||
workspaceId: string | null;
|
||||
dashboardId: string | null;
|
||||
cwd: string;
|
||||
root?: string;
|
||||
initialFilePath?: string;
|
||||
@@ -30,7 +30,7 @@ type WorkspaceContextValue = {
|
||||
const noop = () => {};
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
workspaceId: null,
|
||||
dashboardId: null,
|
||||
cwd: '~',
|
||||
swapSourceId: null,
|
||||
setSwapSourceId: noop,
|
||||
|
||||
@@ -10,7 +10,7 @@ type WorkspaceLayoutProps = {
|
||||
onLayoutChange: (layout: LayoutNode) => void;
|
||||
registry?: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
workspaceId?: string;
|
||||
dashboardId?: string;
|
||||
cwd?: string;
|
||||
promptPrefix?: string;
|
||||
noHeader?: boolean;
|
||||
@@ -21,7 +21,7 @@ type WorkspaceLayoutProps = {
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd, promptPrefix, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => {
|
||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, dashboardId, cwd, promptPrefix, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => {
|
||||
const { registry: globalRegistry } = useAppRegistry();
|
||||
const registry = registryProp ?? globalRegistry;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceProvider value={{ dashboardId: dashboardId ?? null, cwd: cwd ?? '~', promptPrefix, swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef }
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import type { LayoutNode, WorkspaceState, EphemeralPanels, PanelComponents } from './types';
|
||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents } from './types';
|
||||
import type { DefaultFileSort } from './WorkspaceContext';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||
@@ -11,7 +11,7 @@ import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
workspace: WorkspaceState;
|
||||
workspace: DashboardState;
|
||||
locked?: boolean;
|
||||
cwd?: string;
|
||||
root?: string;
|
||||
@@ -143,7 +143,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
|
||||
return (
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceId: workspace.key,
|
||||
dashboardId: workspace.key,
|
||||
cwd,
|
||||
root,
|
||||
initialFilePath,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, HomeRoot } from './types';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, DashboardDefinition, DashboardState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, HomeRoot } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export type { DefaultFileSort } from './WorkspaceContext';
|
||||
|
||||
@@ -19,7 +19,7 @@ export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||
|
||||
export type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||
|
||||
export type WorkspaceDefinition = {
|
||||
export type DashboardDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
@@ -28,7 +28,7 @@ export type WorkspaceDefinition = {
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
export type DashboardState = {
|
||||
key: string;
|
||||
value: LayoutNode;
|
||||
setValue: (value: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => void;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { FileViewerProvider } from '../../apps/FileViewer';
|
||||
import { EmbeddableChat } from '../../apps/Chat/EmbeddableChat';
|
||||
|
||||
@@ -66,6 +67,7 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
|
||||
export const ChatEphemeralBody = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [homeRoot] = useUserState<string>('files/homeRoot', 'home');
|
||||
const [, setRefreshSignal] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||
const chatContext = searchParams.get('chatContext') ?? '';
|
||||
const chatType = searchParams.get('chatType') as 'file' | 'folder' | null;
|
||||
const hostRoot = homeRoot === '~' || homeRoot === 'officer.dev';
|
||||
@@ -81,12 +83,17 @@ export const ChatEphemeralBody = () => {
|
||||
? `[${tag}: ${path}] Let's talk about this file`
|
||||
: `[${tag}: ${path || '/'}] consider, for this session, this directory as your current working directory`;
|
||||
|
||||
const handleMessageComplete = useCallback(() => {
|
||||
setRefreshSignal((n) => n + 1);
|
||||
}, [setRefreshSignal]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
className="h-full"
|
||||
cwd={{ root: homeRoot, path: cwdPath.replace(/^\//, '') || '/' }}
|
||||
sandboxed={sandboxed}
|
||||
defaultInput={message}
|
||||
onMessageComplete={handleMessageComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, F
|
||||
export type { FileType } from './apps/FileViewer';
|
||||
export { TerminalView } from './apps/Terminal';
|
||||
export type { TerminalViewProps } from './apps/Terminal';
|
||||
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
|
||||
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
|
||||
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
|
||||
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
|
||||
export { resolveIcon, availableIconNames } from './utils/resolve-icon';
|
||||
@@ -23,4 +23,4 @@ export { resolveIcon, availableIconNames } from './utils/resolve-icon';
|
||||
// Workspace
|
||||
export { WorkspaceView, WorkspaceLayout, WorkspaceProvider, useWorkspace } from './components/Workspace';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './components/Workspace';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot, DefaultFileSort } from './components/Workspace';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, DashboardDefinition, DashboardState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels, DropPosition, HomeRoot, DefaultFileSort } from './components/Workspace';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { useSettings, DEFAULT_SETTINGS } from './useSettings';
|
||||
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
||||
export { useUserState } from './useUserState';
|
||||
export { useWorkspacesState } from './useWorkspacesState';
|
||||
export { useDashboardState } from './useDashboardState';
|
||||
export { usePiModels, useVisiblePiModels, useEnabledPiModels, modelKey } from './useModels';
|
||||
export type { ModelOption } from './useModels';
|
||||
export { useAccessPolicy } from './useAccessPolicy';
|
||||
|
||||
+5
-5
@@ -4,9 +4,9 @@ import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './useSettings';
|
||||
|
||||
const QUERY_KEY = ['WORKSPACES_STATE'];
|
||||
const QUERY_KEY = ['DASHBOARD_STATE'];
|
||||
|
||||
export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
export function useDashboardState<T>(key: string, defaultValue: T) {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -16,7 +16,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/workspaces'),
|
||||
queryFn: () => client.get<UserState>('/dashboards'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
if (!(key in currentState)) {
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: defaultValue });
|
||||
clientRef.current.patch('/workspaces', { [key]: defaultValue }).catch(() => { });
|
||||
clientRef.current.patch('/dashboards', { [key]: defaultValue }).catch(() => { });
|
||||
}
|
||||
}, [isSuccess, key, defaultValue, queryClient]);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function useWorkspacesState<T>(key: string, defaultValue: T) {
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current.patch('/workspaces', { [key]: newValue }).catch(() => { });
|
||||
clientRef.current.patch('/dashboards', { [key]: newValue }).catch(() => { });
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
Reference in New Issue
Block a user