From 54a836de5c1cb88f0e22551658a553e1622ac9c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 14:03:12 +0000 Subject: [PATCH] feat(ux): nudges API, reminder scheduling, and handoff docs for local UX work - Add dashboard/nudges.py and GET /api/nudges for proactive action items - Fix POST /api/reminders to schedule APScheduler jobs; add PATCH endpoint - Add CRM follow-up POST endpoint and chat vault attachments in agent_jobs - Add global progress bar shell + skeleton CSS (JS wiring pending) - Document current state, local setup, and phased UX overhaul plan Co-authored-by: Vinayak Raizada --- dashboard/agent_jobs.py | 44 +++++++- dashboard/api.py | 45 +++++++- dashboard/nudges.py | 135 +++++++++++++++++++++++ dashboard/static/app.css | 213 +++++++++++++++++++++++++++++++++++- dashboard/static/index.html | 8 +- docs/CURRENT_STATE.md | 123 +++++++++++++++++++++ docs/LOCAL_SETUP.md | 95 ++++++++++++++++ docs/UX_OVERHAUL_PLAN.md | 165 ++++++++++++++++++++++++++++ tests/test_nudges.py | 45 ++++++++ 9 files changed, 868 insertions(+), 5 deletions(-) create mode 100644 dashboard/nudges.py create mode 100644 docs/CURRENT_STATE.md create mode 100644 docs/LOCAL_SETUP.md create mode 100644 docs/UX_OVERHAUL_PLAN.md create mode 100644 tests/test_nudges.py diff --git a/dashboard/agent_jobs.py b/dashboard/agent_jobs.py index b52f3f5..05f9618 100644 --- a/dashboard/agent_jobs.py +++ b/dashboard/agent_jobs.py @@ -106,6 +106,45 @@ def list_jobs(active_only: bool = False) -> list[dict]: return [_public(j) for j in items[:30]] +def _enrich_message(message: str, world_id: str | None, attachments: list | None) -> str: + if not attachments: + return message + from integrations import object_storage + from memory import vault_documents + + blocks = [message] + for att in attachments[:6]: + if not isinstance(att, dict): + continue + if att.get("type") != "vault": + continue + doc_id = att.get("doc_id") or att.get("id") + if not doc_id: + continue + try: + doc_id = int(doc_id) + except (TypeError, ValueError): + continue + doc = vault_documents.get_document(doc_id) + if not doc: + continue + if world_id and doc.get("world_id") != world_id: + continue + raw = object_storage.get_bytes(doc.get("storage_key") or "") + if not raw: + continue + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + blocks.append(f"\n\n[ATTACHED FILE: {doc.get('github_path') or doc.get('filename') or doc.get('title')} — binary, not inlined]") + continue + path = doc.get("github_path") or doc.get("filename") or doc.get("title") or "file" + if len(text) > 14000: + text = text[:14000] + "\n… (truncated)" + blocks.append(f"\n\n[ATTACHED FILE: {path}]\n```\n{text}\n```") + return "".join(blocks) + + def start_job( *, mode: str, @@ -114,18 +153,21 @@ def start_job( rag_mode: str = "auto", specialist: str = "supervisor", session_id: str | None = None, + attachments: list | None = None, ) -> dict: job_id = _new_id() cancel_event = threading.Event() + enriched_message = _enrich_message(message, world_id, attachments) job = { "id": job_id, "status": "queued", "mode": mode, - "message": message, + "message": enriched_message, "world_id": world_id, "rag_mode": rag_mode, "specialist": specialist, "session_id": session_id, + "attachments": attachments or [], "run_id": None, "phase": "Queued…", "events": [], diff --git a/dashboard/api.py b/dashboard/api.py index 50a694f..0760450 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -238,15 +238,57 @@ def api_goals_update(gid): @bp.route("/reminders", methods=["POST"]) def api_reminders_create(): from agent import store + from scheduler.jobs import schedule_reminder data = request.get_json(silent=True) or {} text = (data.get("text") or "").strip() due_at = (data.get("due_at") or "").strip() if not text or not due_at: return jsonify({"error": "text and due_at are required"}), 400 rid = store.add_reminder(text=text, due_at=due_at, repeat=(data.get("repeat") or None)) + _safe(lambda: schedule_reminder(rid, due_at), None) return jsonify({"id": rid}) +@bp.route("/reminders/", methods=["PATCH"]) +def api_reminders_update(rid): + from agent import store + from scheduler.jobs import cancel_reminder_job, schedule_reminder + data = request.get_json(silent=True) or {} + status = (data.get("status") or "").strip() + if status not in ("pending", "done", "cancelled"): + return jsonify({"error": "status must be pending, done, or cancelled"}), 400 + store.set_reminder_status(rid, status) + if status in ("done", "cancelled"): + _safe(lambda: cancel_reminder_job(rid), None) + elif data.get("due_at"): + due_at = (data.get("due_at") or "").strip() + store.reschedule_reminder(rid, due_at) + _safe(lambda: schedule_reminder(rid, due_at), None) + return jsonify({"ok": True, "id": rid, "status": status}) + + +@bp.route("/nudges") +def api_nudges(): + from dashboard import nudges as nudge_mod + world_id = (request.args.get("world_id") or "").strip() or None + return jsonify({"nudges": _safe(lambda: nudge_mod.collect_nudges(world_id), [])}) + + +@bp.route("/crm/contacts//followup", methods=["POST"]) +def api_contact_followup(cid): + from datetime import datetime, timedelta + from memory.sql_store import get_contact, update_contact + data = request.get_json(silent=True) or {} + contact = get_contact(cid) + if not contact: + return jsonify({"error": "contact not found"}), 404 + days = int(data.get("days") or 3) + days = max(1, min(days, 90)) + followup_date = (datetime.now() + timedelta(days=days)).isoformat() + update_contact(cid, next_followup_at=followup_date) + return jsonify({"ok": True, "id": cid, "next_followup_at": followup_date, "days": days}) + + @bp.route("/crm/contacts", methods=["POST"]) def api_contacts_create(): from memory.sql_store import add_contact @@ -275,7 +317,7 @@ def api_contacts_create(): def api_contacts_update(cid): from memory.sql_store import update_contact data = request.get_json(silent=True) or {} - allowed = {"name", "company", "role", "email", "status", "priority", "notes", "linkedin_url"} + allowed = {"name", "company", "role", "email", "status", "priority", "notes", "linkedin_url", "next_followup_at"} payload = {k: v for k, v in data.items() if k in allowed} if not payload: return jsonify({"error": "no valid fields"}), 400 @@ -489,6 +531,7 @@ def api_chat_async(): rag_mode=(data.get("rag_mode") or "auto").strip().lower() or "auto", specialist=specialist or "supervisor", session_id=(data.get("session_id") or "").strip() or None, + attachments=data.get("attachments") or [], ) return jsonify({"job": job}) diff --git a/dashboard/nudges.py b/dashboard/nudges.py new file mode 100644 index 0000000..b5e1424 --- /dev/null +++ b/dashboard/nudges.py @@ -0,0 +1,135 @@ +"""Actionable nudges for the web UI — reminders, follow-ups, approvals, vault-derived prompts.""" +from __future__ import annotations + +import re +from datetime import datetime + +from agent import store +from memory.sql_store import get_contacts_needing_followup, get_conn + + +def _iso_now() -> str: + return datetime.now().isoformat() + + +def _overdue(due_at: str | None) -> bool: + if not due_at: + return False + try: + return datetime.fromisoformat(due_at.replace("Z", "+00:00")[:26]) <= datetime.now() + except Exception: + return False + + +def _vault_lead_nudges(world_id: str | None = None) -> list[dict]: + """If synced vault docs mention leads/prospects, nudge when CRM has untouched prospects.""" + from memory import vault_documents + + try: + conn = get_conn() + if world_id: + prospects = conn.execute( + "SELECT id, name, company, status FROM contacts WHERE status = 'prospect' LIMIT 20" + ).fetchall() + docs = vault_documents.list_documents(world_id) + else: + prospects = conn.execute( + "SELECT id, name, company, status FROM contacts WHERE status = 'prospect' LIMIT 20" + ).fetchall() + docs = [] + for wid_row in conn.execute("SELECT DISTINCT world_id FROM vault_documents").fetchall(): + docs.extend(vault_documents.list_documents(wid_row["world_id"])) + conn.close() + except Exception: + return [] + + if not prospects: + return [] + + lead_pattern = re.compile(r"\b(leads?|prospects?|outreach|pipeline|icp)\b", re.I) + lead_docs = [ + d for d in docs + if d.get("source_type") == "github" + and lead_pattern.search(d.get("description") or "") + or lead_pattern.search(d.get("title") or "") + or lead_pattern.search(d.get("github_path") or "") + ] + if not lead_docs: + return [] + + doc = lead_docs[0] + repo = doc.get("github_repo") or "vault" + path = doc.get("github_path") or doc.get("title") or "document" + count = len(prospects) + names = ", ".join(f"{p['name']}" + (f" @ {p['company']}" if p.get("company") else "") for p in prospects[:3]) + extra = f" (+{count - 3} more)" if count > 3 else "" + return [{ + "id": f"vault-leads-{doc.get('id')}", + "kind": "vault_leads", + "title": f"{count} prospect{'s' if count != 1 else ''} not contacted", + "body": f"Your synced doc {repo}/{path} references leads. Have you reached out to {names}{extra}?", + "action": "crm", + "priority": 2, + "meta": { + "world_id": doc.get("world_id"), + "doc_id": doc.get("id"), + "prospect_count": count, + }, + }] + + +def collect_nudges(world_id: str | None = None) -> list[dict]: + nudges: list[dict] = [] + + for r in store.get_pending_reminders(): + overdue = _overdue(r.get("due_at")) + nudges.append({ + "id": f"reminder-{r['id']}", + "kind": "reminder", + "title": r.get("text") or "Reminder", + "body": f"Due {r.get('due_at', '')[:16].replace('T', ' ')}", + "action": "goals", + "priority": 1 if overdue else 3, + "meta": {"reminder_id": r["id"], "overdue": overdue}, + }) + + try: + for c in get_contacts_needing_followup(): + nudges.append({ + "id": f"followup-{c['id']}", + "kind": "followup", + "title": f"Follow up with {c.get('name', 'contact')}", + "body": f"{c.get('company') or 'No company'} · status {c.get('status', '?')}", + "action": "crm", + "priority": 2, + "meta": {"contact_id": c["id"]}, + }) + except Exception: + pass + + for a in store.list_pending_approvals(): + nudges.append({ + "id": f"approval-{a['id']}", + "kind": "approval", + "title": f"Approval needed: {a.get('tool_name', 'tool')}", + "body": (a.get("summary") or "")[:160], + "action": "approvals", + "priority": 1, + "meta": {"approval_id": a["id"]}, + }) + + for g in store.list_goals("active")[:5]: + nudges.append({ + "id": f"goal-{g['id']}", + "kind": "goal", + "title": g.get("title") or "Active goal", + "body": (g.get("detail") or "Track progress on this goal")[:120], + "action": "goals", + "priority": 4, + "meta": {"goal_id": g["id"]}, + }) + + nudges.extend(_vault_lead_nudges(world_id)) + + nudges.sort(key=lambda n: (n.get("priority") or 9, n.get("title") or "")) + return nudges[:24] diff --git a/dashboard/static/app.css b/dashboard/static/app.css index b10d840..b64edb9 100644 --- a/dashboard/static/app.css +++ b/dashboard/static/app.css @@ -3213,10 +3213,221 @@ td { } .content--loading { - opacity: 0.72; pointer-events: none; } +.content--loading #content { + opacity: 0.55; +} + +.global-progress { + position: relative; + height: 3px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; + z-index: 20; +} + +.global-progress__bar { + display: block; + height: 100%; + width: 0; + background: var(--color-accent, #da291c); + transition: width 0.25s ease-out; +} + +.global-progress.is-indeterminate .global-progress__bar { + width: 35%; + animation: global-progress-slide 1.1s ease-in-out infinite; +} + +@keyframes global-progress-slide { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(320%); } +} + +@media (prefers-reduced-motion: reduce) { + .global-progress.is-indeterminate .global-progress__bar { + animation: none; + width: 100%; + opacity: 0.5; + } +} + +.skeleton { + display: block; + border-radius: 2px; + background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.04) 100%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.2s ease-in-out infinite; +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { animation: none; background: rgba(255,255,255,0.06); } +} + +.view-skeleton { + display: grid; + gap: var(--space-sm); + padding: var(--space-xs) 0; +} + +.skeleton-card { + padding: var(--space-sm); + border: 1px solid var(--color-hairline); + background: rgba(255, 255, 255, 0.02); + display: grid; + gap: var(--space-xs); +} + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-sm); +} + +.up-next-panel { + display: grid; + gap: var(--space-xs); +} + +.up-next-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-xxs); +} + +.up-next-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--color-hairline); + background: rgba(255, 255, 255, 0.02); +} + +.up-next-item.is-urgent { + border-color: rgba(247, 84, 64, 0.35); +} + +.up-next-item__body { + min-width: 0; +} + +.up-next-item__title { + font-size: 0.9rem; + font-weight: 600; +} + +.up-next-item__meta { + font-size: 0.8rem; + color: var(--color-muted); + margin-top: 2px; +} + +.capability-strip { + display: flex; + flex-wrap: wrap; + gap: var(--space-xxs); + margin-top: var(--space-sm); +} + +.capability-chip { + padding: var(--space-xxs) var(--space-xs); + border: 1px solid var(--color-hairline); + background: transparent; + color: var(--color-text); + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; +} + +.capability-chip:hover, +.capability-chip:focus-visible { + border-color: var(--color-accent, #da291c); + color: var(--color-accent, #da291c); +} + +.chat-attachments { + display: flex; + flex-wrap: wrap; + gap: var(--space-xxs); + margin-bottom: var(--space-xs); +} + +.chat-attachment-chip { + display: inline-flex; + align-items: center; + gap: var(--space-xxs); + padding: 2px var(--space-xs); + border: 1px solid var(--color-hairline); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.78rem; +} + +.chat-attachment-chip button { + border: none; + background: transparent; + color: var(--color-muted); + cursor: pointer; + padding: 0 2px; +} + +.github-tree { + margin-top: var(--space-xs); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; +} + +.github-tree details { + margin-left: var(--space-xs); +} + +.github-tree summary { + cursor: pointer; + padding: 2px 0; + color: var(--color-muted); + list-style: none; +} + +.github-tree summary::-webkit-details-marker { display: none; } + +.github-tree-file { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: 2px 0 2px var(--space-sm); +} + +.github-tree-file__actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.github-tree-file__actions .button-sm { + padding: 2px 6px; + font-size: 0.7rem; +} + +.notif-item.is-clickable { + cursor: pointer; +} + +.notif-item.is-clickable:hover { + background: rgba(255, 255, 255, 0.03); +} + .panel-loading { min-height: 0; } diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 5c0c721..be7ad03 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -14,7 +14,7 @@ - + @@ -109,6 +109,10 @@

Control center

+ +

Loading dashboard…

@@ -251,7 +255,7 @@

Markdown

- + diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md new file mode 100644 index 0000000..216932e --- /dev/null +++ b/docs/CURRENT_STATE.md @@ -0,0 +1,123 @@ +# Nawab OS — Current State + +**Last updated:** 2026-06-13 +**Active branch:** `cursor/frontend-ux-overhaul-cb1c` +**Base:** `cursor/github-repo-readme-viewer-cb1c` → `main` + +--- + +## Branches on remote + +| Branch | Status | PR | +|--------|--------|-----| +| `cursor/github-repo-readme-viewer-cb1c` | **Complete** — README viewer + synced markdown browse | [PR #1](https://github.com/Vinayak-RZ/Nawab-OS/pull/1) (draft) | +| `cursor/frontend-ux-overhaul-cb1c` | **In progress** — backend + CSS shell; JS wiring pending | Push this branch for local handoff | + +--- + +## Shipped (README viewer branch — merged into UX branch) + +### GitHub repo files in Worlds vault +- **Open README** per linked repo +- Collapsible list of synced markdown files +- **View** on vault document cards → existing MD dialog (`FOSMarkdown`) +- API: `GET /api/worlds/{world_id}/repos/{link_id}/files` + +### Key files +- `dashboard/static/app.js` — `openVaultDocViewer`, `renderGithubReposPanel`, vault View buttons +- `memory/vault_documents.py` — `list_documents_for_github_repo`, `find_readme_document` +- `tests/test_github_repo_files.py`, `tests/test_dashboard_api.py::test_world_repo_files_endpoint` + +--- + +## In progress (UX overhaul branch — this push) + +### Backend — done in this branch +| Change | File | +|--------|------| +| Reminders from UI now schedule APScheduler jobs | `dashboard/api.py` — `POST /reminders` calls `schedule_reminder` | +| Reminder cancel/reschedule | `PATCH /api/reminders/{id}` | +| Proactive nudges API | `GET /api/nudges?world_id=` → `dashboard/nudges.py` | +| CRM follow-up scheduling | `POST /api/crm/contacts/{id}/followup` + `next_followup_at` on PATCH | +| Chat file attachments (vault docs) | `POST /api/chat/async` accepts `attachments[]`; `dashboard/agent_jobs.py` inlines file content into agent message | + +### Nudges (`dashboard/nudges.py`) +Aggregates actionable items: +- Pending/overdue reminders +- CRM follow-ups due +- Pending approvals +- Active goals (top 5) +- Vault-derived lead nudges (synced GitHub docs mentioning leads + prospect contacts not contacted) + +### Frontend — partial (CSS/HTML only, **JS not wired yet**) +| Added | Not wired in `app.js` yet | +|-------|---------------------------| +| `#global-progress` bar in `index.html` | `setViewLoading()` still only toggles opacity | +| Skeleton + up-next + github-tree + chat-attachment CSS in `app.css` | No skeleton render, no Up Next panel | +| Cache bust `app.css?v=28`, `app.js?v=29` | `app.js` unchanged on this branch | + +--- + +## Not started (planned — see `UX_OVERHAUL_PLAN.md`) + +- Wire global progress bar + skeleton loaders in `app.js` +- Dashboard dedup + **Up Next** panel using `/api/nudges` +- GitHub **folder tree** (not flat list) + **Tag in agent** button +- Chat: inline specialist picker, vault attachment chips, `attachments` in `startAgentJob` +- CRM: follow-up buttons in UI +- Goals: reminder done/cancel buttons +- Notifications: click-through to Approvals/CRM/Goals +- Remove redundant dashboard panels (command header, duplicate fleet graph) + +--- + +## Local setup (quick) + +```bash +git clone https://github.com/Vinayak-RZ/Nawab-OS.git +cd Nawab-OS +git fetch origin +git checkout cursor/frontend-ux-overhaul-cb1c + +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit: DASHBOARD_PIN, GITHUB_*, AWS_S3_BUCKET optional + +python main.py +# Open http://127.0.0.1:5000 (or port shown in logs) +``` + +### Env vars that matter for your features +- `DASHBOARD_PIN` — 6-digit gate (optional locally) +- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — repo link + sync in Worlds +- `AWS_S3_BUCKET` — omit to use `./data/vault-objects/` locally +- `QDRANT_*` — optional; memory search degrades gracefully if unset + +### Skillfish (local Cursor only) +```bash +npx skillfish add affaan-m/everything-claude-code frontend-patterns +npx skillfish add anthropics/claude-plugins-official frontend-design +``` +Failed in cloud agent environment; works on local Cursor. + +--- + +## Test commands + +```bash +python3 -m pytest tests/test_github_repo_files.py tests/test_dashboard_api.py -q +# After nudges tests added locally: +python3 -m pytest tests/test_nudges.py -q +``` + +--- + +## Architecture reminder + +``` +Worlds → Link GitHub → Sync jobs → vault_documents (SQLite) + S3/local payloads + ↓ + MD viewer / (planned) chat attachments +Scheduler → reminders, follow-ups, heartbeat → notifications bell +Agent chat → POST /api/chat/async → agent_jobs → core.run() +``` diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md new file mode 100644 index 0000000..b3a9a5b --- /dev/null +++ b/docs/LOCAL_SETUP.md @@ -0,0 +1,95 @@ +# Local development — Nawab OS + +## 1. Clone and branch + +```bash +git clone https://github.com/Vinayak-RZ/Nawab-OS.git +cd Nawab-OS +git fetch origin +git checkout cursor/frontend-ux-overhaul-cb1c +``` + +For README viewer only (smaller scope): + +```bash +git checkout cursor/github-repo-readme-viewer-cb1c +``` + +## 2. Python environment + +```bash +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +pip install pytest # for tests +``` + +## 3. Environment file + +```bash +cp .env.example .env +``` + +Minimum for dashboard: + +```env +DASHBOARD_PIN=482910 # optional; omit to skip PIN locally +``` + +For GitHub repo linking (Worlds view): + +```env +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... +``` + +GitHub OAuth callback URL (register in GitHub app settings): + +``` +http://127.0.0.1:5000/api/github/callback +``` + +Storage (pick one): + +```env +# Local object storage (default if unset): +# files go to ./data/vault-objects/ + +# Or S3: +AWS_S3_BUCKET=your-bucket +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +``` + +## 4. Run + +```bash +python main.py +``` + +Open the URL printed in the terminal (typically `http://127.0.0.1:5000`). + +## 5. Verify handoff APIs + +After PIN login: + +```bash +curl -s -b cookies.txt -c cookies.txt -X POST http://127.0.0.1:5000/api/auth/pin \ + -H 'Content-Type: application/json' -d '{"pin":"482910"}' + +curl -s -b cookies.txt http://127.0.0.1:5000/api/nudges +curl -s -b cookies.txt http://127.0.0.1:5000/api/health +``` + +## 6. Cursor skills (optional) + +```bash +npx skillfish add affaan-m/everything-claude-code frontend-patterns +npx skillfish add anthropics/claude-plugins-official frontend-design +``` + +## 7. What to read next + +- `docs/CURRENT_STATE.md` — what's done vs WIP +- `docs/UX_OVERHAUL_PLAN.md` — phased plan to continue +- `DESIGN.md` — do not change theme during UX work diff --git a/docs/UX_OVERHAUL_PLAN.md b/docs/UX_OVERHAUL_PLAN.md new file mode 100644 index 0000000..f158c88 --- /dev/null +++ b/docs/UX_OVERHAUL_PLAN.md @@ -0,0 +1,165 @@ +# UX Overhaul Plan — Nawab OS + +**Goal:** Better loading UX, discoverable capabilities, GitHub repo tree + tag-in-agent, proactive nudges — **without changing the design theme** (Rosso Corsa / dark canvas per `DESIGN.md`). + +**Continue on branch:** `cursor/frontend-ux-overhaul-cb1c` +**Read first:** `docs/CURRENT_STATE.md` + +--- + +## Phase 1 — Loading & feedback (frontend) + +**Objective:** Replace “whole page goes dim and dead” with visible progress. + +### Work items +1. Update `setViewLoading(on, { progress })` in `app.js`: + - Show/hide `#global-progress` + - Toggle `is-indeterminate` class (CSS already in `app.css`) +2. Add `renderViewSkeleton(view)` — use `.skeleton`, `.skeleton-card`, `.skeleton-grid` +3. In `render()`: if `state._viewLoading`, render skeleton for current view instead of full content +4. Vault panel: replace text “Loading…” with skeleton cards +5. Keep `ops-stack` progress for GitHub sync (already works) + +### Expected outcome +Navigation feels responsive; users see structure while data loads. + +### Files +- `dashboard/static/app.js` +- `dashboard/static/app.css` (minor tweaks only if needed) + +--- + +## Phase 2 — Dashboard cleanup + Up Next + +**Objective:** Remove dead space; surface what needs attention. + +### Remove / consolidate +- Drop duplicate `command-header` (operator panel already has quick actions) +- Remove full-width “Agent fleet” duplicate (detail lives on Agents page) +- Remove dashboard runtime graph (keep on Chat + Agents) +- Remove vault “All slots overview” `
` legacy block + +### Add +- `renderUpNext()` — `GET /api/nudges?world_id={current}` +- Render on Control center above charts +- Each nudge: title, body, action button → `goView(action)` or open vault doc + +### Files +- `dashboard/static/app.js` +- `dashboard/nudges.py` (extend kinds if needed) + +--- + +## Phase 3 — GitHub repo tree + tag in agent + +**Objective:** See repo **folder structure**; open files; attach to agent chat. + +### Work items +1. `buildGithubPathTree(docs)` — group `github_path` by directory +2. `renderGithubTree(node, worldId)` — nested `
` per folder +3. Per file actions: + - **View** → `openVaultDocViewer` + - **Tag in agent** → `tagVaultDocInChat(doc)` → adds to `state._chatAttachments`, `goView('chat')` +4. Optionally fetch `GET /api/worlds/{id}/repos/{link_id}/files` on expand (API exists; UI currently reads vault facets only) + +### Chat attachment flow +```javascript +state._chatAttachments = [{ type: 'vault', doc_id, title, path }]; +// In chatPayload / startAgentJob: +attachments: state._chatAttachments +// Clear after send +``` + +Backend already inlines attached vault file content in `agent_jobs._enrich_message`. + +### Files +- `dashboard/static/app.js` +- `dashboard/static/app.css` (`.github-tree` exists) + +--- + +## Phase 4 — Chat & capability discovery + +**Objective:** Use platform features without hunting through nav. + +### Chat composer +- Inline `#chat-specialist-select` (reuse `populateSpecialistSelect` pattern) +- **Attach from vault** button → modal/picker of synced docs for active world +- Capability strip on empty state: CRM, Goals, Outreach, Vault, Documents → `goView` or prefill message + +### Agents page +- CRM/Vault tabs → link cards to full views instead of duplicate data + +### Files +- `dashboard/static/app.js` +- `dashboard/static/index.html` (optional dialog for vault picker) + +--- + +## Phase 5 — CRM, Goals, Notifications + +### CRM +- Per contact: **Follow up in 3d / 7d** → `POST /api/crm/contacts/{id}/followup` +- Follow-ups list: **Open in CRM** + schedule buttons + +### Goals +- Reminder rows: **Done** / **Cancel** → `PATCH /api/reminders/{id}` + +### Notifications +- `data-notif-action` → approvals / crm / goals +- `POST /api/notifications/{id}/read` on click + +### Files +- `dashboard/static/app.js` +- `dashboard/api.py` (notification read already exists) + +--- + +## Phase 6 — Tests & polish + +| Test | Covers | +|------|--------| +| `tests/test_nudges.py` | `collect_nudges`, vault lead nudge | +| `tests/test_dashboard_api.py` | `GET /nudges`, `PATCH /reminders`, followup POST | +| Manual | GitHub tree, tag-in-chat, Up Next panel | + +--- + +## Priority order (if time-boxed) + +``` +P0 Phase 1 (loading) + Phase 3 (GitHub tree + tag) +P1 Phase 2 (Up Next) + Phase 5 (reminder/CRM fixes in UI) +P2 Phase 4 (chat discovery) + dashboard dedup +P3 Phase 6 tests + notification deep links +``` + +--- + +## Non-goals (this overhaul) + +- Design theme / color token changes +- S3 bucket browser (use synced vault registry) +- Live GitHub API tree before sync (sync-first is fine) +- Replacing vanilla JS with React + +--- + +## Risks + +| Risk | Mitigation | +|------|------------| +| Large `app.js` edits | Small commits per phase | +| Attachment size in chat | Already truncated at 14k chars in `agent_jobs` | +| Nudges false positives on “leads” keyword | Tune regex or require prospect count > 0 (already does) | + +--- + +## Handoff checklist for local dev + +- [ ] Checkout `cursor/frontend-ux-overhaul-cb1c` +- [ ] `pip install -r requirements.txt && pip install pytest` +- [ ] Copy `.env` with GitHub OAuth for Worlds testing +- [ ] Run `python main.py`, verify `/api/nudges` returns JSON when logged in +- [ ] Start Phase 1 in `app.js` (progress bar + skeletons) +- [ ] Install skillfish skills in local Cursor if desired diff --git a/tests/test_nudges.py b/tests/test_nudges.py new file mode 100644 index 0000000..ba3b5bc --- /dev/null +++ b/tests/test_nudges.py @@ -0,0 +1,45 @@ +import os +import tempfile + +from agent import store +from dashboard import nudges + + +def test_collect_nudges_includes_reminder(monkeypatch): + db_path = tempfile.mktemp(suffix=".db") + monkeypatch.setenv("FOUNDER_OS_DB", db_path) + store.init_agent_db() + rid = store.add_reminder("Call investor", "2099-01-01T09:00:00") + items = nudges.collect_nudges() + assert any(n["kind"] == "reminder" and n["meta"]["reminder_id"] == rid for n in items) + + +def test_vault_lead_nudge_when_prospects_exist(monkeypatch): + import tempfile as tf + + from memory import vault_documents as vd + from memory.sql_store import add_contact, init_db + + with tf.TemporaryDirectory() as tmp: + os.environ["VAULT_OBJECT_ROOT"] = tmp + db_path = tf.mktemp(suffix=".db") + monkeypatch.setenv("FOUNDER_OS_DB", db_path) + init_db() + vd.init_vault_documents_db() + add_contact(name="Alice Lead", company="Acme", status="prospect") + vd.upsert_github_document( + world_id="w-nudge", + world_slug="w-nudge", + template_id="startup", + facet_id="docs", + title="GTM", + description="Generated 12 leads from outreach", + filename="README.md", + file_bytes=b"# Leads\n", + source_ref="github:o/r:README.md", + github_repo="o/r", + github_path="README.md", + ) + lead_nudges = nudges._vault_lead_nudges("w-nudge") + assert lead_nudges + assert lead_nudges[0]["kind"] == "vault_leads"