15 tasks across BE cloud CRUD (foundational — routes never existed), gmail_address + connector list_labels/stop_watch, tRPC + shared types, Electron stepper split (Local/Cloud flows) + config panel parity + catalog gating + i18n. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1938 lines
74 KiB
Markdown
1938 lines
74 KiB
Markdown
# Cloud Scout Creation Flow (Gmail) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Give Gmail cloud scouts a slim creation flow (name + focus + auto-trash + label/sender filter, OAuth during creation) fitting the two-stage HITL pipeline, with full edit parity in the config panel — and build the foundational cloud-scout CRUD backend routes that were never implemented.
|
||
|
||
**Architecture:** Backend gains the missing `CloudScoutConfig` CRUD routes (`POST/GET/PUT/DELETE /api/v1/scouts/cloud`) plus a `gmail_address` column, Gmail label-listing and disconnect endpoints. The Electron stepper is split into a thin router that delegates to `LocalScoutCreationFlow` (unchanged) or a new `CloudScoutCreationFlow`. The cloud config panel is rewritten for edit parity.
|
||
|
||
**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic / Pydantic v2 / Google API client. Electron / TypeScript / React 19 / tRPC v11 / zod / i18next.
|
||
|
||
**Spec:** [docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-design.md](2026-05-16-cloud-scout-creation-flow-design.md)
|
||
|
||
**CRITICAL CONTEXT — discovered during planning:** The backend cloud-scout CRUD routes (`/api/v1/scouts/cloud`) **do not exist**. The Electron tRPC `scout.cloud.{list,create,update,delete}` procedures call them but they 404. Cloud scouts are currently non-functional end-to-end. Phase A below builds these foundational routes; the slim-flow work (Phases C–D) depends on them. This is a larger scope than the spec literally implied ("verify/extend") because there is nothing to extend yet.
|
||
|
||
**Repository layout:** monorepo with submodules `api/` (FastAPI) and `adiuvAI/` (Electron), both on branch `develop`. Monorepo on `main`. Don't bump submodule pointers until the final task of each submodule's work.
|
||
|
||
**Working dir:** `c:\Users\PC-Roby\Documents\_adiuvai_workspace`. Windows; use the Bash tool for git/pytest. Pre-existing flaky tests to ignore throughout: `test_eval_runner`, `test_eval_journey`, `test_home_request_calls_memory_middleware` (hangs), `test_stream_end_serializes`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Backend (`api/`)
|
||
|
||
| Path | Responsibility | New/Modified |
|
||
|------|----------------|--------------|
|
||
| `api/alembic/versions/009_cloud_scout_gmail_address.py` | Add `gmail_address` column | New |
|
||
| `api/app/models.py` | `CloudScoutConfig.gmail_address` field | Modified |
|
||
| `api/app/schemas/__init__.py` | Cloud create/update/response Pydantic models | Modified |
|
||
| `api/app/api/routes/scouts.py` | Cloud CRUD routes + serializer + label-list + disconnect + gmail_address persistence | Modified |
|
||
| `api/app/scouts/connectors/gmail.py` | `list_labels` + `stop_watch` | Modified |
|
||
| `api/tests/test_scout_cloud_crud.py` | Cloud CRUD route tests | New |
|
||
| `api/tests/test_scout_connectors_gmail.py` | `list_labels` + `stop_watch` tests | Modified |
|
||
|
||
### Electron (`adiuvAI/`)
|
||
|
||
| Path | Responsibility | New/Modified |
|
||
|------|----------------|--------------|
|
||
| `adiuvAI/src/shared/api-types.ts` | `CloudScoutConfigSchema` new fields | Modified |
|
||
| `adiuvAI/src/main/router/index.ts` | tRPC cloud input changes + `gmailLabels` + `disconnectGmail` | Modified |
|
||
| `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` | Thin router: shared template step → delegate | Modified |
|
||
| `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx` | Extracted current local flow | New |
|
||
| `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx` | New Gmail slim flow | New |
|
||
| `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` | Rewrite for parity | Modified |
|
||
| `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx` | Disable Teams/Outlook | Modified |
|
||
| `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` | New scout keys | Modified |
|
||
|
||
---
|
||
|
||
# PHASE A — Backend Cloud CRUD Routes (foundational)
|
||
|
||
## Task 1: Cloud scout Pydantic schemas
|
||
|
||
**Files:**
|
||
- Modify: `api/app/schemas/__init__.py`
|
||
|
||
**Context:** No request/response schemas exist for cloud-scout CRUD. Add them near the other scout schemas (`ScoutCatalogItem`, `ScoutCreationCheckResponse`, etc). The response mirrors the `CloudScoutConfig` ORM columns, camelCased by the existing serialization layer (the route returns a plain dict; the Electron `proxyGet` camelCases keys, so we return snake_case keys and let the client convert — verify by checking how `ScoutRunLogResponse` is returned).
|
||
|
||
- [ ] **Step 1: Read the existing scout schemas to match style**
|
||
|
||
Read `api/app/schemas/__init__.py` around the `ScoutCatalogItem` / `ScoutCreationCheckResponse` / `ScoutRunLogResponse` definitions. Note whether response models use `alias_generator`/camelCase or snake_case + client conversion. (The Electron `proxyGet`/`proxyPost` camelCases responses and snake_cases requests, so backend speaks snake_case.)
|
||
|
||
- [ ] **Step 2: Add the cloud schemas**
|
||
|
||
Add to `api/app/schemas/__init__.py`:
|
||
|
||
```python
|
||
class CloudScoutCreateRequest(BaseModel):
|
||
name: str
|
||
provider: Literal["gmail", "teams", "outlook"]
|
||
data_types: list[str] = Field(default_factory=list)
|
||
prompt_template: str = ""
|
||
schedule_cron: str | None = None # None → server default
|
||
filter_config: dict | None = None
|
||
auto_trash_spam: bool = False
|
||
|
||
|
||
class CloudScoutUpdateRequest(BaseModel):
|
||
name: str | None = None
|
||
data_types: list[str] | None = None
|
||
prompt_template: str | None = None
|
||
schedule_cron: str | None = None
|
||
filter_config: dict | None = None
|
||
auto_trash_spam: bool | None = None
|
||
enabled: bool | None = None
|
||
|
||
|
||
class CloudScoutResponse(BaseModel):
|
||
id: str
|
||
user_id: str
|
||
provider: str
|
||
name: str
|
||
data_types: list[str]
|
||
prompt_template: str
|
||
schedule_cron: str
|
||
filter_config: dict | None
|
||
auto_trash_spam: bool
|
||
enabled: bool
|
||
last_run_at: int | None
|
||
gmail_address: str | None
|
||
oauth_connected: bool
|
||
created_at: int
|
||
updated_at: int
|
||
```
|
||
|
||
If `Literal` / `Field` aren't imported in this file, add them: `from typing import Literal` and `from pydantic import BaseModel, Field`.
|
||
|
||
- [ ] **Step 3: Verify import**
|
||
|
||
```bash
|
||
cd api
|
||
python -c "from app.schemas import CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse; print('ok')"
|
||
```
|
||
Expected: `ok`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/schemas/__init__.py
|
||
git commit -m "feat(scouts): add cloud scout CRUD pydantic schemas"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Cloud scout serializer + CRUD routes
|
||
|
||
**Files:**
|
||
- Modify: `api/app/api/routes/scouts.py`
|
||
- Test: `api/tests/test_scout_cloud_crud.py`
|
||
|
||
**Context:** `scouts.py` already has `_dt_ms` (line 60), `_dt_ms_opt` (line 64), `_to_data_types` (line 68), `_enforce_agent_limit` (line 99), and uses `Depends(get_session)` + `Depends(get_current_user)` returning `UserProfile`. Mirror the existing route style. The serializer computes `oauth_connected` from `oauth_token_encrypted is not None`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `api/tests/test_scout_cloud_crud.py`:
|
||
|
||
```python
|
||
"""Tests for cloud scout CRUD routes."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.main import app
|
||
from app.models import CloudScoutConfig
|
||
from tests.conftest import _TestSessionLocal, make_jwt
|
||
|
||
|
||
def _auth_headers(tier: str = "power") -> dict:
|
||
return {"Authorization": f"Bearer {make_jwt(tier)}"}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _patch_session():
|
||
# Route handlers use app.db.get_session; point it at the test session.
|
||
with patch("app.api.routes.scouts.get_session", new=_test_get_session):
|
||
yield
|
||
|
||
|
||
async def _test_get_session():
|
||
async with _TestSessionLocal() as session:
|
||
yield session
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_cloud_scout_defaults_schedule():
|
||
payload = {
|
||
"name": "Inbox",
|
||
"provider": "gmail",
|
||
"data_types": [],
|
||
"prompt_template": "client requests",
|
||
"auto_trash_spam": True,
|
||
# schedule_cron omitted → server default
|
||
}
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers())
|
||
assert resp.status_code == 201, resp.text
|
||
body = resp.json()
|
||
assert body["name"] == "Inbox"
|
||
assert body["provider"] == "gmail"
|
||
assert body["auto_trash_spam"] is True
|
||
assert body["prompt_template"] == "client requests"
|
||
assert body["schedule_cron"] # non-empty default applied
|
||
assert body["oauth_connected"] is False
|
||
assert body["gmail_address"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_cloud_scouts_returns_only_own():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
await client.post(
|
||
"/api/v1/scouts/cloud",
|
||
json={"name": "A", "provider": "gmail"},
|
||
headers=_auth_headers(),
|
||
)
|
||
resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers())
|
||
assert resp.status_code == 200
|
||
rows = resp.json()
|
||
assert all(r["provider"] == "gmail" for r in rows)
|
||
assert any(r["name"] == "A" for r in rows)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_cloud_scout_applies_filter_and_autotrash():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
created = (await client.post(
|
||
"/api/v1/scouts/cloud",
|
||
json={"name": "B", "provider": "gmail"},
|
||
headers=_auth_headers(),
|
||
)).json()
|
||
sid = created["id"]
|
||
resp = await client.put(
|
||
f"/api/v1/scouts/cloud/{sid}",
|
||
json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"},
|
||
headers=_auth_headers(),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]}
|
||
assert body["auto_trash_spam"] is True
|
||
assert body["prompt_template"] == "invoices"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_delete_cloud_scout():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
created = (await client.post(
|
||
"/api/v1/scouts/cloud",
|
||
json={"name": "C", "provider": "gmail"},
|
||
headers=_auth_headers(),
|
||
)).json()
|
||
sid = created["id"]
|
||
resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers())
|
||
assert resp.status_code == 200
|
||
listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json()
|
||
assert all(r["id"] != sid for r in listing)
|
||
```
|
||
|
||
Note: the `_patch_session` fixture assumes the route reads `get_session` as a module-level name in `scouts.py`. If `get_session` is imported differently, adjust the patch target. Confirm via `grep -n "get_session" api/app/api/routes/scouts.py`.
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_cloud_crud.py -v
|
||
```
|
||
Expected: 404s (routes don't exist) → assertion failures.
|
||
|
||
- [ ] **Step 3: Add the serializer + routes to `scouts.py`**
|
||
|
||
Add near the other helpers in `api/app/api/routes/scouts.py`:
|
||
|
||
```python
|
||
def _to_cloud_response(scout: CloudScoutConfig) -> dict:
|
||
return {
|
||
"id": scout.id,
|
||
"user_id": scout.user_id,
|
||
"provider": scout.provider,
|
||
"name": scout.name,
|
||
"data_types": scout.data_types or [],
|
||
"prompt_template": scout.prompt_template or "",
|
||
"schedule_cron": scout.schedule_cron,
|
||
"filter_config": scout.filter_config,
|
||
"auto_trash_spam": scout.auto_trash_spam,
|
||
"enabled": scout.enabled,
|
||
"last_run_at": _dt_ms_opt(scout.last_run_at),
|
||
"gmail_address": scout.gmail_address,
|
||
"oauth_connected": scout.oauth_token_encrypted is not None,
|
||
"created_at": _dt_ms(scout.created_at),
|
||
"updated_at": _dt_ms(scout.updated_at),
|
||
}
|
||
```
|
||
|
||
Then add the routes (place after the existing scout routes, before the OAuth section):
|
||
|
||
```python
|
||
_DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *"
|
||
|
||
|
||
@router.get("/cloud", response_model=list[CloudScoutResponse])
|
||
async def list_cloud_scouts(
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
rows = (await db.execute(
|
||
select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id)
|
||
)).scalars().all()
|
||
return [_to_cloud_response(s) for s in rows]
|
||
|
||
|
||
@router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_cloud_scout(
|
||
body: CloudScoutCreateRequest,
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
scout = CloudScoutConfig(
|
||
id=str(uuid.uuid4()),
|
||
user_id=current_user.id,
|
||
provider=body.provider,
|
||
name=body.name,
|
||
data_types=body.data_types,
|
||
prompt_template=body.prompt_template,
|
||
filter_config=body.filter_config,
|
||
schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE,
|
||
auto_trash_spam=body.auto_trash_spam,
|
||
enabled=True,
|
||
)
|
||
db.add(scout)
|
||
await db.commit()
|
||
await db.refresh(scout)
|
||
return _to_cloud_response(scout)
|
||
|
||
|
||
@router.put("/cloud/{scout_id}", response_model=CloudScoutResponse)
|
||
async def update_cloud_scout(
|
||
scout_id: str,
|
||
body: CloudScoutUpdateRequest,
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
scout = await db.get(CloudScoutConfig, scout_id)
|
||
if scout is None or scout.user_id != current_user.id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
|
||
if body.name is not None:
|
||
scout.name = body.name
|
||
if body.data_types is not None:
|
||
scout.data_types = body.data_types
|
||
if body.prompt_template is not None:
|
||
scout.prompt_template = body.prompt_template
|
||
if body.schedule_cron is not None:
|
||
scout.schedule_cron = body.schedule_cron
|
||
if body.filter_config is not None:
|
||
scout.filter_config = body.filter_config
|
||
if body.auto_trash_spam is not None:
|
||
scout.auto_trash_spam = body.auto_trash_spam
|
||
if body.enabled is not None:
|
||
scout.enabled = body.enabled
|
||
await db.commit()
|
||
await db.refresh(scout)
|
||
return _to_cloud_response(scout)
|
||
|
||
|
||
@router.delete("/cloud/{scout_id}")
|
||
async def delete_cloud_scout(
|
||
scout_id: str,
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
scout = await db.get(CloudScoutConfig, scout_id)
|
||
if scout is None or scout.user_id != current_user.id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
|
||
await db.delete(scout)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
```
|
||
|
||
Ensure imports at the top of `scouts.py` include: `uuid`, `select` (from sqlalchemy), and the three new schemas `CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse`. Add to the existing `from app.schemas import (...)` block and `from sqlalchemy import select` if missing.
|
||
|
||
- [ ] **Step 4: Run the tests**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_cloud_crud.py -v
|
||
```
|
||
Expected: 4 PASS. If the `get_session` patch target is wrong, fix it and re-run.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py
|
||
git commit -m "feat(scouts): add cloud scout CRUD routes + serializer"
|
||
```
|
||
|
||
---
|
||
|
||
# PHASE B — gmail_address + connector additions
|
||
|
||
## Task 3: Alembic 009 — gmail_address column + model field
|
||
|
||
**Files:**
|
||
- Create: `api/alembic/versions/009_cloud_scout_gmail_address.py`
|
||
- Modify: `api/app/models.py`
|
||
|
||
- [ ] **Step 1: Create the migration**
|
||
|
||
```python
|
||
"""Add gmail_address to cloud_scout_configs.
|
||
|
||
Revision ID: 009
|
||
Revises: 008
|
||
Create Date: 2026-05-16
|
||
"""
|
||
|
||
from typing import Sequence, Union
|
||
|
||
import sqlalchemy as sa
|
||
from alembic import op
|
||
|
||
|
||
revision: str = "009"
|
||
down_revision: Union[str, None] = "008"
|
||
branch_labels: Union[str, Sequence[str], None] = None
|
||
depends_on: Union[str, Sequence[str], None] = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.add_column("cloud_scout_configs", sa.Column("gmail_address", sa.String(320), nullable=True))
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_column("cloud_scout_configs", "gmail_address")
|
||
```
|
||
|
||
- [ ] **Step 2: Verify revision graph**
|
||
|
||
```bash
|
||
cd api
|
||
python -c "from alembic.script import ScriptDirectory; from alembic.config import Config; sd = ScriptDirectory.from_config(Config('alembic.ini')); print([s.revision for s in sd.walk_revisions()][:3])"
|
||
```
|
||
Expected: `['009', '008', '007']`.
|
||
|
||
- [ ] **Step 3: Add the model field**
|
||
|
||
In `api/app/models.py`, inside `CloudScoutConfig` (after `device_inactivity_pause_days`):
|
||
|
||
```python
|
||
gmail_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||
```
|
||
|
||
- [ ] **Step 4: Run scout tests to confirm create_all still works**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_cloud_crud.py tests/test_scout_engine.py -v
|
||
```
|
||
Expected: green (the test DB uses `create_all`, picks up the new column).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add alembic/versions/009_cloud_scout_gmail_address.py app/models.py
|
||
git commit -m "feat(scouts): add gmail_address column to cloud_scout_configs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Persist gmail_address on OAuth callback
|
||
|
||
**Files:**
|
||
- Modify: `api/app/api/routes/scouts.py` (the `scout_gmail_oauth_callback` handler, lines 370-440)
|
||
|
||
**Context:** After token exchange, fetch the Gmail profile to get `emailAddress` and store it. The connector's `_get_gmail_service` can build a service, but at callback time the token is freshly minted — simplest is to call Gmail's `getProfile` via a one-off service built from `creds_dict`.
|
||
|
||
- [ ] **Step 1: Add gmail_address persistence after token storage**
|
||
|
||
In `scout_gmail_oauth_callback`, after `scout.oauth_token_encrypted = encrypted` and before the first `await db.commit()`, add:
|
||
|
||
```python
|
||
# Fetch the connected Gmail address for display.
|
||
try:
|
||
import asyncio
|
||
from googleapiclient.discovery import build
|
||
from google.oauth2.credentials import Credentials
|
||
|
||
def _fetch_email() -> str | None:
|
||
creds = Credentials(
|
||
token=creds_dict["token"],
|
||
refresh_token=creds_dict.get("refresh_token"),
|
||
token_uri=creds_dict["token_uri"],
|
||
client_id=creds_dict["client_id"],
|
||
client_secret=creds_dict["client_secret"],
|
||
scopes=creds_dict["scopes"],
|
||
)
|
||
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
||
profile = service.users().getProfile(userId="me").execute()
|
||
return profile.get("emailAddress")
|
||
|
||
scout.gmail_address = await asyncio.to_thread(_fetch_email)
|
||
except Exception:
|
||
logger.exception("failed to fetch gmail address for scout %s", scout_id)
|
||
```
|
||
|
||
- [ ] **Step 2: Verify the handler still imports/compiles**
|
||
|
||
```bash
|
||
cd api
|
||
python -c "import app.api.routes.scouts; print('ok')"
|
||
```
|
||
Expected: `ok`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/api/routes/scouts.py
|
||
git commit -m "feat(scouts): persist connected gmail_address on oauth callback"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: GmailConnector.list_labels + stop_watch
|
||
|
||
**Files:**
|
||
- Modify: `api/app/scouts/connectors/gmail.py`
|
||
- Modify: `api/tests/test_scout_connectors_gmail.py`
|
||
|
||
**Context:** `_get_gmail_service(scout)` (line 48) returns a sync Google API service. Mirror the existing `archive` / `fetch_metadata` `asyncio.to_thread(_sync)` pattern.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `api/tests/test_scout_connectors_gmail.py`:
|
||
|
||
```python
|
||
@pytest.mark.asyncio
|
||
async def test_list_labels_returns_id_and_name():
|
||
scout = _make_scout()
|
||
conn = GmailConnector()
|
||
fake = {"labels": [
|
||
{"id": "INBOX", "name": "INBOX", "type": "system"},
|
||
{"id": "Label_1", "name": "Work", "type": "user"},
|
||
]}
|
||
with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
|
||
mock_svc.return_value.users().labels().list().execute.return_value = fake
|
||
labels = await conn.list_labels(scout)
|
||
assert {"id": "INBOX", "name": "INBOX"} in labels
|
||
assert {"id": "Label_1", "name": "Work"} in labels
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_watch_calls_stop():
|
||
scout = _make_scout()
|
||
conn = GmailConnector()
|
||
with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
|
||
await conn.stop_watch(scout)
|
||
mock_svc.return_value.users().stop.assert_called()
|
||
```
|
||
|
||
(`_make_scout` already exists in this test file from Phase 3.)
|
||
|
||
- [ ] **Step 2: Run to confirm fail**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_connectors_gmail.py -k "list_labels or stop_watch" -v
|
||
```
|
||
Expected: AttributeError (methods missing).
|
||
|
||
- [ ] **Step 3: Implement the two methods**
|
||
|
||
Add to the `GmailConnector` class in `api/app/scouts/connectors/gmail.py`:
|
||
|
||
```python
|
||
async def list_labels(self, scout) -> list[dict]:
|
||
"""Return the account's Gmail labels as [{id, name}]. Empty if no token."""
|
||
if not scout.oauth_token_encrypted:
|
||
return []
|
||
|
||
def _sync() -> list[dict]:
|
||
service = _get_gmail_service(scout)
|
||
resp = service.users().labels().list(userId="me").execute()
|
||
return [{"id": lbl["id"], "name": lbl["name"]} for lbl in resp.get("labels", [])]
|
||
|
||
return await asyncio.to_thread(_sync)
|
||
|
||
async def stop_watch(self, scout) -> None:
|
||
"""Stop Gmail push notifications. Swallows errors (watch may be gone)."""
|
||
if not scout.oauth_token_encrypted:
|
||
return
|
||
|
||
def _sync() -> None:
|
||
service = _get_gmail_service(scout)
|
||
service.users().stop(userId="me").execute()
|
||
|
||
try:
|
||
await asyncio.to_thread(_sync)
|
||
except Exception:
|
||
logger.exception("stop_watch failed for scout %s", scout.id)
|
||
```
|
||
|
||
(`asyncio` and `logger` are already imported in this file.)
|
||
|
||
- [ ] **Step 4: Run the tests**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_connectors_gmail.py -v
|
||
```
|
||
Expected: all PASS (3 existing + 2 new).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/scouts/connectors/gmail.py tests/test_scout_connectors_gmail.py
|
||
git commit -m "feat(scouts): add GmailConnector list_labels + stop_watch"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Gmail label-list + disconnect routes
|
||
|
||
**Files:**
|
||
- Modify: `api/app/api/routes/scouts.py`
|
||
- Test: `api/tests/test_scout_cloud_crud.py` (append)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Append to `api/tests/test_scout_cloud_crud.py`:
|
||
|
||
```python
|
||
@pytest.mark.asyncio
|
||
async def test_gmail_labels_route_returns_labels():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
created = (await client.post(
|
||
"/api/v1/scouts/cloud",
|
||
json={"name": "L", "provider": "gmail"},
|
||
headers=_auth_headers(),
|
||
)).json()
|
||
sid = created["id"]
|
||
|
||
with patch("app.api.routes.scouts.get_connector") as mock_get:
|
||
from unittest.mock import AsyncMock
|
||
mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}])
|
||
resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers())
|
||
assert resp.status_code == 200
|
||
assert resp.json() == [{"id": "INBOX", "name": "INBOX"}]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_gmail_disconnect_clears_token():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
created = (await client.post(
|
||
"/api/v1/scouts/cloud",
|
||
json={"name": "D", "provider": "gmail"},
|
||
headers=_auth_headers(),
|
||
)).json()
|
||
sid = created["id"]
|
||
# mark it connected directly in the DB
|
||
async with _TestSessionLocal() as session:
|
||
row = await session.get(CloudScoutConfig, sid)
|
||
row.oauth_token_encrypted = "blob"
|
||
row.gmail_address = "a@b.com"
|
||
await session.commit()
|
||
|
||
with patch("app.api.routes.scouts.get_connector") as mock_get:
|
||
from unittest.mock import AsyncMock
|
||
mock_get.return_value.stop_watch = AsyncMock()
|
||
resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers())
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["oauth_connected"] is False
|
||
assert body["gmail_address"] is None
|
||
assert body["enabled"] is False
|
||
```
|
||
|
||
- [ ] **Step 2: Run to confirm fail**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_cloud_crud.py -k "labels or disconnect" -v
|
||
```
|
||
Expected: 404.
|
||
|
||
- [ ] **Step 3: Add the routes**
|
||
|
||
Add to `api/app/api/routes/scouts.py` (after the cloud CRUD routes). Ensure `from app.scouts.connectors.registry import get_connector` is imported at module top (or import inside the handler):
|
||
|
||
```python
|
||
@router.get("/cloud/{scout_id}/gmail-labels")
|
||
async def list_gmail_labels(
|
||
scout_id: str,
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
scout = await db.get(CloudScoutConfig, scout_id)
|
||
if scout is None or scout.user_id != current_user.id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
|
||
try:
|
||
connector = get_connector("gmail")
|
||
except KeyError:
|
||
return []
|
||
return await connector.list_labels(scout)
|
||
|
||
|
||
@router.post("/cloud/{scout_id}/gmail-disconnect", response_model=CloudScoutResponse)
|
||
async def disconnect_gmail(
|
||
scout_id: str,
|
||
db: AsyncSession = Depends(get_session),
|
||
current_user: UserProfile = Depends(get_current_user),
|
||
):
|
||
scout = await db.get(CloudScoutConfig, scout_id)
|
||
if scout is None or scout.user_id != current_user.id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
|
||
try:
|
||
connector = get_connector("gmail")
|
||
await connector.stop_watch(scout)
|
||
except KeyError:
|
||
pass
|
||
scout.oauth_token_encrypted = None
|
||
scout.gmail_history_id = None
|
||
scout.gmail_watch_expires_at = None
|
||
scout.gmail_address = None
|
||
scout.enabled = False
|
||
await db.commit()
|
||
await db.refresh(scout)
|
||
return _to_cloud_response(scout)
|
||
```
|
||
|
||
Add `from app.scouts.connectors.registry import get_connector` to the imports if not present.
|
||
|
||
- [ ] **Step 4: Run the tests**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_scout_cloud_crud.py -v
|
||
```
|
||
Expected: all PASS (6 total).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py
|
||
git commit -m "feat(scouts): add gmail label-list + disconnect routes"
|
||
```
|
||
|
||
---
|
||
|
||
# PHASE C — tRPC + shared types
|
||
|
||
## Task 7: Shared CloudScoutConfig type fields
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/shared/api-types.ts`
|
||
|
||
- [ ] **Step 1: Extend `CloudScoutConfigSchema`**
|
||
|
||
In `adiuvAI/src/shared/api-types.ts`, update the schema (currently ends with `oauthConnected: z.boolean().optional()`):
|
||
|
||
```typescript
|
||
export const CloudScoutConfigSchema = z.object({
|
||
id: z.string(),
|
||
userId: z.string(),
|
||
provider: z.enum(['gmail', 'teams', 'outlook']),
|
||
name: z.string(),
|
||
dataTypes: z.array(z.string()),
|
||
promptTemplate: z.string(),
|
||
scheduleCron: z.string(),
|
||
filterConfig: z.object({
|
||
labels: z.array(z.string()).optional(),
|
||
senders: z.array(z.string()).optional(),
|
||
}).optional(),
|
||
autoTrashSpam: z.boolean().optional(),
|
||
enabled: z.boolean(),
|
||
lastRunAt: z.number().int().nullable().optional(),
|
||
gmailAddress: z.string().nullable().optional(),
|
||
oauthConnected: z.boolean().optional(),
|
||
createdAt: z.number().int(),
|
||
updatedAt: z.number().int(),
|
||
});
|
||
export type CloudScoutConfig = z.infer<typeof CloudScoutConfigSchema>;
|
||
```
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep "api-types" || echo "no api-types errors"
|
||
```
|
||
Expected: `no api-types errors`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/shared/api-types.ts
|
||
git commit -m "feat(scouts): add cloud scout config fields to shared type"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: tRPC cloud router — input changes + new procedures
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/main/router/index.ts` (the `scoutCloudRouter`, lines 1155-1239)
|
||
|
||
- [ ] **Step 1: Update `create` input — schedule optional, add autoTrashSpam**
|
||
|
||
Replace the `create` procedure's input schema:
|
||
|
||
```typescript
|
||
create: publicProcedure
|
||
.input(z.object({
|
||
name: z.string(),
|
||
provider: z.enum(['gmail', 'teams', 'outlook']),
|
||
dataTypes: z.array(z.string()).default([]),
|
||
promptTemplate: z.string().default(''),
|
||
scheduleCron: z.string().optional(),
|
||
filterConfig: z.object({
|
||
labels: z.array(z.string()).optional(),
|
||
senders: z.array(z.string()).optional(),
|
||
}).optional(),
|
||
autoTrashSpam: z.boolean().optional(),
|
||
}))
|
||
.mutation(async ({ input }) => {
|
||
try {
|
||
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
|
||
'/api/v1/scouts/cloud',
|
||
input as Record<string, unknown>,
|
||
);
|
||
return { data: result, error: null };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Failed to create cloud scout';
|
||
return { data: null, error: msg };
|
||
}
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 2: Update `update` input — add autoTrashSpam, type filterConfig**
|
||
|
||
```typescript
|
||
update: publicProcedure
|
||
.input(z.object({
|
||
id: z.string(),
|
||
name: z.string().optional(),
|
||
dataTypes: z.array(z.string()).optional(),
|
||
promptTemplate: z.string().optional(),
|
||
scheduleCron: z.string().optional(),
|
||
filterConfig: z.object({
|
||
labels: z.array(z.string()).optional(),
|
||
senders: z.array(z.string()).optional(),
|
||
}).optional(),
|
||
autoTrashSpam: z.boolean().optional(),
|
||
enabled: z.boolean().optional(),
|
||
}))
|
||
.mutation(async ({ input }) => {
|
||
const { id, ...updates } = input;
|
||
try {
|
||
const result = await getBackendClient().proxyPut<CloudScoutConfig>(
|
||
`/api/v1/scouts/cloud/${id}`,
|
||
updates as Record<string, unknown>,
|
||
);
|
||
return { data: result, error: null };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Failed to update cloud scout';
|
||
return { data: null, error: msg };
|
||
}
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 3: Add `gmailLabels` + `disconnectGmail` procedures**
|
||
|
||
Add inside `scoutCloudRouter`, after `completeGmailOAuth`:
|
||
|
||
```typescript
|
||
gmailLabels: publicProcedure
|
||
.input(z.object({ scoutId: z.string() }))
|
||
.query(async ({ input }) => {
|
||
try {
|
||
return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
|
||
`/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`,
|
||
);
|
||
} catch (err) {
|
||
console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err);
|
||
return [];
|
||
}
|
||
}),
|
||
|
||
disconnectGmail: publicProcedure
|
||
.input(z.object({ scoutId: z.string() }))
|
||
.mutation(async ({ input }) => {
|
||
try {
|
||
const result = await getBackendClient().proxyPost<CloudScoutConfig>(
|
||
`/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`,
|
||
{},
|
||
);
|
||
return { data: result, error: null };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail';
|
||
return { data: null, error: msg };
|
||
}
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep "router/index" || echo "no router errors"
|
||
```
|
||
Expected: `no router errors`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/main/router/index.ts
|
||
git commit -m "feat(scouts): tRPC cloud input changes + gmailLabels + disconnectGmail"
|
||
```
|
||
|
||
---
|
||
|
||
# PHASE D — Electron UI
|
||
|
||
## Task 9: Extract LocalScoutCreationFlow from the stepper
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx`
|
||
- Modify: `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx`
|
||
|
||
**Context:** This is a pure refactor — move the current local 3-step body (config + review for `local_directory`) into its own component without behavior change. The stepper keeps Step 1 (template pick) and delegates. This isolates the local flow before the cloud flow is added, keeping each file focused.
|
||
|
||
- [ ] **Step 1: Create `LocalScoutCreationFlow.tsx`**
|
||
|
||
Move the local-specific logic (directory picker, data-types, schedule, prompt builder, review, create-local mutation) out of `InlineScoutCreationStepper.tsx` into a new component:
|
||
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import { FolderOpen, X, Sparkles } from 'lucide-react';
|
||
import { trpc } from '@/lib/trpc';
|
||
import { useNotify } from '@/hooks/useNotify';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
|
||
import { PromptBuilderChat } from './PromptBuilderChat';
|
||
|
||
export function LocalScoutCreationFlow({
|
||
template,
|
||
onCancel,
|
||
onCreated,
|
||
}: {
|
||
template: AgentCatalogItem;
|
||
onCancel: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const createLocalMutation = trpc.scout.local.create.useMutation();
|
||
const { notify, notifyError } = useNotify();
|
||
|
||
const [step, setStep] = useState<2 | 3>(2);
|
||
const [name, setName] = useState(template.name);
|
||
const [directory, setDirectory] = useState('');
|
||
const [promptDialogOpen, setPromptDialogOpen] = useState(false);
|
||
const [dataTypes, setDataTypes] = useState<string[]>((template.supportedDataTypes ?? []).slice(0, 2));
|
||
const [schedule, setSchedule] = useState('0 * * * *');
|
||
const [promptTemplate, setPromptTemplate] = useState('');
|
||
const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | null>(null);
|
||
const [error, setError] = useState('');
|
||
|
||
const isSubmitting = createLocalMutation.isPending;
|
||
|
||
async function pickDirectory() {
|
||
try {
|
||
const result = await window.electronDialog.showOpenDialog({
|
||
properties: ['openDirectory'],
|
||
title: 'Select directory for scout to watch',
|
||
});
|
||
if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!);
|
||
} catch { /* noop */ }
|
||
}
|
||
|
||
function toggleDataType(type: string) {
|
||
setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]);
|
||
}
|
||
|
||
function nextFromConfig() {
|
||
if (!name.trim()) { setError('Scout name is required.'); return; }
|
||
if (!directory) { setError('Select a directory.'); return; }
|
||
if (dataTypes.length === 0) { setError('Select at least one data type.'); return; }
|
||
setError(''); setStep(3);
|
||
}
|
||
|
||
function handleCreate() {
|
||
setError('');
|
||
createLocalMutation.mutate(
|
||
{ name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null },
|
||
{
|
||
onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); },
|
||
onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); },
|
||
},
|
||
);
|
||
}
|
||
|
||
const unlocked = !!directory && dataTypes.length > 0;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-7 w-full">
|
||
{step === 2 && (
|
||
<>
|
||
<div className="pb-1">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
|
||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||
Configure<br />
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
|
||
placeholder="scout name."
|
||
spellCheck={false}
|
||
/>
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Point it at your files, pick what to extract, and set the run schedule.</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
|
||
{directory && (
|
||
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
|
||
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
|
||
<span className="flex-1 truncate">{directory}</span>
|
||
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
|
||
<X className="size-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
<Button size="sm" variant="outline" onClick={pickDirectory}>
|
||
<FolderOpen className="size-3.5 mr-1.5" />
|
||
{directory ? 'Change directory' : 'Select directory'}
|
||
</Button>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
|
||
const active = dataTypes.includes(value);
|
||
return (
|
||
<button key={value} onClick={() => toggleDataType(value)}
|
||
className={cn(
|
||
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
|
||
active ? 'bg-foreground text-background border-foreground'
|
||
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
|
||
)}>
|
||
<Icon className="size-3.5" />
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
|
||
<Select value={schedule} onValueChange={setSchedule}>
|
||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{SCHEDULE_OPTIONS.map(opt => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Button variant={promptTemplate || scoutConfig ? 'outline' : 'default'} size="sm" disabled={!unlocked} onClick={() => setPromptDialogOpen(true)}>
|
||
<Sparkles className="size-3.5 mr-1.5" />
|
||
{promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
|
||
</Button>
|
||
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
|
||
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
|
||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||
<PromptBuilderChat
|
||
autoStart
|
||
agentType="local_directory"
|
||
dataTypes={dataTypes}
|
||
directory={directory}
|
||
onPromptUpdate={(p) => setPromptTemplate(p)}
|
||
onConfigUpdate={(c) => setScoutConfig(c)}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
|
||
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>Close</Button>
|
||
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>Confirm</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{step === 3 && (
|
||
<div className="flex flex-col gap-5">
|
||
<div className="pb-1">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
|
||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||
Review and<br />
|
||
<span className="text-muted-foreground/50">create your scout.</span>
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your scout will start running on schedule.</p>
|
||
</div>
|
||
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
|
||
<CardContent className="p-5 flex flex-col gap-4">
|
||
<div className="space-y-1">
|
||
<p className="text-xs text-muted-foreground">Template</p>
|
||
<p className="text-sm font-medium">{template.name}</p>
|
||
</div>
|
||
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
|
||
<p><span className="text-muted-foreground">Name:</span> {name}</p>
|
||
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
|
||
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
|
||
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
|
||
{scoutConfig && <p><span className="text-muted-foreground">Extraction config:</span> Added</p>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||
|
||
<div className="flex items-center justify-between pt-1">
|
||
<Button variant="outline" size="sm" onClick={() => { setError(''); step === 2 ? onCancel() : setStep(2); }}>
|
||
{step === 2 ? 'Cancel' : 'Back'}
|
||
</Button>
|
||
{step === 2 && <Button size="sm" onClick={nextFromConfig}>Next</Button>}
|
||
{step === 3 && <Button size="sm" onClick={handleCreate} disabled={isSubmitting}>Create scout now</Button>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Slim down `InlineScoutCreationStepper.tsx` to a router**
|
||
|
||
Replace the whole file body with the shared template-pick step that delegates:
|
||
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import { Bot } from 'lucide-react';
|
||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||
import { TemplateSelectCard } from './TemplateSelectCard';
|
||
import { LocalScoutCreationFlow } from './LocalScoutCreationFlow';
|
||
import { CloudScoutCreationFlow } from './CloudScoutCreationFlow';
|
||
|
||
export function InlineScoutCreationStepper({
|
||
catalog,
|
||
isLoadingCatalog,
|
||
onCancel,
|
||
onCreated,
|
||
}: {
|
||
catalog: AgentCatalogItem[];
|
||
isLoadingCatalog: boolean;
|
||
onCancel: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);
|
||
|
||
if (selectedTemplate) {
|
||
return selectedTemplate.type === 'local_directory'
|
||
? <LocalScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />
|
||
: <CloudScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />;
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5 w-full">
|
||
<div className="pb-1">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
|
||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||
Choose your<br />
|
||
<span className="text-muted-foreground/50">starting template.</span>
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point — you can customize everything before the scout goes live.</p>
|
||
</div>
|
||
|
||
{isLoadingCatalog && (
|
||
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">Loading templates...</div>
|
||
)}
|
||
|
||
{!isLoadingCatalog && catalog.length === 0 && (
|
||
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
|
||
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
|
||
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{catalog.map((item) => (
|
||
<TemplateSelectCard key={item.id} item={item} selected={false} onSelect={() => setSelectedTemplate(item)} />
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between pt-1">
|
||
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Add the missing `Button` import: `import { Button } from '@/components/ui/button';`.
|
||
|
||
- [ ] **Step 3: Typecheck (CloudScoutCreationFlow not yet created — expect that one import error only)**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep -iE "InlineScoutCreationStepper|LocalScoutCreationFlow" | grep -v "CloudScoutCreationFlow" || echo "only the expected missing-CloudFlow import remains"
|
||
```
|
||
Expected: only the `CloudScoutCreationFlow` missing-module error (resolved in Task 10).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/settings/LocalScoutCreationFlow.tsx src/renderer/components/settings/InlineScoutCreationStepper.tsx
|
||
git commit -m "refactor(scouts): extract LocalScoutCreationFlow, stepper becomes router"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: CloudScoutCreationFlow (Gmail slim flow)
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx`
|
||
|
||
**Context:** Two steps. Step A: name + focus + auto-trash + Connect Gmail (creates dormant scout, runs OAuth). Step B (after connect): label multi-select + sender chips + Finish. The OAuth deep-link callback is delivered via `window.electronAI.onScoutGmailOAuthCallback` (already wired in Phase 3, used by `CloudScoutConfigPanel`).
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```typescript
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { Mail, X, Plus } from 'lucide-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { trpc } from '@/lib/trpc';
|
||
import { useNotify } from '@/hooks/useNotify';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import type { AgentCatalogItem } from '../../../shared/api-types';
|
||
|
||
export function CloudScoutCreationFlow({
|
||
template,
|
||
onCancel,
|
||
onCreated,
|
||
}: {
|
||
template: AgentCatalogItem;
|
||
onCancel: () => void;
|
||
onCreated: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const { notify, notifyError } = useNotify();
|
||
|
||
const createMutation = trpc.scout.cloud.create.useMutation();
|
||
const updateMutation = trpc.scout.cloud.update.useMutation();
|
||
const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
|
||
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
|
||
|
||
const [step, setStep] = useState<'basics' | 'filter'>('basics');
|
||
const [name, setName] = useState(template.name);
|
||
const [focus, setFocus] = useState('');
|
||
const [autoTrash, setAutoTrash] = useState(false);
|
||
const [scoutId, setScoutId] = useState<string | null>(null);
|
||
const [connecting, setConnecting] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
const [labels, setLabels] = useState<string[]>([]);
|
||
const [senderInput, setSenderInput] = useState('');
|
||
const [senders, setSenders] = useState<string[]>([]);
|
||
|
||
const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
|
||
{ scoutId: scoutId ?? '' },
|
||
{ enabled: step === 'filter' && !!scoutId },
|
||
);
|
||
|
||
const scoutIdRef = useRef<string | null>(null);
|
||
scoutIdRef.current = scoutId;
|
||
|
||
// OAuth deep-link callback → complete, then advance to filter step.
|
||
useEffect(() => {
|
||
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
|
||
if (!electronAI?.onScoutGmailOAuthCallback) return;
|
||
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
|
||
try {
|
||
await completeOAuth.mutateAsync({ code, state });
|
||
notify('success', 'toast.scout.gmailConnected');
|
||
setConnecting(false);
|
||
setStep('filter');
|
||
} catch (err) {
|
||
setConnecting(false);
|
||
notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
|
||
}
|
||
});
|
||
return () => { off?.(); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
async function handleConnect() {
|
||
if (!name.trim()) { setError(t('scouts.nameRequired')); return; }
|
||
setError('');
|
||
setConnecting(true);
|
||
try {
|
||
const res = await createMutation.mutateAsync({
|
||
name,
|
||
provider: 'gmail',
|
||
dataTypes: [],
|
||
promptTemplate: focus,
|
||
autoTrashSpam: autoTrash,
|
||
filterConfig: {},
|
||
});
|
||
if (res.error || !res.data) throw new Error(res.error ?? 'create failed');
|
||
setScoutId(res.data.id);
|
||
await startOAuth.mutateAsync({ scoutId: res.data.id });
|
||
// Wait for the deep-link callback (handled in the effect).
|
||
} catch (err) {
|
||
setConnecting(false);
|
||
setError(err instanceof Error ? err.message : 'Failed to connect');
|
||
}
|
||
}
|
||
|
||
function toggleLabel(id: string) {
|
||
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
|
||
}
|
||
|
||
function addSender() {
|
||
const v = senderInput.trim();
|
||
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
|
||
setSenderInput('');
|
||
}
|
||
|
||
async function handleFinish() {
|
||
if (!scoutId) return;
|
||
try {
|
||
await updateMutation.mutateAsync({
|
||
id: scoutId,
|
||
filterConfig: { labels, senders },
|
||
});
|
||
notify('success', 'toast.scout.created');
|
||
onCreated();
|
||
} catch (err) {
|
||
notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
|
||
}
|
||
}
|
||
|
||
if (step === 'basics') {
|
||
return (
|
||
<div className="flex flex-col gap-7 w-full">
|
||
<div className="pb-1">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
|
||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||
Configure<br />
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
|
||
placeholder={t('scouts.namePlaceholder')}
|
||
spellCheck={false}
|
||
/>
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.gmailBasicsHint')}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.focusLabel')}</label>
|
||
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
|
||
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
|
||
</div>
|
||
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
|
||
</div>
|
||
|
||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||
|
||
<div className="flex items-center justify-between pt-1">
|
||
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
|
||
<Button size="sm" onClick={handleConnect} disabled={connecting}>
|
||
<Mail className="size-3.5 mr-1.5" />
|
||
{connecting ? t('scouts.connecting') : t('scouts.connectGmail')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// step === 'filter'
|
||
return (
|
||
<div className="flex flex-col gap-7 w-full">
|
||
<div className="pb-1">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
|
||
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
|
||
Narrow the<br />
|
||
<span className="text-muted-foreground/50">emails it watches.</span>
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.filterHint')}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterLabels')}</label>
|
||
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels…</p>}
|
||
<div className="flex flex-wrap gap-3">
|
||
{(labelsQuery.data ?? []).map(lbl => (
|
||
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
|
||
<span>{lbl.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterSenders')}</label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
value={senderInput}
|
||
onChange={(e) => setSenderInput(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
|
||
placeholder={t('scouts.filterSendersPlaceholder')}
|
||
/>
|
||
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
|
||
</div>
|
||
{senders.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mt-2">
|
||
{senders.map(s => (
|
||
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
|
||
{s}
|
||
<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground mt-2">{t('scouts.watchAllInbox')}</p>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end gap-2 pt-1">
|
||
<Button variant="outline" size="sm" onClick={handleFinish}>{t('scouts.skipFilter')}</Button>
|
||
<Button size="sm" onClick={handleFinish} disabled={updateMutation.isPending}>{t('scouts.finish')}</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
If `Switch` isn't an existing shadcn component, add it: `cd adiuvAI && npx shadcn@latest add switch`. Verify first with `ls src/renderer/components/ui/switch.tsx`.
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep -iE "CloudScoutCreationFlow|InlineScoutCreationStepper" || echo "no creation-flow errors"
|
||
```
|
||
Expected: `no creation-flow errors` (i18n keys are runtime, not typechecked).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/settings/CloudScoutCreationFlow.tsx src/renderer/components/ui/switch.tsx
|
||
git commit -m "feat(scouts): add CloudScoutCreationFlow Gmail slim flow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Rewrite CloudScoutConfigPanel for parity
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx`
|
||
|
||
- [ ] **Step 1: Replace the panel body**
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { Mail, X, Plus } from 'lucide-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { trpc } from '@/lib/trpc';
|
||
import { useNotify } from '@/hooks/useNotify';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import type { CloudScoutConfig } from '../../../shared/api-types';
|
||
|
||
export function CloudScoutConfigPanel({
|
||
scout,
|
||
}: {
|
||
scout: CloudScoutConfig & { scoutType: 'cloud' };
|
||
onOpenJourney: () => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const utils = trpc.useUtils();
|
||
const updateMutation = trpc.scout.cloud.update.useMutation();
|
||
const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
|
||
const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
|
||
const disconnect = trpc.scout.cloud.disconnectGmail.useMutation();
|
||
const { notify, notifyError } = useNotify();
|
||
|
||
const [focus, setFocus] = useState(scout.promptTemplate ?? '');
|
||
const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false);
|
||
const [labels, setLabels] = useState<string[]>(scout.filterConfig?.labels ?? []);
|
||
const [senders, setSenders] = useState<string[]>(scout.filterConfig?.senders ?? []);
|
||
const [senderInput, setSenderInput] = useState('');
|
||
|
||
const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
|
||
{ scoutId: scout.id },
|
||
{ enabled: !!scout.oauthConnected },
|
||
);
|
||
|
||
useEffect(() => {
|
||
const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
|
||
if (!electronAI?.onScoutGmailOAuthCallback) return;
|
||
const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
|
||
try {
|
||
await completeOAuth.mutateAsync({ code, state });
|
||
notify('success', 'toast.scout.gmailConnected');
|
||
void utils.scout.cloud.list.invalidate();
|
||
} catch (err) {
|
||
notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
|
||
}
|
||
});
|
||
return () => { off?.(); };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
function toggleLabel(id: string) {
|
||
setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
|
||
}
|
||
function addSender() {
|
||
const v = senderInput.trim();
|
||
if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
|
||
setSenderInput('');
|
||
}
|
||
|
||
function handleSave() {
|
||
updateMutation.mutate(
|
||
{ id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } },
|
||
{
|
||
onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
|
||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||
},
|
||
);
|
||
}
|
||
|
||
function handleDisconnect() {
|
||
disconnect.mutate({ scoutId: scout.id }, {
|
||
onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
|
||
onError: (err) => notifyError('toast.scout.updateError', err),
|
||
});
|
||
}
|
||
|
||
const connected = !!scout.oauthConnected;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline" className="capitalize">{scout.provider}</Badge>
|
||
</div>
|
||
|
||
{/* Connection status */}
|
||
{connected ? (
|
||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||
<Mail className="size-4 text-muted-foreground" />
|
||
<span className="text-xs flex-1">{t('scouts.connectedAs')} <span className="font-medium">{scout.gmailAddress ?? '—'}</span></span>
|
||
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })}>{t('scouts.reconnect')}</Button>
|
||
<Button size="sm" variant="ghost" onClick={handleDisconnect} disabled={disconnect.isPending}>{t('scouts.disconnect')}</Button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
|
||
<span className="text-xs text-amber-700 dark:text-amber-400 flex-1">{t('scouts.gmailAccessRequired')}</span>
|
||
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })} disabled={startOAuth.isPending}>
|
||
{t('scouts.connectGmail')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Focus */}
|
||
<div>
|
||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.focusLabel')}</label>
|
||
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
|
||
</div>
|
||
|
||
{/* Filter — labels */}
|
||
{connected && (
|
||
<div>
|
||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterLabels')}</label>
|
||
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels…</p>}
|
||
<div className="flex flex-wrap gap-3">
|
||
{(labelsQuery.data ?? []).map(lbl => (
|
||
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
|
||
<span>{lbl.name}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter — senders */}
|
||
<div>
|
||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterSenders')}</label>
|
||
<div className="flex gap-2">
|
||
<Input value={senderInput} onChange={(e) => setSenderInput(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
|
||
placeholder={t('scouts.filterSendersPlaceholder')} />
|
||
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
|
||
</div>
|
||
{senders.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mt-2">
|
||
{senders.map(s => (
|
||
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
|
||
{s}<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Auto-trash */}
|
||
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
|
||
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
|
||
</div>
|
||
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
|
||
</div>
|
||
|
||
<div className="flex items-center justify-end pt-1">
|
||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>{t('common.save')}</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Note: `onOpenJourney` is kept in the prop type for compatibility with `ScoutRow` but no longer used — verify `ScoutRow` still passes it; if `ScoutRow` errors on an unused prop, leave the prop in the signature (it's accepted, just ignored). If TypeScript complains about the unused prop, prefix with `_`: `onOpenJourney: _onOpenJourney`. Simplest: keep the prop in the destructure but don't reference it — TS won't error on an unused destructured prop.
|
||
|
||
- [ ] **Step 2: Typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep -i "CloudScoutConfigPanel" || echo "no panel errors"
|
||
```
|
||
Expected: `no panel errors`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/settings/CloudScoutConfigPanel.tsx
|
||
git commit -m "refactor(scouts): rewrite CloudScoutConfigPanel for slim-model parity"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Disable Teams/Outlook template cards
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx`
|
||
|
||
- [ ] **Step 1: Read the card to find the catalog-item type discriminator**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
cat src/renderer/components/settings/TemplateSelectCard.tsx
|
||
```
|
||
Note the `item` prop shape and how `onSelect` is wired. Cloud non-Gmail providers are identified by `item.provider === 'teams' || item.provider === 'outlook'` (or `item.type` — confirm from the file).
|
||
|
||
- [ ] **Step 2: Gate selection + add "coming soon"**
|
||
|
||
In the card component, compute:
|
||
```typescript
|
||
const comingSoon = item.provider === 'teams' || item.provider === 'outlook';
|
||
```
|
||
Apply to the root element: when `comingSoon`, add `opacity-50 pointer-events-none` to the className and render a small badge `{comingSoon && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('scouts.comingSoon')}</span>}` near the title. Guard `onSelect`: `onClick={comingSoon ? undefined : onSelect}`.
|
||
|
||
Use `useTranslation` if not already imported. Confirm the actual field (`item.provider` vs `item.type`) from Step 1 and use the correct one.
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep -i "TemplateSelectCard" || echo "no card errors"
|
||
```
|
||
Expected: `no card errors`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/settings/TemplateSelectCard.tsx
|
||
git commit -m "feat(scouts): disable Teams/Outlook template cards (coming soon)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: i18n keys (5 languages)
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json`
|
||
|
||
- [ ] **Step 1: Add the keys under the `scouts` namespace in each file**
|
||
|
||
English (`en/translation.json`), add to the `scouts` object:
|
||
```json
|
||
"nameRequired": "Scout name is required.",
|
||
"namePlaceholder": "scout name.",
|
||
"gmailBasicsHint": "Name it, tell it what to focus on, then connect your Gmail.",
|
||
"focusLabel": "What should this scout watch for?",
|
||
"focusPlaceholder": "e.g. client requests, invoices, project updates",
|
||
"autoTrashSpam": "Auto-trash spam",
|
||
"autoTrashHint": "Move emails it judges as spam to Gmail Trash (recoverable for 30 days).",
|
||
"connecting": "Connecting…",
|
||
"filterHint": "Leave empty to watch your whole inbox, or narrow by label and sender.",
|
||
"filterLabels": "Labels",
|
||
"filterSenders": "Senders",
|
||
"filterSendersPlaceholder": "alice@example.com or @client.co",
|
||
"watchAllInbox": "No filter = watch the whole inbox.",
|
||
"skipFilter": "Skip",
|
||
"finish": "Finish",
|
||
"connectedAs": "Connected as",
|
||
"reconnect": "Reconnect",
|
||
"disconnect": "Disconnect",
|
||
"gmailAccessRequired": "Gmail access required to start receiving email suggestions.",
|
||
"comingSoon": "Coming soon"
|
||
```
|
||
|
||
Italian (`it`):
|
||
```json
|
||
"nameRequired": "Il nome dello scout è obbligatorio.",
|
||
"namePlaceholder": "nome scout.",
|
||
"gmailBasicsHint": "Dagli un nome, indica su cosa concentrarsi, poi connetti Gmail.",
|
||
"focusLabel": "Su cosa deve vigilare questo scout?",
|
||
"focusPlaceholder": "es. richieste clienti, fatture, aggiornamenti progetto",
|
||
"autoTrashSpam": "Cestina spam automaticamente",
|
||
"autoTrashHint": "Sposta le email giudicate spam nel Cestino di Gmail (recuperabili per 30 giorni).",
|
||
"connecting": "Connessione…",
|
||
"filterHint": "Lascia vuoto per monitorare tutta la casella, o restringi per etichetta e mittente.",
|
||
"filterLabels": "Etichette",
|
||
"filterSenders": "Mittenti",
|
||
"filterSendersPlaceholder": "alice@example.com oppure @client.co",
|
||
"watchAllInbox": "Nessun filtro = monitora tutta la casella.",
|
||
"skipFilter": "Salta",
|
||
"finish": "Fine",
|
||
"connectedAs": "Connesso come",
|
||
"reconnect": "Riconnetti",
|
||
"disconnect": "Disconnetti",
|
||
"gmailAccessRequired": "Accesso a Gmail necessario per ricevere suggerimenti email.",
|
||
"comingSoon": "Prossimamente"
|
||
```
|
||
|
||
Spanish (`es`):
|
||
```json
|
||
"nameRequired": "El nombre del scout es obligatorio.",
|
||
"namePlaceholder": "nombre del scout.",
|
||
"gmailBasicsHint": "Ponle nombre, indícale en qué centrarse y conecta tu Gmail.",
|
||
"focusLabel": "¿Qué debe vigilar este scout?",
|
||
"focusPlaceholder": "p. ej. solicitudes de clientes, facturas, novedades de proyectos",
|
||
"autoTrashSpam": "Enviar spam a la papelera automáticamente",
|
||
"autoTrashHint": "Mueve los correos que considere spam a la Papelera de Gmail (recuperables 30 días).",
|
||
"connecting": "Conectando…",
|
||
"filterHint": "Déjalo vacío para vigilar toda la bandeja, o filtra por etiqueta y remitente.",
|
||
"filterLabels": "Etiquetas",
|
||
"filterSenders": "Remitentes",
|
||
"filterSendersPlaceholder": "alice@example.com o @client.co",
|
||
"watchAllInbox": "Sin filtro = vigila toda la bandeja de entrada.",
|
||
"skipFilter": "Omitir",
|
||
"finish": "Finalizar",
|
||
"connectedAs": "Conectado como",
|
||
"reconnect": "Reconectar",
|
||
"disconnect": "Desconectar",
|
||
"gmailAccessRequired": "Se requiere acceso a Gmail para recibir sugerencias de correo.",
|
||
"comingSoon": "Próximamente"
|
||
```
|
||
|
||
French (`fr`):
|
||
```json
|
||
"nameRequired": "Le nom du scout est requis.",
|
||
"namePlaceholder": "nom du scout.",
|
||
"gmailBasicsHint": "Nommez-le, indiquez sur quoi se concentrer, puis connectez votre Gmail.",
|
||
"focusLabel": "Que doit surveiller ce scout ?",
|
||
"focusPlaceholder": "ex. demandes clients, factures, mises à jour de projet",
|
||
"autoTrashSpam": "Mettre le spam à la corbeille automatiquement",
|
||
"autoTrashHint": "Déplace les e-mails jugés indésirables vers la corbeille Gmail (récupérables 30 jours).",
|
||
"connecting": "Connexion…",
|
||
"filterHint": "Laissez vide pour surveiller toute la boîte, ou filtrez par libellé et expéditeur.",
|
||
"filterLabels": "Libellés",
|
||
"filterSenders": "Expéditeurs",
|
||
"filterSendersPlaceholder": "alice@example.com ou @client.co",
|
||
"watchAllInbox": "Aucun filtre = surveille toute la boîte de réception.",
|
||
"skipFilter": "Ignorer",
|
||
"finish": "Terminer",
|
||
"connectedAs": "Connecté en tant que",
|
||
"reconnect": "Reconnecter",
|
||
"disconnect": "Déconnecter",
|
||
"gmailAccessRequired": "Accès Gmail requis pour recevoir des suggestions d'e-mails.",
|
||
"comingSoon": "Bientôt disponible"
|
||
```
|
||
|
||
German (`de`):
|
||
```json
|
||
"nameRequired": "Scout-Name ist erforderlich.",
|
||
"namePlaceholder": "Scout-Name.",
|
||
"gmailBasicsHint": "Benenne ihn, lege den Fokus fest und verbinde dann dein Gmail.",
|
||
"focusLabel": "Worauf soll dieser Scout achten?",
|
||
"focusPlaceholder": "z. B. Kundenanfragen, Rechnungen, Projekt-Updates",
|
||
"autoTrashSpam": "Spam automatisch in den Papierkorb",
|
||
"autoTrashHint": "Verschiebt als Spam eingestufte E-Mails in den Gmail-Papierkorb (30 Tage wiederherstellbar).",
|
||
"connecting": "Verbinde…",
|
||
"filterHint": "Leer lassen, um den ganzen Posteingang zu überwachen, oder nach Label und Absender filtern.",
|
||
"filterLabels": "Labels",
|
||
"filterSenders": "Absender",
|
||
"filterSendersPlaceholder": "alice@example.com oder @client.co",
|
||
"watchAllInbox": "Kein Filter = überwacht den gesamten Posteingang.",
|
||
"skipFilter": "Überspringen",
|
||
"finish": "Fertig",
|
||
"connectedAs": "Verbunden als",
|
||
"reconnect": "Neu verbinden",
|
||
"disconnect": "Trennen",
|
||
"gmailAccessRequired": "Gmail-Zugriff erforderlich, um E-Mail-Vorschläge zu erhalten.",
|
||
"comingSoon": "Demnächst"
|
||
```
|
||
|
||
Merge these into each file's existing `scouts` object (don't create a second `scouts` key). Keep valid JSON (commas).
|
||
|
||
- [ ] **Step 2: Validate JSON**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
for f in en it es fr de; do node -e "JSON.parse(require('fs').readFileSync('src/renderer/locales/$f/translation.json','utf8')); console.log('$f ok')"; done
|
||
```
|
||
Expected: `en ok` … `de ok`.
|
||
|
||
- [ ] **Step 3: Confirm `common.save` exists** (used by the config panel Save button)
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
node -e "const j=require('./src/renderer/locales/en/translation.json'); console.log(j.common && j.common.save ? 'has common.save' : 'MISSING')"
|
||
```
|
||
If `MISSING`, add `"save": "Save"` (and translations: it `"Salva"`, es `"Guardar"`, fr `"Enregistrer"`, de `"Speichern"`) to the `common` object in each file.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/locales/
|
||
git commit -m "i18n: add cloud scout creation + config keys (5 languages)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Full typecheck + manual smoke
|
||
|
||
**Files:** none (verification)
|
||
|
||
- [ ] **Step 1: Full Electron typecheck**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npx tsc --noEmit 2>&1 | grep -iE "Scout|settings/" || echo "no scout-surface errors"
|
||
```
|
||
Expected: `no scout-surface errors`. Pre-existing unrelated errors (drizzle-executor, ChatChartBlock, etc.) are acceptable.
|
||
|
||
- [ ] **Step 2: Lint**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
npm run lint 2>&1 | tail -5
|
||
```
|
||
Expected: no new errors in touched files.
|
||
|
||
- [ ] **Step 3: Manual smoke (document result, don't block on it)**
|
||
|
||
Start the app, open Settings → Scouts → Create scout → Gmail. Verify: only name + focus + auto-trash on first cloud step; Connect Gmail opens consent; after consent, label/sender filter step appears; Finish creates a scout row showing "Connected as <email>". Open the scout's config panel: focus/filter/auto-trash editable, Disconnect works. Verify Teams/Outlook cards are disabled. Verify local-directory creation is unchanged.
|
||
|
||
---
|
||
|
||
# PHASE E — Submodule bumps
|
||
|
||
## Task 15: Bump submodule pointers
|
||
|
||
- [ ] **Step 1: Confirm both submodules committed**
|
||
|
||
```bash
|
||
cd c:/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git -C api status --short
|
||
git -C adiuvAI status --short
|
||
```
|
||
Expected: clean (all work committed in prior tasks).
|
||
|
||
- [ ] **Step 2: Bump pointers**
|
||
|
||
```bash
|
||
cd c:/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add api adiuvAI
|
||
git commit -m "chore: bump submodules — cloud scout creation flow (Gmail)"
|
||
```
|
||
|
||
---
|
||
|
||
## Plan Self-Review
|
||
|
||
**Spec coverage:**
|
||
- Stepper branch (local untouched, cloud slim) — Tasks 9, 10.
|
||
- OAuth during creation, scout created at connect step — Task 10.
|
||
- Label + sender filter — Tasks 5, 6, 10.
|
||
- Focus → prompt_template → scout_purpose — Tasks 2, 10 (created with `promptTemplate: focus`).
|
||
- Auto-trash toggle — Tasks 1, 2, 10.
|
||
- Config panel parity — Task 11.
|
||
- BE label-list + disconnect routes — Task 6.
|
||
- Serializer `oauthConnected` + `filterConfig` + `gmail_address` — Tasks 2, 4.
|
||
- `gmail_address` column (Alembic 009) — Task 3.
|
||
- Catalog gating Teams/Outlook — Task 12.
|
||
- i18n 5 langs — Task 13.
|
||
- **Added beyond spec (necessary):** foundational cloud CRUD routes (Tasks 1, 2) — the spec assumed these existed; they don't.
|
||
|
||
**Placeholder scan:** no TBDs. Where a real symbol name might differ (e.g. `get_session` patch target, `item.provider` vs `item.type`), the task gives a grep/cat to confirm and adapt — task-time verification, not a placeholder.
|
||
|
||
**Type consistency:** `CloudScoutResponse` (snake_case, BE) ↔ `CloudScoutConfigSchema` (camelCase, TS) fields align: `auto_trash_spam`↔`autoTrashSpam`, `filter_config`↔`filterConfig`, `gmail_address`↔`gmailAddress`, `oauth_connected`↔`oauthConnected`. `filterConfig` shape `{ labels?, senders? }` consistent across Tasks 2, 7, 8, 10, 11. `scout.cloud.create` returns `{ data, error }` — consumed correctly in Task 10 (`res.data.id`).
|
||
|
||
**Scope:** one coherent track — BE foundational CRUD + Gmail connector additions + Electron flow split. Shippable as one PR. Phase 4 (categorization/brief) remains out of scope.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
**Plan complete and saved to** `docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md`. **Two execution options:**
|
||
|
||
**1. Subagent-Driven (recommended)** — fresh subagent per task, review between, fast iteration.
|
||
|
||
**2. Inline Execution** — execute in this session with checkpoints.
|
||
|
||
**Note:** planning uncovered that the backend cloud-CRUD routes never existed (cloud scouts 404 today). Tasks 1–2 build them — larger than the spec implied but required for any cloud scout to function.
|
||
|
||
**Which approach?**
|