Merge pull request #120 from resolutionflow/feat/conversational-branching
feat: conversational branching, AI markers, TaskLane improvements, collapsible sidebar
This commit was merged in pull request #120.
This commit is contained in:
32
.github/copilot-instructions.md
vendored
Normal file
32
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Copilot Instructions — ResolutionFlow
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
MSP engineers — IT professionals at Managed Service Providers who troubleshoot infrastructure and support issues for multiple client companies. They work under ticket pressure, need to resolve issues fast, and produce clean documentation automatically.
|
||||
|
||||
### Brand Personality
|
||||
**Three words:** Professional, Modern, SaaS
|
||||
**Voice:** Direct, competent, no fluff. Built by MSP engineers, for MSP engineers.
|
||||
**Emotional goals:** Confidence, competence, clarity, focus.
|
||||
|
||||
### Aesthetic Direction
|
||||
- Flat, high-contrast dark theme (Sentry/PostHog-inspired). Premium and clean.
|
||||
- **References:** Notion (clarity), Stripe (polish), Figma (functional density)
|
||||
- **Anti-references:** Microsoft Teams (clutter), Kaseya VSA 9 (dated patterns)
|
||||
- Accent: ember orange (#f97316), max 5% of UI. No glassmorphism, no gradient surfaces, no ambient effects.
|
||||
- See `DESIGN-SYSTEM.md` for full token and component specs.
|
||||
|
||||
### Accessibility
|
||||
- WCAG 2.2 AA baseline
|
||||
- Enhanced focus appearance on all interactive elements
|
||||
- 7:1 contrast ratio for data visualization colors
|
||||
- `prefers-reduced-motion` fully supported
|
||||
- Never rely on color alone for status — pair with icons or text
|
||||
|
||||
### Design Principles
|
||||
1. **Clarity over decoration.** Every pixel should communicate. No ornamental effects.
|
||||
2. **Density without clutter.** Use typography hierarchy and spacing to create structure, not chrome.
|
||||
3. **Confidence through consistency.** Same patterns, same tokens, same behavior everywhere.
|
||||
4. **Speed is a feature.** Minimize clicks. Copilot-first — primary interaction is typing.
|
||||
5. **Accessible by default.** WCAG 2.2, enhanced focus, high-contrast data viz, motion sensitivity.
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
68
.impeccable.md
Normal file
68
.impeccable.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Design Context — ResolutionFlow
|
||||
|
||||
> Persistent design guidance for all AI sessions. Source of truth for design intent and principles.
|
||||
> For component specs, tokens, and implementation details, see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md).
|
||||
|
||||
## Users
|
||||
|
||||
**MSP engineers** — IT professionals at Managed Service Providers who troubleshoot infrastructure and support issues for multiple client companies. They work under ticket pressure, juggling PSA tools (ConnectWise, Autotask, HaloPSA) and need to resolve issues fast while producing clean documentation.
|
||||
|
||||
**Context of use:** Mid-ticket, often stressed, switching between tools. They need the interface to get out of their way and help them think clearly. Documentation is a pain point — it should feel automatic, not like extra work.
|
||||
|
||||
**Job to be done:** Describe an issue, get guided through resolution, and walk away with professional ticket notes — without manual writeup.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
**Three words:** Professional, Modern, SaaS
|
||||
|
||||
**Voice:** Direct, competent, no fluff. Built by MSP engineers, for MSP engineers. The product speaks like a senior colleague — helpful without being patronizing, technical without being dense.
|
||||
|
||||
**Emotional goals:** Confidence, competence, clarity, focus. The interface should make engineers feel like they have a reliable system backing them up. Every interaction should reinforce trust and reduce cognitive load.
|
||||
|
||||
## Aesthetic Direction
|
||||
|
||||
**Visual tone:** Flat, high-contrast dark theme. Premium and clean — Sentry/PostHog DNA. Minimal decoration, maximum signal. Information density without clutter.
|
||||
|
||||
**References:**
|
||||
- **Notion** — clarity of layout, whitespace discipline, typography hierarchy
|
||||
- **Stripe** — polish, professional confidence, attention to micro-detail
|
||||
- **Figma** — functional density done right, tool-like precision, dark mode execution
|
||||
|
||||
**Anti-references:**
|
||||
- **Microsoft Teams** — cluttered, inconsistent spacing, overwhelming chrome, unclear hierarchy
|
||||
- **Kaseya VSA 9** — dated UI patterns, poor information density, legacy enterprise feel
|
||||
|
||||
**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented.
|
||||
|
||||
**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct.
|
||||
|
||||
**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow.
|
||||
|
||||
## Accessibility
|
||||
|
||||
**Target:** WCAG 2.2 AA as baseline, with two enhanced commitments:
|
||||
- **Enhanced focus appearance** — all interactive elements must have visible, high-contrast focus indicators (not just inputs). Keyboard navigation must be obvious and consistent.
|
||||
- **7:1 contrast ratio for data visualization** — chart colors, graph elements, and any data-bearing color must meet AAA contrast against their background. Standard text follows AA (4.5:1 body, 3:1 large).
|
||||
|
||||
**Already implemented:**
|
||||
- `prefers-reduced-motion` fully handled (animations collapse to 0.01ms)
|
||||
- Mobile responsive (app shell collapses below 768px)
|
||||
- Bottom-sheet modals on mobile
|
||||
- Styled scrollbars (6px, subtle)
|
||||
|
||||
**Considerations for future work:**
|
||||
- Color blindness: avoid relying on red/green distinction alone for status — always pair with icons or text labels
|
||||
- Screen reader: ensure all interactive elements have accessible names
|
||||
- Keyboard: all flows must be completable without a mouse
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Clarity over decoration.** Every pixel should communicate. If an element doesn't help the user understand or act, remove it. No ornamental gradients, glows, or effects.
|
||||
|
||||
2. **Density without clutter.** MSP engineers work with lots of data. Show what matters, hide what doesn't. Use typography hierarchy and spacing — not chrome — to create structure.
|
||||
|
||||
3. **Confidence through consistency.** Same patterns, same tokens, same behavior everywhere. Predictability builds trust. Reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) for every component decision.
|
||||
|
||||
4. **Speed is a feature.** The interface should feel instant. Minimize clicks to action. Auto-generate what can be auto-generated. The copilot-first UX means the primary interaction is typing, not navigating.
|
||||
|
||||
5. **Accessible by default.** WCAG 2.2 compliance isn't a checklist item — it's a design constraint. Enhanced focus, high-contrast data viz, and motion sensitivity are built in, not bolted on.
|
||||
74
CLAUDE.md
74
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
> **Last Updated:** March 23, 2026
|
||||
> **Last Updated:** March 27, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -23,25 +23,13 @@
|
||||
- **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned.
|
||||
- **Accent color:** Ember orange (#f97316 / #ea580c). Used sparingly — ≤5% of the UI. Warning is yellow (#eab308), not amber, to stay distinct from accent.
|
||||
- **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts
|
||||
- **Logo:** 30px gradient square (cyan) + "ResolutionFlow" in Bricolage Grotesque 700
|
||||
- **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700
|
||||
- **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
- **Brand assets:** `brand-assets/` (source SVGs), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
|
||||
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. Step Library is called "Solutions Library" in the UI. Maintenance flows are hidden from UI for pilot (backend still supports them). `tree_type` column values unchanged in DB.
|
||||
- **Reference mockups:** `docs/mockups/` (HTML files, open in browser)
|
||||
|
||||
**Component styling rules:**
|
||||
|
||||
- Primary buttons: solid `accent` background (#f97316), white text, 5px radius
|
||||
- Ghost buttons: transparent with 1px `border-default`, hover `bg-elevated`
|
||||
- Cards: `bg-card` with 1px `border-default`, 8px radius. NO shadows, NO blur, NO gradients.
|
||||
- Badges: pill-shaped (20px radius), semantic dim background + matching text color
|
||||
- Active nav: `accent-dim` background + `accent-text` color + 3px left accent bar
|
||||
- Stat cards: 3px colored left border (accent/success/warning by position)
|
||||
- Code blocks: `bg-code` with JetBrains Mono, material-inspired syntax highlighting
|
||||
- Status colors: green/`#34d399` (success), yellow/`#eab308` (warning), red/`#f87171` (danger) — ONLY for semantic meaning
|
||||
- Section labels: 10px, 600 weight, uppercase, `text-muted`, 1.2px letter-spacing
|
||||
|
||||
When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). Use flat dark surfaces, 1px borders, no decorative effects. All colors via CSS variables. Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows.
|
||||
**Component styling:** See Design System section below and [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md). All colors via CSS variables. Use "Flows" not "Trees" in user-facing text; use "Projects" not "Procedures" for procedural flows.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
@@ -54,9 +42,9 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
## Current State
|
||||
|
||||
- **Phase:** Go-to-Market Validation (Pre-PMF)
|
||||
- **Backend:** Complete (35+ API endpoints, 100+ integration tests)
|
||||
- **Backend:** Complete (55+ API endpoints, 100+ integration tests)
|
||||
- **Frontend:** Core features complete, Tree Editor functional
|
||||
- **Database:** PostgreSQL with Docker, 75 migrations
|
||||
- **Database:** PostgreSQL with Docker, 98 migrations
|
||||
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
|
||||
|
||||
### What's In Progress
|
||||
@@ -65,20 +53,6 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
- Solutions Library spec written (`docs/plans/2026-03-23-solutions-library-design.md`), implementation post-pilot
|
||||
- Remaining open issues: #66 Templates + Import/Export, #60 Recurring Issue Detection, #58 Step Feedback Flag
|
||||
|
||||
### Recently Completed
|
||||
|
||||
- Copilot-first dashboard redesign: ChatGPT-style input, suggestion chips, simplified sidebar
|
||||
- Charcoal color palette: sidebar-darkest approach (`#10121a` sidebar, `#1a1c23` page, `#22252e` cards)
|
||||
- Unified Command Palette: merged QuickLaunch into omnibar, removed lightning bolt button
|
||||
- "Solutions Library" rename from "Step Library" site-wide
|
||||
- Maintenance flows hidden from UI for pilot
|
||||
- Landing page copy rewrite: copilot-first messaging ("Resolve tickets faster. Notes write themselves.")
|
||||
- Spring bounce hover animation on dashboard cards
|
||||
- Amber "New Session" button in sidebar
|
||||
- Landing page design audit: hamburger menu, Privacy/Terms pages, branding alignment
|
||||
- Root directory cleanup: archived 9 completed docs, tracked marketing assets
|
||||
- GitHub issues triage: closed 10 stale issues (6 completed, 4 deferred)
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
@@ -95,7 +69,7 @@ When adding new pages/components: reference [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md)
|
||||
### Frontend
|
||||
|
||||
- **Framework:** React 19 + Vite + TypeScript
|
||||
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with cyan accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
|
||||
- **Styling:** Tailwind CSS v4 (`@tailwindcss/vite` plugin, CSS-only config in `index.css`) — flat dark theme with ember orange accent (see [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md))
|
||||
- **State:** Zustand (with immer + zundo for undo/redo)
|
||||
- **Routing:** React Router v7
|
||||
- **API Client:** Axios with token refresh interceptor
|
||||
@@ -110,7 +84,7 @@ patherly/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry point
|
||||
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, psa_connections)
|
||||
│ │ ├── api/endpoints/ # Route handlers (auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations)
|
||||
│ │ │ ├── flow_proposals.py # Knowledge Flywheel review queue CRUD
|
||||
│ │ │ └── flowpilot_analytics.py # FlowPilot dashboard metrics
|
||||
│ │ ├── api/deps.py # Auth dependencies (includes require_team_admin)
|
||||
@@ -118,7 +92,7 @@ patherly/
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy models (includes FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, cache, encryption, registry, types)
|
||||
│ │ ├── services/psa/ # PSA provider abstraction (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
||||
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
|
||||
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
|
||||
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
|
||||
@@ -131,7 +105,7 @@ patherly/
|
||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||
│ │ ├── pages/ # All page components
|
||||
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences)
|
||||
│ │ ├── store/ # Zustand stores (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/ # TypeScript interfaces
|
||||
│ └── (Tailwind v4: CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # Archived design/impl docs (pre-March 2026)
|
||||
@@ -202,7 +176,7 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
|
||||
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
|
||||
- `clientId` is server-side config (`CW_CLIENT_ID` in `config.py`) — identifies the ResolutionFlow app, NOT per-tenant. Per-connection credentials: `company_id`, `public_key`, `private_key`, `server_url`
|
||||
- All PSA integration code in `services/psa/` — provider pattern with `PSAProvider` abstract base class, `ConnectWiseProvider` implementation, `PsaProviderRegistry` for multi-PSA dispatch
|
||||
- PSA endpoints in `api/endpoints/psa_connections.py` — connection CRUD, ticket ops, member mapping
|
||||
- PSA endpoints in `api/endpoints/integrations.py` — connection CRUD, ticket ops, member mapping
|
||||
- Credentials encrypted at rest via `services/psa/encryption.py` (Fernet)
|
||||
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
|
||||
- Design for the Autotask integration following the same service layer pattern (future PSA)
|
||||
@@ -320,7 +294,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container name is `resolutionflow_postgres`, database is `resolutionflow` (not `patherly`), port mapped to `5433` (not `5432`). The `POSTGRES_PORT` env var controls this. Playwright config defaults must match: `postgresql+asyncpg://postgres:postgres@127.0.0.1:5433/resolutionflow`.
|
||||
|
||||
**66. Dev environment runs on devserver01 (192.168.0.9), not localhost:** Code-server runs in Docker on a LAN server. Frontend/backend are accessed via `192.168.0.9`, not `localhost`. CORS must include `http://192.168.0.9:5173` in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL=http://192.168.0.9:8000`. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
|
||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250), not localhost:** Code-server runs in Docker on a VPS (previously devserver01/192.168.0.9). Frontend/backend are accessed via `46.202.92.250`, not `localhost`. CORS must include the VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. Frontend `.env` must set `VITE_API_URL` to the VPS backend URL. See [DEV-ENV.md](DEV-ENV.md) for full setup, Docker config, networking, and known issues.
|
||||
|
||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Check `router.tsx` line 156 for the canonical path. Use `getTreeEditorPath()` from `@/lib/routing` when navigating programmatically.
|
||||
|
||||
@@ -366,11 +340,6 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#10121a`, page `#1a1c23`, cards `#22252e`, borders `#2e3240`. This gives more contrast range than true-dark (`#0c0d10`). All colors via CSS variables in `index.css` `@theme` block. Accent is ember orange (#f97316), not cyan.
|
||||
|
||||
**89. QuickLaunch merged into CommandPalette:** There is no separate QuickLaunch/lightning bolt. The unified Cmd+K omnibar handles search, navigation, quick actions, and FlowPilot. `QuickLaunch.tsx` was deleted.
|
||||
|
||||
**90. Copilot-first UX direction:** The FlowPilot AI chat copilot is the primary experience. Dashboard centers on the chat input. Guided flows (decision trees) are accessible but secondary — in sidebar under "Flows". Maintenance flows are hidden from UI for pilot.
|
||||
|
||||
**91. "New Session" button is amber-400:** Sidebar uses `bg-amber-400/15 text-amber-400` for the New Session button, not cyan. This makes it visually distinct from the cyan accent used elsewhere.
|
||||
|
||||
**92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing.
|
||||
|
||||
@@ -386,6 +355,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
|
||||
**98. `lazyWithRetry` for stale chunk errors:** All lazy-loaded routes use `lazyWithRetry` from `@/lib/lazyWithRetry.ts` instead of `React.lazy`. Auto-reloads the page on chunk load failures (stale deploys). Uses sessionStorage debounce (10s) to prevent loops. When adding new lazy routes, use `lazyWithRetry`, not `lazy`.
|
||||
|
||||
**99. Tailwind v4 `text-secondary` renders invisible on dark backgrounds:** `text-secondary` maps to `--color-secondary: #2e3140` (a dark surface color), NOT `--color-text-secondary`. For readable secondary text, use `text-muted-foreground` (`#848b9b`). Also avoid `text-muted` (`#4f5666`) for body text — it's for labels only. This applies to ALL new components.
|
||||
|
||||
**100. Hover pop-out card pattern:** For cards that expand on hover "in front of everything": use `pointer-events-none` on the scrim (`fixed inset-0 z-40 bg-black/30`), absolute-position the expanded card at `z-50` with its own `onClick` handler, and dismiss via `onMouseLeave` on the wrapper div. Never put interactive event handlers on the scrim — it blocks clicks on sibling elements.
|
||||
|
||||
**101. AI marker format compliance:** The AI assistant uses `[QUESTIONS]`, `[ACTIONS]`, and `[FORK]` markers in responses. Parsed by `unified_chat_service.py` (`_parse_*_marker` functions), returned as structured data in the API response. System prompt in `assistant_chat_service.py` has a final reminder section, and each user message gets an invisible `[SYSTEM: ...]` reminder appended in `_call_anthropic_cached()`. If markers stop appearing: check conversation history stores `display_content` (stripped), verify system prompt final reminder exists, check user message reminder injection is active.
|
||||
|
||||
**102. TaskLane activation must happen in ALL chat response paths:** `AssistantChatPage.tsx` has three code paths calling `sendChatMessage`: `handleSend` (regular messages), `sendPrefill` (dashboard handoff), `handleResumeNew` (resume from concluded session). ALL three must check `response.actions`/`response.questions` and call `setShowTaskLane(true)`. Missing this in any path causes TaskLane to not appear on first message.
|
||||
|
||||
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
@@ -408,7 +387,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
||||
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
|
||||
- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
|
||||
- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
|
||||
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-secondary` (`#848b9b`) → `text-muted` (`#4f5666`)
|
||||
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color.
|
||||
- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`)
|
||||
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity
|
||||
- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74`
|
||||
@@ -513,14 +492,6 @@ When a feature, fix, or significant piece of work is finished and merged/committ
|
||||
|
||||
---
|
||||
|
||||
## gstack
|
||||
|
||||
Use `/browse` from gstack for **all web browsing** — never use `mcp__claude-in-chrome__*` tools.
|
||||
|
||||
**Available skills:** `/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/review`, `/ship`, `/browse`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/retro`, `/investigate`, `/document-release`, `/codex`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade`
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
- **Phase 3:** PSA integrations (ConnectWise in progress), file attachments, client context, analytics
|
||||
@@ -537,6 +508,5 @@ Use `/browse` from gstack for **all web browsing** — never use `mcp__claude-in
|
||||
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||
| GitHub Issues | `gh issue list --state open` |
|
||||
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
|
||||
| Feature Specs | [04-FEATURE-SPECIFICATIONS.md](04-FEATURE-SPECIFICATIONS.md) |
|
||||
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
|
||||
|
||||
@@ -25,6 +25,10 @@ from app.models.ai_session_step import AISessionStep # noqa: F401
|
||||
from app.models.psa_post_log import PsaPostLog # noqa: F401
|
||||
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
|
||||
from app.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage # noqa: F401
|
||||
from app.models.session_branch import SessionBranch # noqa: F401
|
||||
from app.models.fork_point import ForkPoint # noqa: F401
|
||||
from app.models.session_handoff import SessionHandoff # noqa: F401
|
||||
from app.models.session_resolution_output import SessionResolutionOutput # noqa: F401
|
||||
from app.core.config import settings
|
||||
|
||||
# this is the Alembic Config object
|
||||
|
||||
156
backend/alembic/versions/067_add_conversational_branching.py
Normal file
156
backend/alembic/versions/067_add_conversational_branching.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Add conversational branching tables and columns.
|
||||
|
||||
Revision ID: 067
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "067"
|
||||
down_revision = "066"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# session_branches
|
||||
op.create_table(
|
||||
"session_branches",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("fork_point_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("branch_order", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("label", sa.String(200), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("status_reason", sa.Text, nullable=True),
|
||||
sa.Column("status_changed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status_changed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("conversation_messages", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("context_summary", JSONB, nullable=True),
|
||||
sa.Column("evidence_from_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("evidence_description", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.CheckConstraint("status IN ('active', 'dead_end', 'solved', 'untried', 'revived')", name="ck_session_branches_status"),
|
||||
sa.CheckConstraint("branch_order > 0", name="ck_session_branches_branch_order_positive"),
|
||||
)
|
||||
op.create_index("ix_session_branches_session_id", "session_branches", ["session_id"])
|
||||
op.create_index("ix_session_branches_parent_branch_id", "session_branches", ["parent_branch_id"])
|
||||
op.create_index("ix_session_branches_session_status", "session_branches", ["session_id", "status"])
|
||||
op.create_index("ix_session_branches_session_order", "session_branches", ["session_id", "branch_order"])
|
||||
|
||||
# fork_points
|
||||
op.create_table(
|
||||
"fork_points",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("parent_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("trigger_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("fork_reason", sa.Text, nullable=False),
|
||||
sa.Column("options", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_fork_points_session_id", "fork_points", ["session_id"])
|
||||
|
||||
# session_handoffs
|
||||
op.create_table(
|
||||
"session_handoffs",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("handed_off_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("intent", sa.String(20), nullable=False),
|
||||
sa.Column("source_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("snapshot", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("ai_assessment", sa.Text, nullable=True),
|
||||
sa.Column("ai_assessment_data", JSONB, nullable=True),
|
||||
sa.Column("artifacts", JSONB, nullable=True),
|
||||
sa.Column("engineer_notes", sa.Text, nullable=True),
|
||||
sa.Column("priority", sa.String(20), nullable=False, server_default="normal"),
|
||||
sa.Column("claimed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("psa_note_pushed", sa.Boolean, server_default="false"),
|
||||
sa.Column("psa_note_id", sa.String(100), nullable=True),
|
||||
sa.Column("notification_sent", sa.Boolean, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.CheckConstraint("intent IN ('park', 'escalate')", name="ck_session_handoffs_intent"),
|
||||
sa.CheckConstraint("priority IN ('normal', 'elevated')", name="ck_session_handoffs_priority"),
|
||||
)
|
||||
op.create_index("ix_session_handoffs_session_id", "session_handoffs", ["session_id"])
|
||||
|
||||
# session_resolution_outputs
|
||||
op.create_table(
|
||||
"session_resolution_outputs",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("output_type", sa.String(30), nullable=False),
|
||||
sa.Column("generated_content", sa.Text, nullable=False),
|
||||
sa.Column("structured_data", JSONB, nullable=True),
|
||||
sa.Column("edited_content", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
|
||||
sa.Column("pushed_to", sa.String(50), nullable=True),
|
||||
sa.Column("pushed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("pushed_reference", sa.String(200), nullable=True),
|
||||
sa.Column("generated_by_model", sa.String(50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.CheckConstraint("output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", name="ck_session_resolution_outputs_output_type"),
|
||||
sa.CheckConstraint("status IN ('draft', 'approved', 'pushed', 'rejected')", name="ck_session_resolution_outputs_status"),
|
||||
sa.UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_type"),
|
||||
)
|
||||
op.create_index("ix_session_resolution_outputs_session_id", "session_resolution_outputs", ["session_id"])
|
||||
|
||||
# ai_sessions: add 5 columns (NO FK on active_branch_id)
|
||||
op.add_column("ai_sessions", sa.Column("is_branching", sa.Boolean, server_default="false", nullable=False))
|
||||
op.add_column("ai_sessions", sa.Column("active_branch_id", UUID(as_uuid=True), nullable=True))
|
||||
op.add_column("ai_sessions", sa.Column("handoff_count", sa.Integer, server_default="0", nullable=False))
|
||||
op.add_column("ai_sessions", sa.Column("total_active_seconds", sa.Integer, server_default="0", nullable=False))
|
||||
op.add_column("ai_sessions", sa.Column("total_parked_seconds", sa.Integer, server_default="0", nullable=False))
|
||||
|
||||
# ai_session_steps: add 3 columns + update CHECK
|
||||
op.add_column("ai_session_steps", sa.Column("branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True))
|
||||
op.add_column("ai_session_steps", sa.Column("is_fork_point", sa.Boolean, server_default="false", nullable=False))
|
||||
op.add_column("ai_session_steps", sa.Column("fork_point_id", UUID(as_uuid=True), sa.ForeignKey("fork_points.id", ondelete="SET NULL"), nullable=True))
|
||||
op.create_index("ix_ai_session_steps_branch_id", "ai_session_steps", ["branch_id"])
|
||||
|
||||
op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_ai_session_steps_step_type", "ai_session_steps",
|
||||
"step_type IN ('question', 'action', 'script_generation', 'verification', 'info_request', 'note', 'intake_analysis', 'fork')",
|
||||
)
|
||||
|
||||
# file_uploads: add 5 columns
|
||||
op.add_column("file_uploads", sa.Column("ai_description", sa.Text, nullable=True))
|
||||
op.add_column("file_uploads", sa.Column("extracted_content", sa.Text, nullable=True))
|
||||
op.add_column("file_uploads", sa.Column("content_summary", sa.Text, nullable=True))
|
||||
op.add_column("file_uploads", sa.Column("uploaded_on_branch_id", UUID(as_uuid=True), sa.ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True))
|
||||
op.add_column("file_uploads", sa.Column("uploaded_at_step_id", UUID(as_uuid=True), sa.ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("file_uploads", "uploaded_at_step_id")
|
||||
op.drop_column("file_uploads", "uploaded_on_branch_id")
|
||||
op.drop_column("file_uploads", "content_summary")
|
||||
op.drop_column("file_uploads", "extracted_content")
|
||||
op.drop_column("file_uploads", "ai_description")
|
||||
|
||||
op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_ai_session_steps_step_type", "ai_session_steps",
|
||||
"step_type IN ('question', 'action', 'script_generation', 'verification', 'info_request', 'note', 'intake_analysis')",
|
||||
)
|
||||
op.drop_index("ix_ai_session_steps_branch_id", "ai_session_steps")
|
||||
op.drop_column("ai_session_steps", "fork_point_id")
|
||||
op.drop_column("ai_session_steps", "is_fork_point")
|
||||
op.drop_column("ai_session_steps", "branch_id")
|
||||
|
||||
op.drop_column("ai_sessions", "total_parked_seconds")
|
||||
op.drop_column("ai_sessions", "total_active_seconds")
|
||||
op.drop_column("ai_sessions", "handoff_count")
|
||||
op.drop_column("ai_sessions", "active_branch_id")
|
||||
op.drop_column("ai_sessions", "is_branching")
|
||||
|
||||
op.drop_table("session_resolution_outputs")
|
||||
op.drop_table("session_handoffs")
|
||||
op.drop_table("fork_points")
|
||||
op.drop_table("session_branches")
|
||||
@@ -287,7 +287,7 @@ async def send_chat_message(
|
||||
images = await fetch_upload_images(data.upload_ids, account_id, db) or None
|
||||
|
||||
try:
|
||||
ai_content, suggested_flows, session = await unified_chat_service.send_chat_message(
|
||||
ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data = await unified_chat_service.send_chat_message(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
@@ -329,6 +329,9 @@ async def send_chat_message(
|
||||
return ChatMessageResponse(
|
||||
content=ai_content,
|
||||
suggested_flows=suggested_flows,
|
||||
fork=fork_metadata,
|
||||
actions=actions_data,
|
||||
questions=questions_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -416,6 +419,15 @@ async def resolve_session(
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
# Generate resolution outputs (branching feature)
|
||||
try:
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
await gen.generate_all(session_id)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to generate resolution outputs for session {session_id}")
|
||||
# Non-blocking — resolve still succeeds even if output generation fails
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
|
||||
274
backend/app/api/endpoints/session_branches.py
Normal file
274
backend/app/api/endpoints/session_branches.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Branch management endpoints for conversational branching.
|
||||
|
||||
GET /ai-sessions/{id}/branches — List all branches (tree)
|
||||
POST /ai-sessions/{id}/branches/fork — Create fork with N branches
|
||||
PATCH /ai-sessions/{id}/branches/{bid} — Update branch status
|
||||
POST /ai-sessions/{id}/branches/{bid}/switch — Switch active branch
|
||||
POST /ai-sessions/{id}/branches/{bid}/revive — Revive dead-end branch
|
||||
POST /ai-sessions/{id}/branches/{bid}/message — Send message on branch
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.services.branch_manager import BranchManager
|
||||
from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
from app.schemas.session_branch import (
|
||||
BranchTreeResponse,
|
||||
BranchResponse,
|
||||
BranchUpdate,
|
||||
ForkCreateRequest,
|
||||
ForkPointResponse,
|
||||
BranchSwitchResponse,
|
||||
ReviveRequest,
|
||||
BranchMessageRequest,
|
||||
BranchMessageResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ai-sessions/{session_id}/branches", tags=["session-branches"])
|
||||
|
||||
|
||||
async def _get_user_session(
|
||||
session_id: UUID, user: User, db: AsyncSession
|
||||
) -> AISession:
|
||||
"""Fetch session owned by user, or raise 404."""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
@router.get("", response_model=BranchTreeResponse)
|
||||
async def list_branches(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> BranchTreeResponse:
|
||||
"""Get branch tree for a session."""
|
||||
session = await _get_user_session(session_id, current_user, db)
|
||||
manager = BranchManager(db)
|
||||
branches = await manager.get_branch_tree(session_id)
|
||||
|
||||
branch_responses = []
|
||||
for b in branches:
|
||||
branch_responses.append(BranchResponse(
|
||||
id=b.id,
|
||||
session_id=b.session_id,
|
||||
parent_branch_id=b.parent_branch_id,
|
||||
fork_point_step_id=b.fork_point_step_id,
|
||||
branch_order=b.branch_order,
|
||||
label=b.label,
|
||||
status=b.status,
|
||||
status_reason=b.status_reason,
|
||||
status_changed_at=b.status_changed_at,
|
||||
context_summary=b.context_summary,
|
||||
evidence_from_branch_id=b.evidence_from_branch_id,
|
||||
evidence_description=b.evidence_description,
|
||||
created_at=b.created_at,
|
||||
updated_at=b.updated_at,
|
||||
))
|
||||
|
||||
return BranchTreeResponse(
|
||||
branches=branch_responses,
|
||||
active_branch_id=session.active_branch_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fork", response_model=ForkPointResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fork(
|
||||
session_id: UUID,
|
||||
body: ForkCreateRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> ForkPointResponse:
|
||||
"""Create a fork point with N branches."""
|
||||
session = await _get_user_session(session_id, current_user, db)
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot fork a {session.status} session")
|
||||
|
||||
manager = BranchManager(db)
|
||||
|
||||
# Ensure branching is initialized
|
||||
if not session.is_branching:
|
||||
await manager.create_root_branch(session_id)
|
||||
await db.refresh(session)
|
||||
|
||||
# Use the active branch as parent
|
||||
parent_branch_id = session.active_branch_id
|
||||
if not parent_branch_id:
|
||||
raise HTTPException(status_code=400, detail="No active branch to fork from")
|
||||
|
||||
options = [{"label": o.label, "description": o.description} for o in body.options]
|
||||
|
||||
fork_point, branches = await manager.create_fork(
|
||||
session_id=session_id,
|
||||
parent_branch_id=parent_branch_id,
|
||||
trigger_step_id=None,
|
||||
fork_reason=body.fork_reason,
|
||||
options=options,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return ForkPointResponse.model_validate(fork_point)
|
||||
|
||||
|
||||
@router.patch("/{branch_id}", response_model=BranchResponse)
|
||||
async def update_branch_status(
|
||||
session_id: UUID,
|
||||
branch_id: UUID,
|
||||
body: BranchUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> BranchResponse:
|
||||
"""Update a branch's status."""
|
||||
await _get_user_session(session_id, current_user, db)
|
||||
manager = BranchManager(db)
|
||||
|
||||
try:
|
||||
branch = await manager.mark_branch_status(
|
||||
branch_id=branch_id,
|
||||
status=body.status,
|
||||
reason=body.status_reason,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return BranchResponse.model_validate(branch)
|
||||
|
||||
|
||||
@router.post("/{branch_id}/switch", response_model=BranchSwitchResponse)
|
||||
async def switch_branch(
|
||||
session_id: UUID,
|
||||
branch_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> BranchSwitchResponse:
|
||||
"""Switch the active branch."""
|
||||
await _get_user_session(session_id, current_user, db)
|
||||
manager = BranchManager(db)
|
||||
|
||||
try:
|
||||
branch = await manager.switch_branch(session_id, branch_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return BranchSwitchResponse(
|
||||
active_branch_id=branch.id,
|
||||
branch=BranchResponse.model_validate(branch),
|
||||
conversation_messages=branch.conversation_messages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{branch_id}/revive", response_model=BranchResponse)
|
||||
async def revive_branch(
|
||||
session_id: UUID,
|
||||
branch_id: UUID,
|
||||
body: ReviveRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> BranchResponse:
|
||||
"""Revive a dead-end branch with new evidence."""
|
||||
await _get_user_session(session_id, current_user, db)
|
||||
manager = BranchManager(db)
|
||||
|
||||
try:
|
||||
branch = await manager.revive_branch(
|
||||
branch_id=branch_id,
|
||||
evidence_from_branch_id=body.evidence_from_branch_id,
|
||||
evidence_description=body.evidence_description,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return BranchResponse.model_validate(branch)
|
||||
|
||||
|
||||
@router.post("/{branch_id}/message", response_model=BranchMessageResponse)
|
||||
async def send_branch_message(
|
||||
session_id: UUID,
|
||||
branch_id: UUID,
|
||||
body: BranchMessageRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> BranchMessageResponse:
|
||||
"""Send a message on a specific branch."""
|
||||
session = await _get_user_session(session_id, current_user, db)
|
||||
|
||||
if session.status not in ("active", "paused"):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot message a {session.status} session")
|
||||
|
||||
manager = BranchManager(db)
|
||||
|
||||
# Switch to branch if not already active
|
||||
if session.active_branch_id != branch_id:
|
||||
await manager.switch_branch(session_id, branch_id)
|
||||
await db.refresh(session)
|
||||
|
||||
# Get branch
|
||||
result = await db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == branch_id)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
if not branch:
|
||||
raise HTTPException(status_code=404, detail="Branch not found")
|
||||
|
||||
# Build cross-branch context
|
||||
sibling_ctx = await manager.build_cross_branch_context(branch_id)
|
||||
|
||||
# Build prompt
|
||||
builder = BranchAwarePromptBuilder()
|
||||
session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}."
|
||||
prompt_args = builder.build(
|
||||
branch_messages=branch.conversation_messages,
|
||||
sibling_summaries=sibling_ctx,
|
||||
session_context=session_context,
|
||||
attachments=[],
|
||||
new_message=body.message,
|
||||
revival_context=branch.evidence_description if branch.status == "revived" else None,
|
||||
)
|
||||
|
||||
# Call AI
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args)
|
||||
|
||||
# Update branch conversation
|
||||
msgs = list(branch.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": body.message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
# Update session token counts
|
||||
session.total_input_tokens += input_tokens
|
||||
session.total_output_tokens += output_tokens
|
||||
|
||||
# Resume if paused
|
||||
if session.status == "paused":
|
||||
session.status = "active"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return BranchMessageResponse(
|
||||
content=ai_content,
|
||||
branch_id=branch_id,
|
||||
)
|
||||
116
backend/app/api/endpoints/session_handoffs.py
Normal file
116
backend/app/api/endpoints/session_handoffs.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Handoff endpoints — unified park/escalate.
|
||||
|
||||
POST /ai-sessions/{id}/handoff — Create handoff
|
||||
GET /ai-sessions/{id}/handoffs — Handoff history
|
||||
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
|
||||
GET /ai-sessions/queue — Team queue
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
from app.schemas.session_handoff import (
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Queue endpoint needs its own router (no session_id prefix)
|
||||
queue_router = APIRouter(prefix="/ai-sessions", tags=["session-handoffs"])
|
||||
|
||||
# Session-scoped endpoints
|
||||
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"])
|
||||
|
||||
|
||||
@router.post("/handoff", response_model=HandoffResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_handoff(
|
||||
session_id: UUID,
|
||||
body: HandoffCreateRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
"""Create a handoff (park or escalate)."""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session_id,
|
||||
intent=body.intent,
|
||||
engineer_notes=body.engineer_notes,
|
||||
user_id=current_user.id,
|
||||
priority=body.priority,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
|
||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||
async def list_handoffs(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> list[HandoffResponse]:
|
||||
"""Get handoff history for a session."""
|
||||
result = await db.execute(
|
||||
select(SessionHandoff)
|
||||
.where(SessionHandoff.session_id == session_id)
|
||||
.order_by(SessionHandoff.created_at.desc())
|
||||
)
|
||||
handoffs = result.scalars().all()
|
||||
return [HandoffResponse.model_validate(h) for h in handoffs]
|
||||
|
||||
|
||||
@router.post("/handoffs/{handoff_id}/claim", response_model=HandoffResponse)
|
||||
async def claim_handoff(
|
||||
session_id: UUID,
|
||||
handoff_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
"""Claim a handed-off session."""
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
handoff = await manager.claim_session(
|
||||
handoff_id=handoff_id,
|
||||
claiming_user_id=current_user.id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
|
||||
@queue_router.get("/queue")
|
||||
async def get_queue(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> list[dict]:
|
||||
"""Get team queue of parked + escalated sessions."""
|
||||
manager = HandoffManager(db)
|
||||
return await manager.get_queue(
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
)
|
||||
80
backend/app/api/endpoints/session_resolutions.py
Normal file
80
backend/app/api/endpoints/session_resolutions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Resolution output endpoints."""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
from app.schemas.session_resolution import (
|
||||
ResolutionOutputResponse,
|
||||
ResolutionOutputEditRequest,
|
||||
ResolutionOutputPushRequest,
|
||||
ResolutionOutputPushResponse,
|
||||
AllResolutionOutputsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-resolutions"])
|
||||
|
||||
|
||||
@router.get("/outputs", response_model=AllResolutionOutputsResponse)
|
||||
async def get_outputs(
|
||||
session_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> AllResolutionOutputsResponse:
|
||||
result = await db.execute(
|
||||
select(SessionResolutionOutput)
|
||||
.where(SessionResolutionOutput.session_id == session_id)
|
||||
.order_by(SessionResolutionOutput.output_type)
|
||||
)
|
||||
outputs = result.scalars().all()
|
||||
return AllResolutionOutputsResponse(
|
||||
outputs=[ResolutionOutputResponse.model_validate(o) for o in outputs]
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/outputs/{output_id}", response_model=ResolutionOutputResponse)
|
||||
async def edit_output(
|
||||
session_id: UUID,
|
||||
output_id: UUID,
|
||||
body: ResolutionOutputEditRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> ResolutionOutputResponse:
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
try:
|
||||
output = await gen.edit_output(output_id, body.edited_content)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await db.commit()
|
||||
return ResolutionOutputResponse.model_validate(output)
|
||||
|
||||
|
||||
@router.post("/outputs/{output_id}/push", response_model=ResolutionOutputPushResponse)
|
||||
async def push_output(
|
||||
session_id: UUID,
|
||||
output_id: UUID,
|
||||
body: ResolutionOutputPushRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> ResolutionOutputPushResponse:
|
||||
gen = ResolutionOutputGenerator(db)
|
||||
try:
|
||||
output = await gen.push_output(output_id, body.destination)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
await db.commit()
|
||||
return ResolutionOutputPushResponse(
|
||||
output_id=output.id,
|
||||
status=output.status,
|
||||
pushed_to=output.pushed_to or body.destination,
|
||||
pushed_reference=output.pushed_reference,
|
||||
)
|
||||
@@ -35,6 +35,60 @@ def _check_storage_configured() -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _generate_ai_description(upload_id: UUID, file_data: bytes, content_type: str) -> None:
|
||||
"""Background task: generate AI description for uploaded file."""
|
||||
try:
|
||||
from app.core.database import async_session_maker
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
import base64
|
||||
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(FileUpload).where(FileUpload.id == upload_id)
|
||||
)
|
||||
upload = result.scalar_one_or_none()
|
||||
if not upload:
|
||||
return
|
||||
|
||||
if content_type.startswith("image/"):
|
||||
b64_data = base64.b64encode(file_data).decode("utf-8")
|
||||
description, _, _ = await _call_ai(
|
||||
system_base="You are a technical image analyst for IT troubleshooting.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message="Describe this image in one sentence for a troubleshooting context log.",
|
||||
images=[{"media_type": content_type, "data": b64_data}],
|
||||
max_tokens=100,
|
||||
)
|
||||
upload.ai_description = description
|
||||
elif content_type.startswith("text/") or content_type in (
|
||||
"application/json", "application/xml", "application/yaml",
|
||||
):
|
||||
try:
|
||||
text_content = file_data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text_content = file_data.decode("latin-1")
|
||||
|
||||
upload.extracted_content = text_content[:10000]
|
||||
|
||||
if len(text_content) > 2000:
|
||||
summary, _, _ = await _call_ai(
|
||||
system_base="You are a technical log/config analyst.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=f"Summarize this file content in 2-3 sentences:\n\n{text_content[:5000]}",
|
||||
max_tokens=200,
|
||||
)
|
||||
upload.content_summary = summary
|
||||
upload.ai_description = summary
|
||||
else:
|
||||
upload.ai_description = f"Text file: {upload.filename}"
|
||||
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception(f"Failed to generate AI description for upload {upload_id}")
|
||||
|
||||
|
||||
@router.post("", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("10/minute")
|
||||
async def upload_file(
|
||||
@@ -113,6 +167,11 @@ async def upload_file(
|
||||
await db.commit()
|
||||
await db.refresh(upload)
|
||||
|
||||
import asyncio
|
||||
asyncio.create_task(
|
||||
_generate_ai_description(upload.id, file_data, content_type)
|
||||
)
|
||||
|
||||
presigned_url = storage_service.get_presigned_url(upload.storage_key)
|
||||
|
||||
return FileUploadResponse(
|
||||
|
||||
@@ -30,6 +30,9 @@ from app.api.endpoints import admin_gallery
|
||||
from app.api.endpoints import uploads
|
||||
from app.api.endpoints import script_builder
|
||||
from app.api.endpoints import beta_feedback
|
||||
from app.api.endpoints import session_branches
|
||||
from app.api.endpoints import session_handoffs
|
||||
from app.api.endpoints import session_resolutions
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -76,6 +79,8 @@ api_router.include_router(integrations.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(supporting_data.router)
|
||||
api_router.include_router(session_handoffs.queue_router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(session_resolutions.router) # Must be before ai_sessions to avoid /{session_id} conflict
|
||||
api_router.include_router(ai_sessions.router)
|
||||
api_router.include_router(flow_proposals.router)
|
||||
api_router.include_router(flowpilot_analytics.router)
|
||||
@@ -85,3 +90,5 @@ api_router.include_router(admin_gallery.router)
|
||||
api_router.include_router(uploads.router)
|
||||
api_router.include_router(script_builder.router)
|
||||
api_router.include_router(beta_feedback.router)
|
||||
api_router.include_router(session_branches.router)
|
||||
api_router.include_router(session_handoffs.router)
|
||||
|
||||
@@ -50,6 +50,10 @@ from .psa_activity_log import PsaActivityLog
|
||||
from .file_upload import FileUpload
|
||||
from .ai_session_embedding import AISessionEmbedding
|
||||
from .beta_feedback import BetaFeedback
|
||||
from .session_branch import SessionBranch
|
||||
from .fork_point import ForkPoint
|
||||
from .session_handoff import SessionHandoff
|
||||
from .session_resolution_output import SessionResolutionOutput
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -114,4 +118,8 @@ __all__ = [
|
||||
"FileUpload",
|
||||
"AISessionEmbedding",
|
||||
"BetaFeedback",
|
||||
"SessionBranch",
|
||||
"ForkPoint",
|
||||
"SessionHandoff",
|
||||
"SessionResolutionOutput",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,10 @@ if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.tree import Tree
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.fork_point import ForkPoint
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
|
||||
|
||||
class AISession(Base):
|
||||
@@ -206,6 +210,28 @@ class AISession(Base):
|
||||
comment="Full LLM message history for context continuity",
|
||||
)
|
||||
|
||||
# ── Branching ──
|
||||
is_branching: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
comment="Whether conversational branching is active for this session",
|
||||
)
|
||||
active_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True,
|
||||
comment="Currently viewed branch. No FK — soft pointer to avoid circular FK with session_branches",
|
||||
)
|
||||
handoff_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Number of times this session has been handed off",
|
||||
)
|
||||
total_active_seconds: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Cumulative active time in seconds",
|
||||
)
|
||||
total_parked_seconds: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0,
|
||||
comment="Cumulative parked time in seconds",
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
@@ -218,3 +244,19 @@ class AISession(Base):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AISessionStep.step_order",
|
||||
)
|
||||
branches: Mapped[list["SessionBranch"]] = relationship(
|
||||
"SessionBranch",
|
||||
foreign_keys="SessionBranch.session_id",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SessionBranch.branch_order",
|
||||
)
|
||||
handoffs: Mapped[list["SessionHandoff"]] = relationship(
|
||||
"SessionHandoff",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SessionHandoff.created_at",
|
||||
)
|
||||
resolution_outputs: Mapped[list["SessionResolutionOutput"]] = relationship(
|
||||
"SessionResolutionOutput",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.core.database import Base
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.script_template import ScriptGeneration
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.fork_point import ForkPoint
|
||||
|
||||
|
||||
class AISessionStep(Base):
|
||||
@@ -34,7 +36,7 @@ class AISessionStep(Base):
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"step_type IN ('question', 'action', 'script_generation', 'verification', "
|
||||
"'info_request', 'note', 'intake_analysis')",
|
||||
"'info_request', 'note', 'intake_analysis', 'fork')",
|
||||
name="ck_ai_session_steps_step_type",
|
||||
),
|
||||
)
|
||||
@@ -119,6 +121,24 @@ class AISessionStep(Base):
|
||||
Integer, nullable=False, default=0,
|
||||
)
|
||||
|
||||
# ── Branching ──
|
||||
branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("session_branches.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="NULL = pre-branching/root messages",
|
||||
)
|
||||
is_fork_point: Mapped[bool] = mapped_column(
|
||||
default=False,
|
||||
comment="Whether this step triggered a fork",
|
||||
)
|
||||
fork_point_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("fork_points.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
@@ -30,3 +30,27 @@ class FileUpload(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
# ── AI description + branching context ──
|
||||
ai_description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="AI-generated one-sentence description of the file",
|
||||
)
|
||||
extracted_content: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="Extracted text from logs/configs",
|
||||
)
|
||||
content_summary: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
comment="AI summary for long text files (>2000 tokens)",
|
||||
)
|
||||
uploaded_on_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("session_branches.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
uploaded_at_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_session_steps.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
35
backend/app/models/fork_point.py
Normal file
35
backend/app/models/fork_point.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Fork point model — captures decision points where a session branches."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Text, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
|
||||
|
||||
class ForkPoint(Base):
|
||||
"""A decision point where a session forks into multiple branches.
|
||||
options JSONB: [{label, description, branch_id, status}]
|
||||
"""
|
||||
__tablename__ = "fork_points"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
parent_branch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=False)
|
||||
trigger_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
|
||||
fork_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
options: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list, comment="[{label, description, branch_id, status}]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
parent_branch: Mapped["SessionBranch"] = relationship("SessionBranch", foreign_keys=[parent_branch_id])
|
||||
trigger_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep", foreign_keys=[trigger_step_id])
|
||||
58
backend/app/models/session_branch.py
Normal file
58
backend/app/models/session_branch.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Session branch model — represents a diagnostic hypothesis path within a FlowPilot session."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, CheckConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class SessionBranch(Base):
|
||||
"""A diagnostic branch within a FlowPilot session."""
|
||||
__tablename__ = "session_branches"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('active', 'dead_end', 'solved', 'untried', 'revived')",
|
||||
name="ck_session_branches_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"branch_order > 0",
|
||||
name="ck_session_branches_branch_order_positive",
|
||||
),
|
||||
Index("ix_session_branches_session_id", "session_id"),
|
||||
Index("ix_session_branches_parent_branch_id", "parent_branch_id"),
|
||||
Index("ix_session_branches_session_status", "session_id", "status"),
|
||||
Index("ix_session_branches_session_order", "session_id", "branch_order"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False)
|
||||
parent_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="CASCADE"), nullable=True)
|
||||
fork_point_step_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_session_steps.id", ondelete="SET NULL"), nullable=True)
|
||||
branch_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
status_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
status_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status_changed_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
conversation_messages: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list, comment="LLM message history scoped to this branch")
|
||||
context_summary: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="{tried: [], concluded: str, artifacts: []}")
|
||||
evidence_from_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)
|
||||
evidence_description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys="[SessionBranch.session_id]", back_populates="branches")
|
||||
parent_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[parent_branch_id])
|
||||
fork_point_step: Mapped[Optional["AISessionStep"]] = relationship("AISessionStep", foreign_keys=[fork_point_step_id])
|
||||
status_changed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[status_changed_by])
|
||||
evidence_source: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", remote_side="SessionBranch.id", foreign_keys=[evidence_from_branch_id])
|
||||
50
backend/app/models/session_handoff.py
Normal file
50
backend/app/models/session_handoff.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Session handoff model — unified park/escalate with history."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class SessionHandoff(Base):
|
||||
"""A handoff event — either parking or escalating a session.
|
||||
Dual-writes to ai_sessions.escalation_package for backward compat.
|
||||
"""
|
||||
__tablename__ = "session_handoffs"
|
||||
__table_args__ = (
|
||||
CheckConstraint("intent IN ('park', 'escalate')", name="ck_session_handoffs_intent"),
|
||||
CheckConstraint("priority IN ('normal', 'elevated')", name="ck_session_handoffs_priority"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
handed_off_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
intent: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
source_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("session_branches.id", ondelete="SET NULL"), nullable=True)
|
||||
snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict, comment="Branch map, status, next step, waiting on, watch out")
|
||||
ai_assessment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
ai_assessment_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="{likely_cause, suggested_steps, confidence}")
|
||||
artifacts: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(JSONB, nullable=True, comment="[{name, type, reference}]")
|
||||
engineer_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
||||
claimed_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
claimed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
psa_note_pushed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
psa_note_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
notification_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
session: Mapped["AISession"] = relationship("AISession", foreign_keys=[session_id])
|
||||
handed_off_by_user: Mapped["User"] = relationship("User", foreign_keys=[handed_off_by])
|
||||
source_branch: Mapped[Optional["SessionBranch"]] = relationship("SessionBranch", foreign_keys=[source_branch_id])
|
||||
claimed_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[claimed_by])
|
||||
39
backend/app/models/session_resolution_output.py
Normal file
39
backend/app/models/session_resolution_output.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Session resolution output model — three deliverables generated on resolve."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, CheckConstraint, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SessionResolutionOutput(Base):
|
||||
"""One of three resolution deliverables: PSA ticket notes, KB article, client summary.
|
||||
UNIQUE(session_id, output_type) + upsert so outputs can be regenerated.
|
||||
"""
|
||||
__tablename__ = "session_resolution_outputs"
|
||||
__table_args__ = (
|
||||
CheckConstraint("output_type IN ('psa_ticket_notes', 'knowledge_base', 'client_summary')", name="ck_session_resolution_outputs_output_type"),
|
||||
CheckConstraint("status IN ('draft', 'approved', 'pushed', 'rejected')", name="ck_session_resolution_outputs_status"),
|
||||
UniqueConstraint("session_id", "output_type", name="uq_session_resolution_session_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
output_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
generated_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
structured_data: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True, comment="For KB: {symptoms, root_cause, steps, tags}")
|
||||
edited_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
pushed_to: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
pushed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
pushed_reference: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
generated_by_model: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# Relationships
|
||||
session = relationship("AISession", foreign_keys="SessionResolutionOutput.session_id")
|
||||
@@ -228,6 +228,8 @@ class AISessionDetail(AISessionSummary):
|
||||
ticket_data: dict[str, Any] | None = None
|
||||
steps: list[AISessionStepResponse] = []
|
||||
conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here
|
||||
is_branching: bool = False
|
||||
active_branch_id: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -248,10 +250,40 @@ class ChatMessageRequest(BaseModel):
|
||||
upload_ids: list[UUID] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class ForkBranchInfo(BaseModel):
|
||||
"""Branch info returned when a fork is created."""
|
||||
branch_id: str
|
||||
label: str
|
||||
|
||||
|
||||
class ForkMetadata(BaseModel):
|
||||
"""Metadata returned when the AI suggests a diagnostic fork."""
|
||||
fork_point_id: str
|
||||
fork_reason: str
|
||||
branches: list[ForkBranchInfo]
|
||||
active_branch_id: str
|
||||
|
||||
|
||||
class ActionItem(BaseModel):
|
||||
"""A single action item for the engineer."""
|
||||
label: str
|
||||
command: str | None = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
class QuestionItem(BaseModel):
|
||||
"""A question the AI needs answered by the engineer."""
|
||||
text: str
|
||||
context: str = ""
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""AI response to a chat message."""
|
||||
content: str
|
||||
suggested_flows: list[dict[str, Any]] = []
|
||||
fork: ForkMetadata | None = None
|
||||
actions: list[ActionItem] | None = None
|
||||
questions: list[QuestionItem] | None = None
|
||||
|
||||
|
||||
class AISessionSearchResult(BaseModel):
|
||||
|
||||
83
backend/app/schemas/session_branch.py
Normal file
83
backend/app/schemas/session_branch.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Pydantic schemas for session branches and fork points."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BranchCreate(BaseModel):
|
||||
label: str = Field(..., max_length=200)
|
||||
status: str = "untried"
|
||||
|
||||
|
||||
class BranchUpdate(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|dead_end|solved|untried|revived)$")
|
||||
status_reason: str | None = None
|
||||
|
||||
|
||||
class BranchResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
parent_branch_id: UUID | None
|
||||
fork_point_step_id: UUID | None
|
||||
branch_order: int
|
||||
label: str
|
||||
status: str
|
||||
status_reason: str | None
|
||||
status_changed_at: datetime | None
|
||||
context_summary: dict[str, Any] | None
|
||||
evidence_from_branch_id: UUID | None
|
||||
evidence_description: str | None
|
||||
step_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BranchTreeResponse(BaseModel):
|
||||
branches: list[BranchResponse]
|
||||
active_branch_id: UUID | None
|
||||
|
||||
|
||||
class ForkOption(BaseModel):
|
||||
label: str = Field(..., max_length=200)
|
||||
description: str = Field(..., max_length=500)
|
||||
|
||||
|
||||
class ForkCreateRequest(BaseModel):
|
||||
fork_reason: str = Field(..., min_length=5, max_length=2000)
|
||||
options: list[ForkOption] = Field(..., min_length=2, max_length=10)
|
||||
|
||||
|
||||
class ForkPointResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
parent_branch_id: UUID
|
||||
trigger_step_id: UUID | None
|
||||
fork_reason: str
|
||||
options: list[dict[str, Any]]
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BranchSwitchResponse(BaseModel):
|
||||
active_branch_id: UUID
|
||||
branch: BranchResponse
|
||||
conversation_messages: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ReviveRequest(BaseModel):
|
||||
evidence_from_branch_id: UUID
|
||||
evidence_description: str = Field(..., min_length=5, max_length=2000)
|
||||
|
||||
|
||||
class BranchMessageRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1, max_length=8000)
|
||||
upload_ids: list[UUID] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
class BranchMessageResponse(BaseModel):
|
||||
content: str
|
||||
branch_id: UUID
|
||||
step_id: UUID | None = None
|
||||
57
backend/app/schemas/session_handoff.py
Normal file
57
backend/app/schemas/session_handoff.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Pydantic schemas for session handoffs."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HandoffCreateRequest(BaseModel):
|
||||
intent: str = Field(..., pattern="^(park|escalate)$")
|
||||
engineer_notes: str | None = None
|
||||
priority: str = Field("normal", pattern="^(normal|elevated)$")
|
||||
|
||||
|
||||
class HandoffResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
handed_off_by: UUID
|
||||
intent: str
|
||||
source_branch_id: UUID | None
|
||||
snapshot: dict[str, Any]
|
||||
ai_assessment: str | None
|
||||
ai_assessment_data: dict[str, Any] | None
|
||||
artifacts: list[dict[str, Any]] | None
|
||||
engineer_notes: str | None
|
||||
priority: str
|
||||
claimed_by: UUID | None
|
||||
claimed_at: datetime | None
|
||||
psa_note_pushed: bool
|
||||
notification_sent: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class HandoffClaimRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class HandoffBriefingResponse(BaseModel):
|
||||
briefing: str
|
||||
handoff: HandoffResponse
|
||||
|
||||
|
||||
class QueueItemResponse(BaseModel):
|
||||
handoff_id: UUID
|
||||
session_id: UUID
|
||||
intent: str
|
||||
problem_summary: str | None
|
||||
problem_domain: str | None
|
||||
priority: str
|
||||
handed_off_by_name: str | None
|
||||
engineer_notes: str | None
|
||||
branch_count: int = 0
|
||||
created_at: datetime
|
||||
claimed_by: UUID | None
|
||||
claimed_at: datetime | None
|
||||
model_config = {"from_attributes": True}
|
||||
42
backend/app/schemas/session_resolution.py
Normal file
42
backend/app/schemas/session_resolution.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Pydantic schemas for session resolution outputs."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ResolutionOutputResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
output_type: str
|
||||
generated_content: str
|
||||
structured_data: dict[str, Any] | None
|
||||
edited_content: str | None
|
||||
status: str
|
||||
pushed_to: str | None
|
||||
pushed_at: datetime | None
|
||||
pushed_reference: str | None
|
||||
generated_by_model: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ResolutionOutputEditRequest(BaseModel):
|
||||
edited_content: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ResolutionOutputPushRequest(BaseModel):
|
||||
destination: str = Field(..., pattern="^(psa|kb_library|clipboard|email)$")
|
||||
|
||||
|
||||
class ResolutionOutputPushResponse(BaseModel):
|
||||
output_id: UUID
|
||||
status: str
|
||||
pushed_to: str
|
||||
pushed_reference: str | None = None
|
||||
|
||||
|
||||
class AllResolutionOutputsResponse(BaseModel):
|
||||
outputs: list[ResolutionOutputResponse]
|
||||
@@ -33,28 +33,59 @@ deep expertise across the MSP technology stack:
|
||||
- PowerShell scripting and automation
|
||||
- Security: MFA, Conditional Access, EDR, backup/DR
|
||||
|
||||
## How to Answer
|
||||
- **Be direct and actionable.** Engineers are mid-ticket — lead with the fix or next \
|
||||
diagnostic step, then explain why in one sentence if helpful. Skip background unless asked.
|
||||
- **Include specifics.** Exact commands, registry paths, config values, port numbers. \
|
||||
Vague advice wastes time.
|
||||
- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \
|
||||
say so upfront — before the command.
|
||||
- **Use structured formatting.** Bullet points for steps, code blocks for commands, \
|
||||
bold for key terms. Engineers scan, they don't read essays.
|
||||
- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \
|
||||
where to verify (vendor docs, a specific KB article) rather than guessing.
|
||||
## RESPONSE FORMAT — READ THIS FIRST
|
||||
|
||||
## How to Ask Questions
|
||||
- **Default to a single focused question.** Ask what you need to know right now to make progress.
|
||||
- **Use contextual bullets sparingly.** If the question could be ambiguous (e.g., "what error?" \
|
||||
when there are multiple common patterns), add 2-3 sub-bullets to help the engineer recognize \
|
||||
what you're asking for — but keep it short.
|
||||
- **Multiple questions only when blocking.** If you genuinely cannot proceed without knowing \
|
||||
two things (e.g., both the error message AND which users are affected), preface it clearly: \
|
||||
"Before continuing troubleshooting, I need to know: 1) [question], 2) [question]." Use this rarely.
|
||||
- **Avoid interrogation mode.** Don't fire off 5 questions in a row. Get one answer, make \
|
||||
progress, then ask the next question if needed.
|
||||
Every response you write MUST follow this exact structure:
|
||||
|
||||
1. **1-3 sentences of analysis** (what the symptoms tell you)
|
||||
2. **[QUESTIONS] marker** with 1-3 questions for the engineer (if you need info)
|
||||
3. **[ACTIONS] marker** with 1-4 diagnostic commands to run (if applicable)
|
||||
|
||||
You MUST include at least one marker ([QUESTIONS] or [ACTIONS]) in every response. \
|
||||
A response with only prose and no markers is INVALID and will break the UI.
|
||||
|
||||
### Complete example of a correct first response:
|
||||
|
||||
User: "Outlook disconnects every 10-15 min, Teams drops too, only this one user, WiFi"
|
||||
|
||||
Your response:
|
||||
|
||||
Both apps dropping on the same 10-15 min cycle on WiFi points to a network-layer \
|
||||
timeout — likely DHCP lease renewal, AP roaming, or NIC power management. Single-user \
|
||||
scope narrows it to this endpoint.
|
||||
|
||||
[QUESTIONS]
|
||||
[{"text": "Is this user on a laptop or desktop?", "context": "Laptops have power management and docking transitions that cause WiFi drops"},
|
||||
{"text": "Are they on corporate WiFi or working from home?", "context": "Corporate WiFi with multiple APs can cause roaming disconnects"}]
|
||||
[/QUESTIONS]
|
||||
|
||||
[ACTIONS]
|
||||
[{"label": "Check DHCP lease time", "command": "ipconfig /all | Select-String -Pattern 'DHCP|IPv4|Lease|Gateway'", "description": "Short lease times (under 1 hour) cause brief drops at renewal"},
|
||||
{"label": "Check NIC power management", "command": "Get-NetAdapterPowerManagement | Select Name, AllowComputerToTurnOffDevice", "description": "If True, Windows is likely killing the adapter during idle periods"},
|
||||
{"label": "Check WiFi signal and AP", "command": "netsh wlan show interfaces", "description": "Shows current BSSID, signal strength, and whether they are bouncing between APs"}]
|
||||
[/ACTIONS]
|
||||
|
||||
### Rules
|
||||
|
||||
**Prose rules:**
|
||||
- MAXIMUM 3 sentences. No numbered lists. No "Most likely causes: 1... 2... 3..."
|
||||
- Never narrate intentions ("I want to check...", "Let's get eyes on..."). Just include markers.
|
||||
- Be specific: exact commands, registry paths, port numbers.
|
||||
- Warn before destructive actions.
|
||||
|
||||
**[QUESTIONS] marker format:**
|
||||
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
|
||||
- 1-3 questions per response
|
||||
- Do NOT ask questions inline in your prose. ALL questions go in the marker.
|
||||
|
||||
**[ACTIONS] marker format:**
|
||||
- JSON array of objects with `label` (required), `command` (optional), `description` (required)
|
||||
- 1-4 action items per response
|
||||
- Commands should be PowerShell unless context indicates Linux/Mac
|
||||
- For GUI-only steps, omit `command`
|
||||
|
||||
**Both markers are stripped from display** — the engineer sees them as interactive UI cards, \
|
||||
not raw JSON. Put analysis BEFORE markers. Markers go at the END of your response.
|
||||
|
||||
## Using the Team's Flow Library
|
||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||
@@ -73,10 +104,57 @@ When an image is attached, analyze it carefully. Screenshots of error messages,
|
||||
config panels, event viewer logs, and network diagrams are common in MSP work. \
|
||||
Describe what you see and use the visual information to inform your troubleshooting advice.
|
||||
|
||||
## Diagnostic Forking
|
||||
When symptoms point to 2+ different subsystems or root causes, you MUST create a diagnostic \
|
||||
fork. Forking tracks the different investigation paths in the background — the engineer \
|
||||
sees them in a sidebar and can switch between them anytime.
|
||||
|
||||
**IMPORTANT: Forking is invisible to the engineer in the conversation.** You do NOT mention \
|
||||
forking, branching, or paths to the engineer. You just continue the conversation naturally. \
|
||||
The fork marker is metadata that the system uses behind the scenes.
|
||||
|
||||
**You MUST fork when:**
|
||||
- Symptoms affect multiple applications or layers (e.g., Outlook AND Teams dropping)
|
||||
- The problem could be endpoint-side OR infrastructure-side
|
||||
- Multiple well-known causes match the exact same symptom pattern
|
||||
|
||||
**Do NOT fork when:**
|
||||
- One cause is clearly >80% likely — just investigate that first
|
||||
- A single yes/no question would eliminate all but one possibility
|
||||
|
||||
**Fork response format:**
|
||||
Even when forking, you MUST still follow the RESPONSE FORMAT above. Your response \
|
||||
must include [QUESTIONS] and/or [ACTIONS] markers — the fork marker is IN ADDITION \
|
||||
to those, not a replacement. Do NOT ask questions in prose — put them in [QUESTIONS].
|
||||
|
||||
Structure: 1-3 sentences of analysis → [QUESTIONS] and/or [ACTIONS] → [FORK] at the very end.
|
||||
|
||||
Example flow:
|
||||
- Engineer: "Outlook disconnects every 15 min, Teams drops too, only one user"
|
||||
- You: "The 10-15 min pattern with both apps points to network layer."
|
||||
- Then: [QUESTIONS] marker, then [ACTIONS] marker, then [FORK] marker last.
|
||||
|
||||
The fork marker is stripped from display — the engineer never sees it. \
|
||||
The system creates branches silently. Based on the engineer's answer, you pick \
|
||||
the most relevant branch to investigate first.
|
||||
|
||||
To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
||||
|
||||
[FORK]
|
||||
{"fork_reason": "Brief reason", "options": [{"label": "Short name", "description": "One sentence"}, {"label": "Another", "description": "One sentence"}]}
|
||||
[/FORK]
|
||||
|
||||
2-4 options. Never mention "fork", "branch", or "path" in your visible text.
|
||||
|
||||
## Boundaries
|
||||
- Stay focused on IT infrastructure, systems administration, and MSP operations.
|
||||
- If a question is clearly outside your domain, say so briefly and redirect.
|
||||
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
||||
|
||||
## FINAL REMINDER — THIS OVERRIDES EVERYTHING ABOVE
|
||||
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
|
||||
No exceptions. Not even when forking. A response without at least one of these markers \
|
||||
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
|
||||
"""
|
||||
|
||||
|
||||
@@ -174,6 +252,16 @@ async def _call_anthropic_cached(
|
||||
}
|
||||
|
||||
# Add the new user message (uncached — it's new each turn)
|
||||
# Append a format reminder to the user message so the model sees it
|
||||
# immediately before generating. This is invisible to the user (stripped
|
||||
# before storage) but critical for structured output compliance.
|
||||
format_reminder = (
|
||||
"\n\n[SYSTEM: Remember — your response MUST end with [QUESTIONS] "
|
||||
"and/or [ACTIONS] markers containing valid JSON arrays. "
|
||||
"Responses without markers break the UI.]"
|
||||
)
|
||||
reminded_message = new_message + format_reminder
|
||||
|
||||
# If images are attached, build multimodal content blocks
|
||||
if images:
|
||||
content_blocks: list[dict[str, Any]] = []
|
||||
@@ -186,10 +274,10 @@ async def _call_anthropic_cached(
|
||||
"data": img["data"],
|
||||
},
|
||||
})
|
||||
content_blocks.append({"type": "text", "text": new_message})
|
||||
content_blocks.append({"type": "text", "text": reminded_message})
|
||||
messages.append({"role": "user", "content": content_blocks})
|
||||
else:
|
||||
messages.append({"role": "user", "content": new_message})
|
||||
messages.append({"role": "user", "content": reminded_message})
|
||||
|
||||
# MCP server config (optional — controlled by settings)
|
||||
mcp_servers = anthropic.NOT_GIVEN
|
||||
|
||||
58
backend/app/services/branch_aware_prompt_builder.py
Normal file
58
backend/app/services/branch_aware_prompt_builder.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Branch-aware prompt builder — assembles AI context with cross-branch awareness.
|
||||
|
||||
Pure function: takes data, returns dict matching _call_ai parameter names.
|
||||
No DB access, no LLM calls. The caller pre-fetches all data.
|
||||
|
||||
Return keys: system_base, rag_context, history, new_message, images
|
||||
- system_base: stable system prompt (cached by Anthropic)
|
||||
- rag_context: cross-branch summaries + attachment descriptions (NOT cached)
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from app.services.assistant_chat_service import ASSISTANT_SYSTEM_PROMPT
|
||||
|
||||
|
||||
class BranchAwarePromptBuilder:
|
||||
"""Assembles prompt components for branch-aware AI calls."""
|
||||
|
||||
def build(
|
||||
self,
|
||||
branch_messages: list[dict[str, Any]],
|
||||
sibling_summaries: str,
|
||||
session_context: str,
|
||||
attachments: list[dict[str, Any]],
|
||||
new_message: str,
|
||||
revival_context: str | None = None,
|
||||
token_budget: int = 100_000,
|
||||
) -> dict[str, Any]:
|
||||
"""Build prompt components for _call_ai.
|
||||
|
||||
Returns dict with keys: system_base, rag_context, history, new_message, images.
|
||||
"""
|
||||
# 1. system_base — stable, cached across turns
|
||||
system_base = ASSISTANT_SYSTEM_PROMPT + "\n\n## Session Context\n" + session_context
|
||||
|
||||
# 2. rag_context — changes per query, NOT cached
|
||||
rag_parts = []
|
||||
if revival_context:
|
||||
rag_parts.append(f"\n## Branch Revival\n{revival_context}")
|
||||
if sibling_summaries:
|
||||
rag_parts.append(sibling_summaries)
|
||||
rag_context = "\n".join(rag_parts)
|
||||
|
||||
# 3. history — branch messages filtered to user/assistant
|
||||
history = []
|
||||
for msg in branch_messages:
|
||||
if msg.get("role") in ("user", "assistant"):
|
||||
history.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# 4. images
|
||||
images = attachments if attachments else None
|
||||
|
||||
return {
|
||||
"system_base": system_base,
|
||||
"rag_context": rag_context,
|
||||
"history": history,
|
||||
"new_message": new_message,
|
||||
"images": images,
|
||||
}
|
||||
238
backend/app/services/branch_manager.py
Normal file
238
backend/app/services/branch_manager.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Branch lifecycle management for conversational branching."""
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.fork_point import ForkPoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BranchManager:
|
||||
"""Branch lifecycle management."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_root_branch(self, session_id: UUID) -> SessionBranch:
|
||||
"""Create the root branch, copy conversation_messages, set is_branching=True."""
|
||||
result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
root = SessionBranch(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session_id,
|
||||
parent_branch_id=None,
|
||||
branch_order=1,
|
||||
label="Root",
|
||||
status="active",
|
||||
conversation_messages=list(session.conversation_messages or []),
|
||||
)
|
||||
self.db.add(root)
|
||||
|
||||
session.is_branching = True
|
||||
session.active_branch_id = root.id
|
||||
|
||||
await self.db.flush()
|
||||
return root
|
||||
|
||||
async def create_fork(
|
||||
self,
|
||||
session_id: UUID,
|
||||
parent_branch_id: UUID,
|
||||
trigger_step_id: UUID | None,
|
||||
fork_reason: str,
|
||||
options: list[dict[str, str]],
|
||||
) -> tuple[ForkPoint, list[SessionBranch]]:
|
||||
"""Create a fork point with N branches."""
|
||||
branch_ids = [uuid.uuid4() for _ in options]
|
||||
|
||||
fork_options = []
|
||||
for i, opt in enumerate(options):
|
||||
fork_options.append({
|
||||
"label": opt["label"],
|
||||
"description": opt["description"],
|
||||
"branch_id": str(branch_ids[i]),
|
||||
"status": "untried",
|
||||
})
|
||||
|
||||
fork_point = ForkPoint(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session_id,
|
||||
parent_branch_id=parent_branch_id,
|
||||
trigger_step_id=trigger_step_id,
|
||||
fork_reason=fork_reason,
|
||||
options=fork_options,
|
||||
)
|
||||
self.db.add(fork_point)
|
||||
|
||||
# Get parent branch messages for context inheritance
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == parent_branch_id)
|
||||
)
|
||||
parent = result.scalar_one_or_none()
|
||||
parent_messages = list(parent.conversation_messages or []) if parent else []
|
||||
|
||||
branches = []
|
||||
for i, opt in enumerate(options):
|
||||
branch = SessionBranch(
|
||||
id=branch_ids[i],
|
||||
session_id=session_id,
|
||||
parent_branch_id=parent_branch_id,
|
||||
fork_point_step_id=trigger_step_id,
|
||||
branch_order=i + 1,
|
||||
label=opt["label"],
|
||||
status="untried",
|
||||
conversation_messages=parent_messages,
|
||||
)
|
||||
self.db.add(branch)
|
||||
branches.append(branch)
|
||||
|
||||
# Mark trigger step as fork point
|
||||
if trigger_step_id:
|
||||
step_result = await self.db.execute(
|
||||
select(AISessionStep).where(AISessionStep.id == trigger_step_id)
|
||||
)
|
||||
step = step_result.scalar_one_or_none()
|
||||
if step:
|
||||
step.is_fork_point = True
|
||||
step.fork_point_id = fork_point.id
|
||||
|
||||
await self.db.flush()
|
||||
return fork_point, branches
|
||||
|
||||
async def switch_branch(self, session_id: UUID, target_branch_id: UUID) -> SessionBranch:
|
||||
"""Switch the active branch for a session."""
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch).where(
|
||||
SessionBranch.id == target_branch_id,
|
||||
SessionBranch.session_id == session_id,
|
||||
)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
if not branch:
|
||||
raise ValueError(f"Branch {target_branch_id} not found in session {session_id}")
|
||||
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
session = session_result.scalar_one()
|
||||
session.active_branch_id = target_branch_id
|
||||
|
||||
if branch.status == "untried":
|
||||
branch.status = "active"
|
||||
branch.status_changed_at = datetime.now(timezone.utc)
|
||||
|
||||
await self.db.flush()
|
||||
return branch
|
||||
|
||||
async def mark_branch_status(
|
||||
self,
|
||||
branch_id: UUID,
|
||||
status: str,
|
||||
reason: str | None = None,
|
||||
user_id: UUID | None = None,
|
||||
) -> SessionBranch:
|
||||
"""Update a branch's status."""
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == branch_id)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
if not branch:
|
||||
raise ValueError(f"Branch {branch_id} not found")
|
||||
|
||||
branch.status = status
|
||||
branch.status_reason = reason
|
||||
branch.status_changed_at = datetime.now(timezone.utc)
|
||||
branch.status_changed_by = user_id
|
||||
|
||||
await self.db.flush()
|
||||
return branch
|
||||
|
||||
async def revive_branch(
|
||||
self,
|
||||
branch_id: UUID,
|
||||
evidence_from_branch_id: UUID,
|
||||
evidence_description: str,
|
||||
) -> SessionBranch:
|
||||
"""Revive a dead-end branch with evidence from another branch."""
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == branch_id)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
if not branch:
|
||||
raise ValueError(f"Branch {branch_id} not found")
|
||||
|
||||
branch.status = "revived"
|
||||
branch.status_changed_at = datetime.now(timezone.utc)
|
||||
branch.evidence_from_branch_id = evidence_from_branch_id
|
||||
branch.evidence_description = evidence_description
|
||||
|
||||
revival_msg = {
|
||||
"role": "system",
|
||||
"content": f"[Branch Revived] New evidence from another branch: {evidence_description}",
|
||||
}
|
||||
msgs = list(branch.conversation_messages or [])
|
||||
msgs.append(revival_msg)
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
await self.db.flush()
|
||||
return branch
|
||||
|
||||
async def get_branch_tree(self, session_id: UUID) -> list[SessionBranch]:
|
||||
"""Get all branches for a session, ordered by branch_order."""
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch)
|
||||
.where(SessionBranch.session_id == session_id)
|
||||
.order_by(SessionBranch.branch_order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def build_cross_branch_context(self, branch_id: UUID) -> str:
|
||||
"""Build cross-branch context from sibling summaries."""
|
||||
result = await self.db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == branch_id)
|
||||
)
|
||||
branch = result.scalar_one_or_none()
|
||||
if not branch:
|
||||
return ""
|
||||
|
||||
siblings_result = await self.db.execute(
|
||||
select(SessionBranch)
|
||||
.where(
|
||||
SessionBranch.session_id == branch.session_id,
|
||||
SessionBranch.id != branch_id,
|
||||
)
|
||||
.order_by(SessionBranch.branch_order)
|
||||
)
|
||||
siblings = list(siblings_result.scalars().all())
|
||||
|
||||
if not siblings:
|
||||
return ""
|
||||
|
||||
priority = {"active": 0, "untried": 1, "revived": 2, "dead_end": 3, "solved": 4}
|
||||
siblings.sort(key=lambda b: priority.get(b.status, 5))
|
||||
|
||||
parts = ["\n## Cross-Branch Context"]
|
||||
for sib in siblings:
|
||||
summary = sib.context_summary
|
||||
if summary:
|
||||
tried = ", ".join(summary.get("tried", []))
|
||||
concluded = summary.get("concluded", "No conclusion yet")
|
||||
parts.append(f"- **{sib.label}** [{sib.status}]: Tried: {tried}. {concluded}")
|
||||
else:
|
||||
parts.append(f"- **{sib.label}** [{sib.status}]: No summary yet.")
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -53,7 +53,10 @@ Your response MUST be a valid JSON object with one of these shapes:
|
||||
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request | open_script_builder", "expected_outcome": "What success looks like", "confidence": 0.78}
|
||||
|
||||
3. Resolution suggestion:
|
||||
{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}\
|
||||
{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}
|
||||
|
||||
4. Diagnostic fork (explore multiple hypotheses in parallel):
|
||||
{"type": "fork", "content": "Why we need to branch", "reasoning": "Internal why", "context_message": "Shown to engineer explaining the fork", "fork_reason": "Multiple possible root causes need independent investigation", "options": [{"label": "Branch name", "description": "What this branch will investigate"}], "confidence": 0.45}\
|
||||
"""
|
||||
|
||||
FLOWPILOT_SYSTEM_PROMPT = """\
|
||||
@@ -83,6 +86,17 @@ Every response must have a "type" field: "question", "action", or "resolution_su
|
||||
- Never suggest restarting or rebooting as a first step — diagnose first
|
||||
- Be specific: "Check Event Viewer > System > source NTFS" not "check the logs"
|
||||
|
||||
## DIAGNOSTIC FORKING
|
||||
When you detect MULTIPLE equally plausible root causes that require DIFFERENT investigation paths, use a "fork" response to let the engineer explore them as parallel branches. Use forks when:
|
||||
- Two or more hypotheses have similar probability and investigating one doesn't help eliminate the other
|
||||
- The engineer has tried the obvious path and results are ambiguous (could be DNS OR firewall OR auth)
|
||||
- Symptoms point to multiple subsystems (e.g., "slow login" could be AD replication, DNS, or group policy)
|
||||
Do NOT fork when:
|
||||
- One hypothesis is clearly more likely — just investigate that first
|
||||
- You can ask a single question that would eliminate most possibilities
|
||||
- The session has fewer than 3 steps (gather more info first)
|
||||
Fork options should be 2-4 independent investigation paths. Each option label should be a clear, short hypothesis name (e.g., "DNS Resolution Issue", "AD Replication Lag").
|
||||
|
||||
{team_context}
|
||||
|
||||
{matched_flow_context}\
|
||||
@@ -121,7 +135,7 @@ def _parse_structured_output(raw_text: str) -> dict[str, Any]:
|
||||
if not isinstance(data, dict) or "type" not in data:
|
||||
raise ValueError("LLM response missing required 'type' field")
|
||||
|
||||
valid_types = {"question", "action", "resolution_suggestion"}
|
||||
valid_types = {"question", "action", "resolution_suggestion", "fork"}
|
||||
if data["type"] not in valid_types:
|
||||
raise ValueError(f"Unknown response type: {data['type']}")
|
||||
|
||||
@@ -319,6 +333,7 @@ async def start_session(
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
)
|
||||
db.add(step)
|
||||
|
||||
@@ -421,11 +436,49 @@ async def process_response(
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
)
|
||||
db.add(step)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Handle fork: create branches and enrich step content with branch IDs
|
||||
if parsed["type"] == "fork":
|
||||
from app.services.branch_manager import BranchManager
|
||||
mgr = BranchManager(db)
|
||||
|
||||
# Create root branch if this is the first fork in the session
|
||||
if not session.is_branching:
|
||||
root = await mgr.create_root_branch(session.id)
|
||||
# Reassign the step to the root branch
|
||||
step.branch_id = root.id
|
||||
|
||||
fork_options = parsed.get("options", [])
|
||||
fork_point, new_branches = await mgr.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=session.active_branch_id,
|
||||
trigger_step_id=step.id,
|
||||
fork_reason=parsed.get("fork_reason", ""),
|
||||
options=[{"label": o["label"], "description": o.get("description", "")} for o in fork_options],
|
||||
)
|
||||
|
||||
# Enrich the step content with fork_point_id and branch IDs for frontend
|
||||
enriched_content = dict(step.content or {})
|
||||
enriched_content["fork_point_id"] = str(fork_point.id)
|
||||
enriched_content["fork_branches"] = [
|
||||
{"branch_id": str(b.id), "label": b.label}
|
||||
for b in new_branches
|
||||
]
|
||||
step.content = enriched_content
|
||||
step.is_fork_point = True
|
||||
step.fork_point_id = fork_point.id
|
||||
|
||||
# Auto-switch to the first branch
|
||||
first_branch = new_branches[0]
|
||||
await mgr.switch_branch(session.id, first_branch.id)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Check if resolution was suggested
|
||||
resolution_suggested = parsed["type"] == "resolution_suggestion"
|
||||
resolution_summary = parsed.get("resolution_summary") if resolution_suggested else None
|
||||
@@ -640,6 +693,7 @@ async def pickup_session(
|
||||
briefing_step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="action",
|
||||
content={
|
||||
@@ -714,6 +768,7 @@ async def pickup_session(
|
||||
parsed=parsed,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
)
|
||||
db.add(next_step)
|
||||
|
||||
@@ -926,6 +981,7 @@ async def generate_status_update(
|
||||
step = AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session.id,
|
||||
branch_id=session.active_branch_id if session.is_branching else None,
|
||||
step_order=session.step_count,
|
||||
step_type="status_update",
|
||||
content={
|
||||
@@ -1207,6 +1263,7 @@ def _create_step_from_parsed(
|
||||
parsed: dict[str, Any],
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
branch_id: UUID | None = None,
|
||||
) -> AISessionStep:
|
||||
"""Create an AISessionStep from parsed LLM output."""
|
||||
step_type = parsed["type"]
|
||||
@@ -1233,6 +1290,11 @@ def _create_step_from_parsed(
|
||||
content["follow_up_recommendations"] = parsed.get("follow_up_recommendations", [])
|
||||
content["allow_free_text"] = False
|
||||
content["allow_skip"] = False
|
||||
elif parsed["type"] == "fork":
|
||||
content["fork_reason"] = parsed.get("fork_reason", "")
|
||||
content["fork_options"] = parsed.get("options", [])
|
||||
content["allow_free_text"] = False
|
||||
content["allow_skip"] = False
|
||||
|
||||
# Extract options for question type
|
||||
options = None
|
||||
@@ -1244,6 +1306,7 @@ def _create_step_from_parsed(
|
||||
return AISessionStep(
|
||||
id=uuid.uuid4(),
|
||||
session_id=session_id,
|
||||
branch_id=branch_id,
|
||||
step_order=step_order,
|
||||
step_type=step_type if parsed["type"] != "resolution_suggestion" else "action",
|
||||
content=content,
|
||||
|
||||
289
backend/app/services/handoff_manager.py
Normal file
289
backend/app/services/handoff_manager.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Handoff management — unified park/escalate with dual-write backward compat.
|
||||
|
||||
Creates handoff snapshots, AI assessments (for escalations), claim workflow,
|
||||
and queue queries. Dual-writes to ai_sessions.escalation_package for
|
||||
backward compatibility with the existing escalation queue.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HandoffManager:
|
||||
"""Unified park/escalate handoff management."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_handoff(
|
||||
self,
|
||||
session_id: UUID,
|
||||
intent: str,
|
||||
engineer_notes: str | None,
|
||||
user_id: UUID,
|
||||
priority: str = "normal",
|
||||
) -> SessionHandoff:
|
||||
"""Create a handoff (park or escalate).
|
||||
|
||||
Generates snapshot, updates session status, dual-writes to
|
||||
escalation_package for backward compat.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
# Generate snapshot
|
||||
snapshot = await self._generate_snapshot(session)
|
||||
|
||||
# Generate AI assessment for escalations
|
||||
ai_assessment = None
|
||||
ai_assessment_data = None
|
||||
if intent == "escalate":
|
||||
ai_assessment, ai_assessment_data = await self._generate_ai_assessment(session)
|
||||
|
||||
handoff = SessionHandoff(
|
||||
session_id=session_id,
|
||||
handed_off_by=user_id,
|
||||
intent=intent,
|
||||
source_branch_id=session.active_branch_id,
|
||||
snapshot=snapshot,
|
||||
ai_assessment=ai_assessment,
|
||||
ai_assessment_data=ai_assessment_data,
|
||||
engineer_notes=engineer_notes,
|
||||
priority=priority,
|
||||
)
|
||||
self.db.add(handoff)
|
||||
|
||||
# Update session status
|
||||
if intent == "park":
|
||||
session.status = "paused"
|
||||
elif intent == "escalate":
|
||||
session.status = "escalated"
|
||||
|
||||
session.handoff_count = (session.handoff_count or 0) + 1
|
||||
|
||||
# Dual-write for backward compat
|
||||
session.escalation_package = {
|
||||
"snapshot": snapshot,
|
||||
"intent": intent,
|
||||
"engineer_notes": engineer_notes,
|
||||
"handoff_id": str(handoff.id),
|
||||
}
|
||||
|
||||
await self.db.flush()
|
||||
return handoff
|
||||
|
||||
async def _generate_snapshot(self, session: AISession) -> dict[str, Any]:
|
||||
"""Generate a snapshot of the session state at handoff time."""
|
||||
snapshot: dict[str, Any] = {
|
||||
"problem_summary": session.problem_summary,
|
||||
"problem_domain": session.problem_domain,
|
||||
"status": session.status,
|
||||
"step_count": session.step_count,
|
||||
"confidence_tier": session.confidence_tier,
|
||||
}
|
||||
|
||||
# Add branch map if branching is active
|
||||
if session.is_branching:
|
||||
branches_result = await self.db.execute(
|
||||
select(SessionBranch)
|
||||
.where(SessionBranch.session_id == session.id)
|
||||
.order_by(SessionBranch.branch_order)
|
||||
)
|
||||
branches = list(branches_result.scalars().all())
|
||||
|
||||
branch_map = []
|
||||
for b in branches:
|
||||
branch_map.append({
|
||||
"id": str(b.id),
|
||||
"label": b.label,
|
||||
"status": b.status,
|
||||
"status_reason": b.status_reason,
|
||||
"parent_branch_id": str(b.parent_branch_id) if b.parent_branch_id else None,
|
||||
})
|
||||
snapshot["branch_map"] = branch_map
|
||||
snapshot["active_branch_id"] = str(session.active_branch_id) if session.active_branch_id else None
|
||||
|
||||
return snapshot
|
||||
|
||||
async def claim_session(
|
||||
self,
|
||||
handoff_id: UUID,
|
||||
claiming_user_id: UUID,
|
||||
) -> SessionHandoff:
|
||||
"""Claim a handed-off session."""
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
handoff.claimed_by = claiming_user_id
|
||||
handoff.claimed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Reactivate session
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == handoff.session_id)
|
||||
)
|
||||
session = session_result.scalar_one()
|
||||
session.status = "active"
|
||||
|
||||
# Dual-write
|
||||
session.escalated_to_id = claiming_user_id
|
||||
|
||||
await self.db.flush()
|
||||
return handoff
|
||||
|
||||
async def _generate_ai_assessment(
|
||||
self, session: AISession
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Generate AI diagnostic assessment for escalation handoffs."""
|
||||
try:
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}"
|
||||
msgs = session.conversation_messages or []
|
||||
# Include last 10 messages for context
|
||||
recent = "\n".join(
|
||||
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
|
||||
for m in msgs[-10:]
|
||||
)
|
||||
|
||||
assessment_text, _, _ = await _call_ai(
|
||||
system_base="You are a diagnostic assessment generator for MSP escalations.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=(
|
||||
f"Generate a brief diagnostic assessment for this escalation.\n"
|
||||
f"{context}\n\nRecent conversation:\n{recent}\n\n"
|
||||
f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)"
|
||||
),
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
assessment_data = {
|
||||
"likely_cause": "See assessment text",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
return assessment_text, assessment_data
|
||||
except Exception:
|
||||
logger.exception("Failed to generate AI assessment")
|
||||
return None, None
|
||||
|
||||
async def generate_briefing(
|
||||
self, handoff_id: UUID, claiming_user_id: UUID
|
||||
) -> str:
|
||||
"""Generate a natural-language briefing for the engineer claiming the session."""
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == handoff.session_id)
|
||||
)
|
||||
session = session_result.scalar_one()
|
||||
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
snapshot_text = str(handoff.snapshot)[:2000]
|
||||
briefing, _, _ = await _call_ai(
|
||||
system_base="You are a handoff briefing generator for MSP teams.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=(
|
||||
f"Generate a concise briefing for an engineer picking up this session.\n"
|
||||
f"Problem: {session.problem_summary}\n"
|
||||
f"Intent: {handoff.intent}\n"
|
||||
f"Engineer notes: {handoff.engineer_notes or 'None'}\n"
|
||||
f"Snapshot: {snapshot_text}\n"
|
||||
f"AI Assessment: {handoff.ai_assessment or 'None'}"
|
||||
),
|
||||
max_tokens=500,
|
||||
)
|
||||
return briefing
|
||||
|
||||
async def push_to_psa(self, handoff_id: UUID) -> SessionHandoff:
|
||||
"""Push handoff notes to PSA via existing psa_documentation_service."""
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
try:
|
||||
from app.services.psa_documentation_service import push_session_notes
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == handoff.session_id)
|
||||
)
|
||||
session = session_result.scalar_one()
|
||||
if session.psa_ticket_id and session.psa_connection_id:
|
||||
note_id = await push_session_notes(
|
||||
session=session,
|
||||
notes_content=handoff.ai_assessment or str(handoff.snapshot),
|
||||
db=self.db,
|
||||
)
|
||||
handoff.psa_note_pushed = True
|
||||
handoff.psa_note_id = note_id
|
||||
except Exception:
|
||||
logger.exception(f"Failed to push handoff {handoff_id} to PSA")
|
||||
|
||||
await self.db.flush()
|
||||
return handoff
|
||||
|
||||
async def get_queue(
|
||||
self,
|
||||
team_id: UUID | None = None,
|
||||
account_id: UUID | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get team queue of parked + escalated sessions."""
|
||||
query = (
|
||||
select(SessionHandoff, AISession)
|
||||
.join(AISession, SessionHandoff.session_id == AISession.id)
|
||||
.where(SessionHandoff.claimed_by.is_(None))
|
||||
.order_by(SessionHandoff.created_at.desc())
|
||||
)
|
||||
|
||||
if team_id:
|
||||
query = query.where(AISession.team_id == team_id)
|
||||
elif account_id:
|
||||
query = query.where(AISession.account_id == account_id)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
queue_items = []
|
||||
for handoff, session in rows:
|
||||
queue_items.append({
|
||||
"handoff_id": handoff.id,
|
||||
"session_id": session.id,
|
||||
"intent": handoff.intent,
|
||||
"problem_summary": session.problem_summary,
|
||||
"problem_domain": session.problem_domain,
|
||||
"priority": handoff.priority,
|
||||
"engineer_notes": handoff.engineer_notes,
|
||||
"created_at": handoff.created_at,
|
||||
"claimed_by": handoff.claimed_by,
|
||||
"claimed_at": handoff.claimed_at,
|
||||
})
|
||||
|
||||
return queue_items
|
||||
118
backend/app/services/resolution_output_generator.py
Normal file
118
backend/app/services/resolution_output_generator.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Resolution output generator — three deliverables on session resolve."""
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RESOLUTION_MODEL = "claude-sonnet-4-6"
|
||||
|
||||
|
||||
class ResolutionOutputGenerator:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
||||
result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == session_id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
context = self._build_session_context(session)
|
||||
|
||||
outputs = []
|
||||
for output_type, prompt in [
|
||||
("psa_ticket_notes", self._psa_notes_prompt(context)),
|
||||
("knowledge_base", self._kb_article_prompt(context)),
|
||||
("client_summary", self._client_summary_prompt(context)),
|
||||
]:
|
||||
content, _, _ = await _call_ai(
|
||||
system_base="You are a technical documentation assistant for MSP teams.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=prompt,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
output = SessionResolutionOutput(
|
||||
session_id=session_id,
|
||||
output_type=output_type,
|
||||
generated_content=content,
|
||||
status="draft",
|
||||
generated_by_model=RESOLUTION_MODEL,
|
||||
)
|
||||
self.db.add(output)
|
||||
outputs.append(output)
|
||||
|
||||
await self.db.flush()
|
||||
return outputs
|
||||
|
||||
async def edit_output(self, output_id: UUID, edited_content: str) -> SessionResolutionOutput:
|
||||
result = await self.db.execute(
|
||||
select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id)
|
||||
)
|
||||
output = result.scalar_one_or_none()
|
||||
if not output:
|
||||
raise ValueError(f"Output {output_id} not found")
|
||||
output.edited_content = edited_content
|
||||
await self.db.flush()
|
||||
return output
|
||||
|
||||
async def push_output(self, output_id: UUID, destination: str) -> SessionResolutionOutput:
|
||||
result = await self.db.execute(
|
||||
select(SessionResolutionOutput).where(SessionResolutionOutput.id == output_id)
|
||||
)
|
||||
output = result.scalar_one_or_none()
|
||||
if not output:
|
||||
raise ValueError(f"Output {output_id} not found")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
output.status = "pushed"
|
||||
output.pushed_to = destination
|
||||
output.pushed_at = datetime.now(timezone.utc)
|
||||
await self.db.flush()
|
||||
return output
|
||||
|
||||
def _build_session_context(self, session: AISession) -> str:
|
||||
parts = [
|
||||
f"Problem: {session.problem_summary or 'Unknown'}",
|
||||
f"Domain: {session.problem_domain or 'Unknown'}",
|
||||
f"Resolution: {session.resolution_summary or 'Not specified'}",
|
||||
f"Steps taken: {session.step_count}",
|
||||
]
|
||||
msgs = session.conversation_messages or []
|
||||
if msgs:
|
||||
parts.append("\nConversation highlights:")
|
||||
for msg in msgs[-10:]:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")[:200]
|
||||
parts.append(f" [{role}]: {content}")
|
||||
return "\n".join(parts)
|
||||
|
||||
def _psa_notes_prompt(self, context: str) -> str:
|
||||
return (
|
||||
f"Generate professional PSA ticket notes for this resolved troubleshooting session.\n"
|
||||
f"Format as structured markdown with: Problem, Diagnostic Steps, Resolution, Recommendations.\n\n{context}"
|
||||
)
|
||||
|
||||
def _kb_article_prompt(self, context: str) -> str:
|
||||
return (
|
||||
f"Generate a knowledge base article draft from this resolved session.\n"
|
||||
f"Include: Symptoms, Root Cause, Resolution Steps, Things to Rule Out First.\n\n{context}"
|
||||
)
|
||||
|
||||
def _client_summary_prompt(self, context: str) -> str:
|
||||
return (
|
||||
f"Generate a non-technical summary for the end user/client.\n"
|
||||
f"Explain what was wrong and what was done to fix it in plain language.\n"
|
||||
f"No jargon. 2-3 paragraphs max.\n\n{context}"
|
||||
)
|
||||
@@ -4,7 +4,9 @@ Replaces assistant_chat_service for new chat sessions. Messages are stored
|
||||
in ai_sessions.conversation_messages JSONB. Reuses the same AI calling
|
||||
infrastructure and system prompt from assistant_chat_service.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -22,6 +24,129 @@ from app.services.rag_service import search as rag_search, build_rag_context, ex
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_fork_marker(ai_content: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Extract [FORK]...[/FORK] JSON from AI response.
|
||||
|
||||
Returns (cleaned_content, fork_data_or_None).
|
||||
The fork marker is stripped from the display text.
|
||||
"""
|
||||
match = re.search(r'\[FORK\]\s*([\s\S]*?)\s*\[/FORK\]', ai_content)
|
||||
if not match:
|
||||
return ai_content, None
|
||||
|
||||
try:
|
||||
raw = match.group(1).strip()
|
||||
# Strip markdown fences if AI wrapped it
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw)
|
||||
fork_data = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [FORK] marker: %s", e)
|
||||
return ai_content, None
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(fork_data, dict) or "options" not in fork_data:
|
||||
logger.warning("Invalid [FORK] data — missing 'options'")
|
||||
return ai_content, None
|
||||
|
||||
options = fork_data["options"]
|
||||
if not isinstance(options, list) or len(options) < 2:
|
||||
logger.warning("Invalid [FORK] data — need at least 2 options")
|
||||
return ai_content, None
|
||||
|
||||
# Strip the marker from display text
|
||||
cleaned = ai_content[:match.start()] + ai_content[match.end():]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
return cleaned, fork_data
|
||||
|
||||
|
||||
def _parse_actions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]:
|
||||
"""Extract [ACTIONS]...[/ACTIONS] JSON from AI response.
|
||||
|
||||
Returns (cleaned_content, actions_list_or_None).
|
||||
The actions marker is stripped from the display text.
|
||||
"""
|
||||
match = re.search(r'\[ACTIONS\]\s*([\s\S]*?)\s*\[/ACTIONS\]', ai_content)
|
||||
if not match:
|
||||
return ai_content, None
|
||||
|
||||
try:
|
||||
raw = match.group(1).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw)
|
||||
actions = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [ACTIONS] marker: %s", e)
|
||||
return ai_content, None
|
||||
|
||||
if not isinstance(actions, list) or len(actions) == 0:
|
||||
logger.warning("Invalid [ACTIONS] data — need at least 1 action")
|
||||
return ai_content, None
|
||||
|
||||
# Validate each action has at minimum a label
|
||||
valid_actions = []
|
||||
for a in actions:
|
||||
if isinstance(a, dict) and a.get("label"):
|
||||
valid_actions.append({
|
||||
"label": a["label"],
|
||||
"command": a.get("command"),
|
||||
"description": a.get("description", ""),
|
||||
})
|
||||
|
||||
if not valid_actions:
|
||||
return ai_content, None
|
||||
|
||||
cleaned = ai_content[:match.start()] + ai_content[match.end():]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
return cleaned, valid_actions
|
||||
|
||||
|
||||
def _parse_questions_marker(ai_content: str) -> tuple[str, list[dict[str, Any]] | None]:
|
||||
"""Extract [QUESTIONS]...[/QUESTIONS] JSON from AI response.
|
||||
|
||||
Returns (cleaned_content, questions_list_or_None).
|
||||
The questions marker is stripped from the display text.
|
||||
"""
|
||||
match = re.search(r'\[QUESTIONS\]\s*([\s\S]*?)\s*\[/QUESTIONS\]', ai_content)
|
||||
if not match:
|
||||
return ai_content, None
|
||||
|
||||
try:
|
||||
raw = match.group(1).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw)
|
||||
questions = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [QUESTIONS] marker: %s", e)
|
||||
return ai_content, None
|
||||
|
||||
if not isinstance(questions, list) or len(questions) == 0:
|
||||
logger.warning("Invalid [QUESTIONS] data — need at least 1 question")
|
||||
return ai_content, None
|
||||
|
||||
# Validate each question has at minimum a text field
|
||||
valid_questions = []
|
||||
for q in questions:
|
||||
if isinstance(q, dict) and q.get("text"):
|
||||
valid_questions.append({
|
||||
"text": q["text"],
|
||||
"context": q.get("context", ""),
|
||||
})
|
||||
|
||||
if not valid_questions:
|
||||
return ai_content, None
|
||||
|
||||
cleaned = ai_content[:match.start()] + ai_content[match.end():]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
return cleaned, valid_questions
|
||||
|
||||
|
||||
async def create_chat_session(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
@@ -58,14 +183,14 @@ async def send_chat_message(
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
images: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]], AISession]:
|
||||
) -> tuple[str, list[dict[str, Any]], AISession, dict[str, Any] | None, list[dict[str, Any]] | None, list[dict[str, Any]] | None]:
|
||||
"""Send a message in a chat session and get AI response.
|
||||
|
||||
Args:
|
||||
images: Optional list of {"media_type": str, "data": str (base64)}
|
||||
for vision content attached to this message.
|
||||
|
||||
Returns (ai_content, suggested_flows, session).
|
||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
@@ -81,6 +206,91 @@ async def send_chat_message(
|
||||
if session.status not in ("active", "paused"):
|
||||
raise ValueError(f"Cannot send messages to a {session.status} session")
|
||||
|
||||
# If branching is active, route to branch message handler
|
||||
if session.is_branching and session.active_branch_id:
|
||||
from app.services.branch_manager import BranchManager
|
||||
from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder
|
||||
from app.models.session_branch import SessionBranch
|
||||
|
||||
branch_result = await db.execute(
|
||||
select(SessionBranch).where(SessionBranch.id == session.active_branch_id)
|
||||
)
|
||||
branch = branch_result.scalar_one_or_none()
|
||||
if branch:
|
||||
manager = BranchManager(db)
|
||||
sibling_ctx = await manager.build_cross_branch_context(branch.id)
|
||||
|
||||
builder = BranchAwarePromptBuilder()
|
||||
session_context = f"Problem: {session.problem_summary or 'Unknown'}. Domain: {session.problem_domain or 'Unknown'}."
|
||||
prompt_args = builder.build(
|
||||
branch_messages=branch.conversation_messages,
|
||||
sibling_summaries=sibling_ctx,
|
||||
session_context=session_context,
|
||||
attachments=[],
|
||||
new_message=message,
|
||||
revival_context=branch.evidence_description if branch.status == "revived" else None,
|
||||
)
|
||||
|
||||
# Override images from prompt_args with actual images if provided
|
||||
if images:
|
||||
prompt_args["images"] = images
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(**prompt_args)
|
||||
|
||||
# Update branch conversation
|
||||
msgs = list(branch.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
session.total_input_tokens += input_tokens
|
||||
session.total_output_tokens += output_tokens
|
||||
session.step_count += 2
|
||||
|
||||
if session.status == "paused":
|
||||
session.status = "active"
|
||||
|
||||
# Check for fork, actions, and questions markers in branch response too
|
||||
branch_display, branch_fork_data = _parse_fork_marker(ai_content)
|
||||
branch_display, branch_actions_data = _parse_actions_marker(branch_display)
|
||||
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
||||
if branch_display != ai_content:
|
||||
# Store stripped content in branch history
|
||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||
branch.conversation_messages = msgs
|
||||
|
||||
branch_fork_metadata = None
|
||||
if branch_fork_data:
|
||||
try:
|
||||
fork_point, new_branches = await manager.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=branch.id,
|
||||
trigger_step_id=None,
|
||||
fork_reason=branch_fork_data.get("fork_reason", ""),
|
||||
options=[
|
||||
{"label": o["label"], "description": o.get("description", "")}
|
||||
for o in branch_fork_data["options"]
|
||||
],
|
||||
)
|
||||
first_branch = new_branches[0]
|
||||
await manager.switch_branch(session.id, first_branch.id)
|
||||
branch_fork_metadata = {
|
||||
"fork_point_id": str(fork_point.id),
|
||||
"fork_reason": branch_fork_data.get("fork_reason", ""),
|
||||
"branches": [
|
||||
{"branch_id": str(b.id), "label": b.label}
|
||||
for b in new_branches
|
||||
],
|
||||
"active_branch_id": str(first_branch.id),
|
||||
}
|
||||
await db.flush()
|
||||
except Exception:
|
||||
logger.exception("Failed to create fork within branch for session %s", session.id)
|
||||
|
||||
suggested_flows = extract_suggested_flows(
|
||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
||||
)
|
||||
return branch_display, suggested_flows, session, branch_fork_metadata, branch_actions_data, branch_questions_data
|
||||
|
||||
# Auto-title from first message if still default
|
||||
if session.step_count == 0 and message.strip():
|
||||
session.title = _auto_title(message)
|
||||
@@ -113,10 +323,27 @@ async def send_chat_message(
|
||||
images=images,
|
||||
)
|
||||
|
||||
# Append messages to conversation_messages
|
||||
# Check for fork marker in AI response
|
||||
display_content, fork_data = _parse_fork_marker(ai_content)
|
||||
|
||||
# Check for actions marker in AI response
|
||||
display_content, actions_data = _parse_actions_marker(display_content)
|
||||
|
||||
# Check for questions marker in AI response
|
||||
display_content, questions_data = _parse_questions_marker(display_content)
|
||||
|
||||
logger.info(
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, raw_length: %d, display_length: %d",
|
||||
bool(actions_data), bool(questions_data), bool(fork_data),
|
||||
len(ai_content), len(display_content),
|
||||
)
|
||||
|
||||
# Store DISPLAY content (markers stripped) in conversation_messages.
|
||||
# The format reminder in the user message + system prompt final reminder
|
||||
# are sufficient to keep the AI emitting markers on subsequent turns.
|
||||
msgs = list(session.conversation_messages or [])
|
||||
msgs.append({"role": "user", "content": message})
|
||||
msgs.append({"role": "assistant", "content": ai_content})
|
||||
msgs.append({"role": "assistant", "content": display_content})
|
||||
session.conversation_messages = msgs
|
||||
session.step_count += 2 # message count for display
|
||||
session.total_input_tokens += input_tokens
|
||||
@@ -126,6 +353,46 @@ async def send_chat_message(
|
||||
if session.status == "paused":
|
||||
session.status = "active"
|
||||
|
||||
# If fork was detected, create branches
|
||||
fork_metadata = None
|
||||
if fork_data:
|
||||
try:
|
||||
from app.services.branch_manager import BranchManager
|
||||
mgr = BranchManager(db)
|
||||
|
||||
# Create root branch if this is the first fork
|
||||
if not session.is_branching:
|
||||
await mgr.create_root_branch(session.id)
|
||||
|
||||
fork_point, new_branches = await mgr.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=session.active_branch_id,
|
||||
trigger_step_id=None,
|
||||
fork_reason=fork_data.get("fork_reason", ""),
|
||||
options=[
|
||||
{"label": o["label"], "description": o.get("description", "")}
|
||||
for o in fork_data["options"]
|
||||
],
|
||||
)
|
||||
|
||||
# Don't auto-switch — conversation continues on current branch.
|
||||
# Branches appear in sidebar. User switches when ready.
|
||||
fork_metadata = {
|
||||
"fork_point_id": str(fork_point.id),
|
||||
"fork_reason": fork_data.get("fork_reason", ""),
|
||||
"branches": [
|
||||
{"branch_id": str(b.id), "label": b.label}
|
||||
for b in new_branches
|
||||
],
|
||||
"active_branch_id": str(session.active_branch_id) if session.active_branch_id else None,
|
||||
}
|
||||
|
||||
await db.flush()
|
||||
logger.info("Created fork with %d branches for session %s", len(new_branches), session_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to create fork for session %s", session_id)
|
||||
# Fork failed but chat message still sent — don't break the response
|
||||
|
||||
suggested_flows = extract_suggested_flows(rag_results)
|
||||
|
||||
return ai_content, suggested_flows, session
|
||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||
|
||||
85
backend/tests/test_branch_aware_prompt_builder.py
Normal file
85
backend/tests/test_branch_aware_prompt_builder.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Unit tests for BranchAwarePromptBuilder — pure function, no DB needed."""
|
||||
import pytest
|
||||
from app.services.branch_aware_prompt_builder import BranchAwarePromptBuilder
|
||||
|
||||
|
||||
def test_build_basic():
|
||||
"""Basic build with no cross-branch context."""
|
||||
builder = BranchAwarePromptBuilder()
|
||||
result = builder.build(
|
||||
branch_messages=[
|
||||
{"role": "user", "content": "DNS not resolving"},
|
||||
{"role": "assistant", "content": "Let's check DNS config"},
|
||||
],
|
||||
sibling_summaries="",
|
||||
session_context="Problem: DNS resolution failure. Domain: networking.",
|
||||
attachments=[],
|
||||
new_message="I ran nslookup and got timeout",
|
||||
)
|
||||
assert "system_base" in result
|
||||
assert "rag_context" in result
|
||||
assert "history" in result
|
||||
assert "new_message" in result
|
||||
assert "images" in result
|
||||
assert result["new_message"] == "I ran nslookup and got timeout"
|
||||
assert len(result["history"]) == 2
|
||||
|
||||
|
||||
def test_build_with_cross_branch_context():
|
||||
"""Cross-branch summaries go into rag_context, not system_base."""
|
||||
builder = BranchAwarePromptBuilder()
|
||||
sibling_ctx = "\n## Cross-Branch Context\n- **Network** [dead_end]: Network was fine."
|
||||
result = builder.build(
|
||||
branch_messages=[],
|
||||
sibling_summaries=sibling_ctx,
|
||||
session_context="Problem: test",
|
||||
attachments=[],
|
||||
new_message="test message",
|
||||
)
|
||||
assert "Cross-Branch Context" in result["rag_context"]
|
||||
assert "Cross-Branch Context" not in result["system_base"]
|
||||
|
||||
|
||||
def test_build_with_images():
|
||||
"""Image attachments are passed through."""
|
||||
builder = BranchAwarePromptBuilder()
|
||||
result = builder.build(
|
||||
branch_messages=[],
|
||||
sibling_summaries="",
|
||||
session_context="Problem: test",
|
||||
attachments=[{"media_type": "image/png", "data": "base64data"}],
|
||||
new_message="check this screenshot",
|
||||
)
|
||||
assert len(result["images"]) == 1
|
||||
assert result["images"][0]["media_type"] == "image/png"
|
||||
|
||||
|
||||
def test_build_with_revival_context():
|
||||
"""Revival context is prepended to rag_context."""
|
||||
builder = BranchAwarePromptBuilder()
|
||||
result = builder.build(
|
||||
branch_messages=[],
|
||||
sibling_summaries="",
|
||||
session_context="Problem: test",
|
||||
attachments=[],
|
||||
new_message="test",
|
||||
revival_context="New evidence: the error appears when VPN is active",
|
||||
)
|
||||
assert "New evidence" in result["rag_context"]
|
||||
|
||||
|
||||
def test_history_filters_to_user_assistant():
|
||||
"""Only user and assistant messages appear in history."""
|
||||
builder = BranchAwarePromptBuilder()
|
||||
result = builder.build(
|
||||
branch_messages=[
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "second"},
|
||||
{"role": "system", "content": "should be excluded"},
|
||||
],
|
||||
sibling_summaries="",
|
||||
session_context="Problem: test",
|
||||
attachments=[],
|
||||
new_message="third",
|
||||
)
|
||||
assert len(result["history"]) == 2
|
||||
218
backend/tests/test_branch_manager.py
Normal file
218
backend/tests/test_branch_manager.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Integration tests for BranchManager service."""
|
||||
import uuid
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_branch import SessionBranch
|
||||
from app.models.fork_point import ForkPoint
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_root_branch(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Creating a root branch sets is_branching=True and copies conversation_messages."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[
|
||||
{"role": "user", "content": "test message"},
|
||||
{"role": "assistant", "content": "test response"},
|
||||
],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.branch_manager import BranchManager
|
||||
manager = BranchManager(test_db)
|
||||
root = await manager.create_root_branch(session.id)
|
||||
|
||||
assert root is not None
|
||||
assert root.parent_branch_id is None
|
||||
assert root.label == "Root"
|
||||
assert root.status == "active"
|
||||
assert root.branch_order == 1
|
||||
assert len(root.conversation_messages) == 2
|
||||
|
||||
await test_db.refresh(session)
|
||||
assert session.is_branching is True
|
||||
assert session.active_branch_id == root.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Creating a fork produces a ForkPoint + N branches."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.branch_manager import BranchManager
|
||||
manager = BranchManager(test_db)
|
||||
root = await manager.create_root_branch(session.id)
|
||||
|
||||
step = AISessionStep(
|
||||
session_id=session.id,
|
||||
step_order=0,
|
||||
step_type="question",
|
||||
content={"text": "What's the issue?"},
|
||||
confidence_at_step=0.5,
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.flush()
|
||||
|
||||
fork_point, branches = await manager.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=root.id,
|
||||
trigger_step_id=step.id,
|
||||
fork_reason="Two possible causes identified",
|
||||
options=[
|
||||
{"label": "Network connectivity", "description": "Check network stack"},
|
||||
{"label": "DNS resolution", "description": "Check DNS config"},
|
||||
],
|
||||
)
|
||||
|
||||
assert fork_point is not None
|
||||
assert len(branches) == 2
|
||||
assert branches[0].label == "Network connectivity"
|
||||
assert branches[0].status == "untried"
|
||||
assert branches[0].parent_branch_id == root.id
|
||||
assert branches[1].label == "DNS resolution"
|
||||
assert branches[1].branch_order == 2
|
||||
|
||||
await test_db.refresh(step)
|
||||
assert step.is_fork_point is True
|
||||
assert step.fork_point_id == fork_point.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Switching branches updates active_branch_id."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.branch_manager import BranchManager
|
||||
manager = BranchManager(test_db)
|
||||
root = await manager.create_root_branch(session.id)
|
||||
|
||||
step = AISessionStep(
|
||||
session_id=session.id, step_order=0, step_type="question",
|
||||
content={"text": "test"}, confidence_at_step=0.5,
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.flush()
|
||||
|
||||
_, branches = await manager.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=root.id,
|
||||
trigger_step_id=step.id,
|
||||
fork_reason="test fork",
|
||||
options=[
|
||||
{"label": "Option A", "description": "desc A"},
|
||||
{"label": "Option B", "description": "desc B"},
|
||||
],
|
||||
)
|
||||
|
||||
branch_b = branches[1]
|
||||
result = await manager.switch_branch(session.id, branch_b.id)
|
||||
|
||||
assert result.id == branch_b.id
|
||||
await test_db.refresh(session)
|
||||
assert session.active_branch_id == branch_b.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_branch_dead_end(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Marking a branch as dead_end updates status."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.branch_manager import BranchManager
|
||||
manager = BranchManager(test_db)
|
||||
root = await manager.create_root_branch(session.id)
|
||||
|
||||
updated = await manager.mark_branch_status(
|
||||
branch_id=root.id,
|
||||
status="dead_end",
|
||||
reason="Network was fine, not the cause",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert updated.status == "dead_end"
|
||||
assert updated.status_reason == "Network was fine, not the cause"
|
||||
assert updated.status_changed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""get_branch_tree returns the full tree structure."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[{"role": "user", "content": "help"}],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.branch_manager import BranchManager
|
||||
manager = BranchManager(test_db)
|
||||
root = await manager.create_root_branch(session.id)
|
||||
|
||||
step = AISessionStep(
|
||||
session_id=session.id, step_order=0, step_type="question",
|
||||
content={"text": "test"}, confidence_at_step=0.5,
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.flush()
|
||||
|
||||
await manager.create_fork(
|
||||
session_id=session.id,
|
||||
parent_branch_id=root.id,
|
||||
trigger_step_id=step.id,
|
||||
fork_reason="test",
|
||||
options=[
|
||||
{"label": "A", "description": "a"},
|
||||
{"label": "B", "description": "b"},
|
||||
],
|
||||
)
|
||||
|
||||
tree = await manager.get_branch_tree(session.id)
|
||||
assert len(tree) == 3 # Root + 2 fork branches
|
||||
115
backend/tests/test_handoff_manager.py
Normal file
115
backend/tests/test_handoff_manager.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Integration tests for HandoffManager service."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_park_handoff(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Parking a session creates a handoff with snapshot."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[{"role": "user", "content": "help me"}],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
manager = HandoffManager(test_db)
|
||||
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="park",
|
||||
engineer_notes="Waiting for client to provide logs",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert handoff is not None
|
||||
assert handoff.intent == "park"
|
||||
assert handoff.engineer_notes == "Waiting for client to provide logs"
|
||||
assert handoff.snapshot is not None
|
||||
|
||||
# Session should be paused
|
||||
await test_db.refresh(session)
|
||||
assert session.status == "paused"
|
||||
assert session.handoff_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Escalating creates handoff + dual-writes to escalation_package."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
manager = HandoffManager(test_db)
|
||||
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need senior help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert handoff.intent == "escalate"
|
||||
|
||||
# Dual-write check
|
||||
await test_db.refresh(session)
|
||||
assert session.status == "escalated"
|
||||
assert session.escalation_package is not None
|
||||
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_headers, test_db):
|
||||
"""Claiming a handoff sets claimed_by and reactivates session."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
manager = HandoffManager(test_db)
|
||||
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
claimed = await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert claimed.claimed_by == test_admin["user_data"]["id"]
|
||||
assert claimed.claimed_at is not None
|
||||
|
||||
await test_db.refresh(session)
|
||||
assert session.status == "active"
|
||||
77
backend/tests/test_resolution_outputs.py
Normal file
77
backend/tests/test_resolution_outputs.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Integration tests for ResolutionOutputGenerator."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.services.resolution_output_generator._call_ai")
|
||||
async def test_generate_all_creates_three_outputs(
|
||||
mock_call_ai, client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""generate_all creates PSA notes, KB article, and client summary."""
|
||||
mock_call_ai.return_value = ("Generated content here", 100, 50)
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="resolved",
|
||||
confidence_tier="guided",
|
||||
conversation_messages=[
|
||||
{"role": "user", "content": "DNS not working"},
|
||||
{"role": "assistant", "content": "Fixed by flushing DNS cache"},
|
||||
],
|
||||
resolution_summary="Flushed DNS cache",
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(test_db)
|
||||
outputs = await gen.generate_all(session.id)
|
||||
|
||||
assert len(outputs) == 3
|
||||
types = {o.output_type for o in outputs}
|
||||
assert types == {"psa_ticket_notes", "knowledge_base", "client_summary"}
|
||||
assert all(o.status == "draft" for o in outputs)
|
||||
assert mock_call_ai.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_output(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""Editing an output stores edited_content."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="resolved",
|
||||
confidence_tier="guided",
|
||||
conversation_messages=[],
|
||||
resolution_summary="Fixed it",
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
output = SessionResolutionOutput(
|
||||
session_id=session.id,
|
||||
output_type="psa_ticket_notes",
|
||||
generated_content="Original notes",
|
||||
status="draft",
|
||||
generated_by_model="claude-sonnet-4-6",
|
||||
)
|
||||
test_db.add(output)
|
||||
await test_db.flush()
|
||||
|
||||
from app.services.resolution_output_generator import ResolutionOutputGenerator
|
||||
gen = ResolutionOutputGenerator(test_db)
|
||||
edited = await gen.edit_output(output.id, "My edited notes")
|
||||
|
||||
assert edited.edited_content == "My edited notes"
|
||||
118
backend/tests/test_session_branches_api.py
Normal file
118
backend/tests/test_session_branches_api.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""API endpoint tests for session branches."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_empty(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""GET /ai-sessions/{id}/branches returns empty for non-branching session."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/ai-sessions/{session.id}/branches",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["branches"] == []
|
||||
assert data["active_branch_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""POST /ai-sessions/{id}/branches/fork creates branches."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[{"role": "user", "content": "help"}],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
step = AISessionStep(
|
||||
session_id=session.id, step_order=0, step_type="question",
|
||||
content={"text": "test"}, confidence_at_step=0.5,
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/branches/fork",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"fork_reason": "Two possible causes",
|
||||
"options": [
|
||||
{"label": "Network issue", "description": "Check connectivity"},
|
||||
{"label": "DNS problem", "description": "Check DNS"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert len(data["options"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""POST /ai-sessions/{id}/branches/{bid}/switch changes active branch."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[{"role": "user", "content": "help"}],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
step = AISessionStep(
|
||||
session_id=session.id, step_order=0, step_type="question",
|
||||
content={"text": "test"}, confidence_at_step=0.5,
|
||||
)
|
||||
test_db.add(step)
|
||||
await test_db.commit()
|
||||
|
||||
# Create fork first
|
||||
fork_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/branches/fork",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"fork_reason": "test",
|
||||
"options": [
|
||||
{"label": "A", "description": "a"},
|
||||
{"label": "B", "description": "b"},
|
||||
],
|
||||
},
|
||||
)
|
||||
fork_data = fork_resp.json()
|
||||
branch_b_id = fork_data["options"][1]["branch_id"]
|
||||
|
||||
# Switch to branch B
|
||||
resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/branches/{branch_b_id}/switch",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active_branch_id"] == branch_b_id
|
||||
60
backend/tests/test_session_handoffs_api.py
Normal file
60
backend/tests/test_session_handoffs_api.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""API endpoint tests for session handoffs."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_park_handoff_api(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""POST /ai-sessions/{id}/handoff with intent=park."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
||||
headers=auth_headers,
|
||||
json={"intent": "park", "engineer_notes": "Waiting for logs"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["intent"] == "park"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
"""GET /ai-sessions/queue returns unclaimed handoffs."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
# Create a handoff
|
||||
await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
||||
headers=auth_headers,
|
||||
json={"intent": "escalate", "engineer_notes": "Need help"},
|
||||
)
|
||||
|
||||
resp = await client.get("/api/v1/ai-sessions/queue", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
62
backend/tests/test_session_resolutions_api.py
Normal file
62
backend/tests/test_session_resolutions_api.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""API tests for resolution output endpoints."""
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_resolution_output import SessionResolutionOutput
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outputs_empty(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="guided",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.get(f"/api/v1/ai-sessions/{session.id}/outputs", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["outputs"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, test_db):
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="resolved",
|
||||
confidence_tier="guided",
|
||||
conversation_messages=[],
|
||||
resolution_summary="Fixed",
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
output = SessionResolutionOutput(
|
||||
session_id=session.id,
|
||||
output_type="psa_ticket_notes",
|
||||
generated_content="Original",
|
||||
status="draft",
|
||||
generated_by_model="claude-sonnet-4-6",
|
||||
)
|
||||
test_db.add(output)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session.id}/outputs/{output.id}",
|
||||
headers=auth_headers,
|
||||
json={"edited_content": "My edited version"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["edited_content"] == "My edited version"
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=https://dev.resolutionflow.com/api
|
||||
- VITE_API_URL=https://dev.resolutionflow.com/
|
||||
depends_on:
|
||||
- backend
|
||||
labels:
|
||||
|
||||
5051
docs/superpowers/plans/2026-03-24-conversational-branching.md
Normal file
5051
docs/superpowers/plans/2026-03-24-conversational-branching.md
Normal file
File diff suppressed because it is too large
Load Diff
594
docs/superpowers/plans/2026-03-26-tasklane-improvements.md
Normal file
594
docs/superpowers/plans/2026-03-26-tasklane-improvements.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# TaskLane Improvements 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:** Fix four TaskLane UX issues (partial submit, reset on new chat, double border, resizability) and add a response preview.
|
||||
|
||||
**Architecture:** All changes are in two frontend files. TaskLane.tsx gets the bulk of changes (submit logic, preview, resize handle). AssistantChatPage.tsx gets state-reset fixes and conditional border. No backend changes.
|
||||
|
||||
**Tech Stack:** React, TypeScript, Tailwind CSS, Lucide icons, localStorage
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TaskLane Reset on New Chat and Chat Switch
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/AssistantChatPage.tsx:169-189` (handleNewChat)
|
||||
- Modify: `frontend/src/pages/AssistantChatPage.tsx:153-167` (selectChat)
|
||||
|
||||
- [ ] **Step 1: Add TaskLane reset to `handleNewChat`**
|
||||
|
||||
In `frontend/src/pages/AssistantChatPage.tsx`, find the `handleNewChat` function. After `setMessages([])` (inside the try block), add the TaskLane cleanup:
|
||||
|
||||
```typescript
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
intake_content: { text: '' },
|
||||
})
|
||||
const chatItem: ChatListItem = {
|
||||
id: session.session_id,
|
||||
title: session.title,
|
||||
message_count: 0,
|
||||
pinned: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
setChats(prev => [chatItem, ...prev])
|
||||
setActiveChatId(session.session_id)
|
||||
setMessages([])
|
||||
// Clear TaskLane from previous session
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
} catch {
|
||||
toast.error('Failed to create chat')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add TaskLane reset to `selectChat`**
|
||||
|
||||
In the same file, find the `selectChat` callback. Add TaskLane cleanup at the start of the function (before the try block):
|
||||
|
||||
```typescript
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
setActiveChatId(chatId)
|
||||
// Clear TaskLane when switching chats
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(chatId)
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify in browser**
|
||||
|
||||
1. Start a new chat session, send a message that triggers the TaskLane
|
||||
2. Click "+ New Chat" — TaskLane should disappear
|
||||
3. Start another session with TaskLane visible, click an older chat in the sidebar — TaskLane should disappear
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/AssistantChatPage.tsx
|
||||
git commit -m "fix: clear TaskLane when switching chats or creating new chat
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Conditional Chat Input Border
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/AssistantChatPage.tsx:564`
|
||||
|
||||
- [ ] **Step 1: Make the border conditional**
|
||||
|
||||
Find the chat input wrapper div (around line 564):
|
||||
|
||||
```tsx
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0 border-t border-border">
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
<div className={cn("px-3 sm:px-6 py-3 shrink-0", !showTaskLane && "border-t border-border")}>
|
||||
```
|
||||
|
||||
The `cn` utility is already imported in this file.
|
||||
|
||||
- [ ] **Step 2: Verify in browser**
|
||||
|
||||
1. Open a chat without TaskLane — input area should have top border
|
||||
2. Send a message that triggers TaskLane — top border should disappear
|
||||
3. Close the TaskLane via X — top border should reappear
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/AssistantChatPage.tsx
|
||||
git commit -m "fix: remove chat input top border when TaskLane is open
|
||||
|
||||
Prevents double-border clash between chat input and TaskLane footer.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Partial Submit + Dynamic Label
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/assistant/TaskLane.tsx:75` (allHandled), `89-94` (handleSubmit), `367-382` (footer)
|
||||
|
||||
- [ ] **Step 1: Change submit enablement from "all handled" to "any handled"**
|
||||
|
||||
In `frontend/src/components/assistant/TaskLane.tsx`, find:
|
||||
|
||||
```typescript
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
```
|
||||
|
||||
Add a new derived value below it:
|
||||
|
||||
```typescript
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
const anyHandled = tasks.some(t => t.state === 'done' || t.state === 'skipped')
|
||||
const handledCount = tasks.filter(t => t.state === 'done' || t.state === 'skipped').length
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the footer submit button**
|
||||
|
||||
Find the footer section (starts around line 350). Replace the entire `<button>` for submit:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-1.5 rounded-lg px-4 py-2.5 text-[0.8125rem] font-semibold transition-colors',
|
||||
anyHandled && !submitting
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<><Loader2 size={14} className="animate-spin" /> Sending...</>
|
||||
) : (
|
||||
<><Send size={14} /> {allHandled ? 'Send All Responses' : `Send ${handledCount} of ${totalCount} Responses`}</>
|
||||
)}
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the header badge to use `allHandled` for the checkmark**
|
||||
|
||||
The header badge already uses `allHandled` for "Ready" — this stays correct since it still reflects whether everything is handled. No change needed.
|
||||
|
||||
- [ ] **Step 4: Verify in browser**
|
||||
|
||||
1. Trigger TaskLane with questions + actions
|
||||
2. Answer only 1 question — submit button should be enabled, label should say "Send 1 of N Responses"
|
||||
3. Answer all items — label should say "Send All Responses"
|
||||
4. Submit with partial answers — AI should receive only the answered items
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/assistant/TaskLane.tsx
|
||||
git commit -m "feat: enable partial TaskLane submission with dynamic label
|
||||
|
||||
Engineers can submit responses as soon as at least one item is
|
||||
answered or skipped. Pending items are omitted from the message.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Done Card Click-to-Edit
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/assistant/TaskLane.tsx:138-145` (question done card), `257-265` (action done card)
|
||||
|
||||
- [ ] **Step 1: Make question done cards clickable**
|
||||
|
||||
Find the question done card (around line 138):
|
||||
|
||||
```tsx
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2">
|
||||
<div className="text-[0.8125rem] text-muted-foreground">{q.text}</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 italic">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors"
|
||||
>
|
||||
<div className="text-[0.8125rem] text-muted-foreground">{q.text}</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 italic">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make action done cards clickable**
|
||||
|
||||
Find the action done card (around line 257):
|
||||
|
||||
```tsx
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-success">✓ Done</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-success">✓ Done</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify in browser**
|
||||
|
||||
1. Answer a question, confirm it (green card appears)
|
||||
2. Click the green card — it should reopen with the previous value pre-filled in the textarea
|
||||
3. Edit the value, re-confirm — card goes back to green with updated text
|
||||
4. Same test for an action item
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/assistant/TaskLane.tsx
|
||||
git commit -m "feat: click done TaskLane cards to re-edit responses
|
||||
|
||||
Completed question and action cards are now clickable. Clicking
|
||||
reopens them in active state with the previous value preserved.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Collapsible Preview Section
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/assistant/TaskLane.tsx` — add import for `Eye` icon, add `showPreview` state, add `buildPreviewText` function, add preview UI in footer
|
||||
|
||||
- [ ] **Step 1: Add state and icon import**
|
||||
|
||||
At the top of `TaskLane.tsx`, update the Lucide import to include `Eye`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, X, MessageCircleQuestion, Wrench, Eye,
|
||||
} from 'lucide-react'
|
||||
```
|
||||
|
||||
Inside the component, after the `showRunAll` state, add:
|
||||
|
||||
```typescript
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `buildPreviewText` function**
|
||||
|
||||
After the `handleCopy` function (around line 87), add:
|
||||
|
||||
```typescript
|
||||
const buildPreviewText = (): string => {
|
||||
const parts: string[] = []
|
||||
for (const t of tasks) {
|
||||
if (t.type === 'question') {
|
||||
const q = t as QuestionResponse
|
||||
const name = `Q: ${q.text}`
|
||||
if (q.state === 'done' && q.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${q.value.trim()}\n\`\`\``)
|
||||
} else if (q.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
} else {
|
||||
const a = t as ActionResponse
|
||||
const name = a.label || 'Check'
|
||||
if (a.state === 'done' && a.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${a.value.trim()}\n\`\`\``)
|
||||
} else if (a.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n') || '(No responses yet)'
|
||||
}
|
||||
```
|
||||
|
||||
This mirrors the formatting logic in `AssistantChatPage.tsx`'s `handleTaskSubmit`.
|
||||
|
||||
- [ ] **Step 3: Add preview UI in footer**
|
||||
|
||||
Find the footer `<div>` (starts with `{/* Footer */}`). Insert the preview section between the progress bar and the submit button:
|
||||
|
||||
```tsx
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-default shrink-0">
|
||||
{/* Progress bar */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{tasks.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex-1 h-[3px] rounded-full',
|
||||
t.state === 'done' ? 'bg-success' :
|
||||
t.state === 'skipped' ? 'bg-muted' :
|
||||
t.state === 'active' ? 'bg-accent' :
|
||||
'bg-elevated'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapsible preview */}
|
||||
{anyHandled && (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Preview ({handledCount}/{totalCount} done)
|
||||
{showPreview ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="rounded-lg border border-default bg-code p-2.5 max-h-[150px] overflow-y-auto">
|
||||
<pre className="text-[0.6875rem] font-mono text-heading whitespace-pre-wrap">{buildPreviewText()}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-1.5 rounded-lg px-4 py-2.5 text-[0.8125rem] font-semibold transition-colors',
|
||||
anyHandled && !submitting
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<><Loader2 size={14} className="animate-spin" /> Sending...</>
|
||||
) : (
|
||||
<><Send size={14} /> {allHandled ? 'Send All Responses' : `Send ${handledCount} of ${totalCount} Responses`}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify in browser**
|
||||
|
||||
1. Trigger TaskLane, answer 1 item — "Preview (1/N done)" toggle should appear above submit button
|
||||
2. Click the toggle — preview expands showing the formatted message
|
||||
3. Answer more items — preview updates in real-time
|
||||
4. Click toggle again — preview collapses
|
||||
5. With 0 items handled — preview toggle should not appear
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/assistant/TaskLane.tsx
|
||||
git commit -m "feat: add collapsible response preview to TaskLane footer
|
||||
|
||||
Shows a real-time preview of the formatted message that will be
|
||||
sent to the AI. Collapsed by default, appears when at least one
|
||||
item is answered or skipped.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Resizable TaskLane with Grip Handle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/assistant/TaskLane.tsx` — add `useRef`, `useCallback`, `useEffect` imports, add resize state/refs, add grip handle JSX, replace fixed width
|
||||
|
||||
- [ ] **Step 1: Add resize state and refs**
|
||||
|
||||
Update the React import at the top of `TaskLane.tsx`:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
```
|
||||
|
||||
Add the `GripVertical` icon to the Lucide import:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, X, MessageCircleQuestion, Wrench, Eye, GripVertical,
|
||||
} from 'lucide-react'
|
||||
```
|
||||
|
||||
Inside the component, after the existing state declarations (after `showPreview` state), add:
|
||||
|
||||
```typescript
|
||||
// ── Resize state ──
|
||||
const DEFAULT_WIDTH = 340
|
||||
const MIN_WIDTH = 280
|
||||
const MAX_WIDTH_RATIO = 0.5 // 50vw
|
||||
|
||||
const [panelWidth, setPanelWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem('rf-tasklane-width')
|
||||
return stored ? Math.max(MIN_WIDTH, parseInt(stored, 10) || DEFAULT_WIDTH) : DEFAULT_WIDTH
|
||||
})
|
||||
const isDragging = useRef(false)
|
||||
const startX = useRef(0)
|
||||
const startWidth = useRef(0)
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging.current) return
|
||||
const maxWidth = window.innerWidth * MAX_WIDTH_RATIO
|
||||
// Dragging left (negative deltaX) should increase width since panel is on the right
|
||||
const deltaX = startX.current - e.clientX
|
||||
const newWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, startWidth.current + deltaX))
|
||||
setPanelWidth(newWidth)
|
||||
}, [])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('rf-tasklane-width', String(Math.round(panelWidth)))
|
||||
}, [panelWidth])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
startX.current = e.clientX
|
||||
startWidth.current = panelWidth
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [panelWidth])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [handleMouseMove, handleMouseUp])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the outer div's fixed width with dynamic width**
|
||||
|
||||
Find the outer `<div>` of the component return (the line with `w-[340px]`):
|
||||
|
||||
```tsx
|
||||
<div className="w-[340px] bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200">
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* Resize grip handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Keep the rest of the component unchanged — the header, body, and footer are all children of this outer div.
|
||||
|
||||
- [ ] **Step 3: Verify in browser**
|
||||
|
||||
1. Trigger the TaskLane — should appear at stored width (or 340px default)
|
||||
2. Hover over the left edge — faint grip dots should appear, cursor changes to `col-resize`
|
||||
3. Drag left — panel widens. Drag right — panel narrows
|
||||
4. Release — width persists
|
||||
5. Reload the page, trigger TaskLane again — width should match the last drag position
|
||||
6. Try to drag past 50vw — should cap. Try to drag below 280px — should cap.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/assistant/TaskLane.tsx
|
||||
git commit -m "feat: resizable TaskLane with grip handle and localStorage persistence
|
||||
|
||||
Left edge has a 6px drag zone with dot grip indicator on hover.
|
||||
Width clamped between 280px and 50vw. Persists to localStorage.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final Verification
|
||||
|
||||
- [ ] **Step 1: Full integration test**
|
||||
|
||||
Run through the complete flow:
|
||||
1. Start from the dashboard — type a troubleshooting issue in the main input
|
||||
2. TaskLane should appear on the first response (prefill path fix from earlier session)
|
||||
3. Answer 1 of 3 questions — submit button should say "Send 1 of N Responses"
|
||||
4. Click "Preview" — should show formatted response
|
||||
5. Click a done card — should reopen for editing
|
||||
6. Resize the TaskLane by dragging the left edge
|
||||
7. Submit partial responses — AI should respond acknowledging the partial info
|
||||
8. Click "+ New Chat" — TaskLane should disappear
|
||||
9. Switch to an older chat in sidebar — TaskLane should stay hidden
|
||||
10. Verify no double border on the chat input with and without TaskLane
|
||||
|
||||
- [ ] **Step 2: Build check**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: Clean build with no TypeScript errors.
|
||||
|
||||
- [ ] **Step 3: Final commit if any cleanup needed**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
# Only commit if there are changes
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# TaskLane Improvements Design
|
||||
|
||||
> **Date:** 2026-03-26
|
||||
> **Status:** Approved
|
||||
> **Scope:** `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The TaskLane is a right-side panel in the AI Assistant chat that renders structured questions and diagnostic actions from the AI. It launched with a working parse → render → submit pipeline, but has four UX issues and a missing submission flow design.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Progressive Preview + Batch Submit
|
||||
|
||||
**Problem:** The "Send All Responses" button is disabled until every item is answered or skipped. Engineers often want to submit partial results — answer 2 of 3 questions, paste output from 1 of 4 commands, and let the AI work with what they have.
|
||||
|
||||
**Design:**
|
||||
|
||||
- **Submit enabled when ≥1 item is done or skipped.** Remaining pending items are simply omitted from the message (not auto-skipped — they're just not addressed).
|
||||
- **Dynamic submit label:** `Send 2 of 6 Responses` when partial, `Send All Responses` when all handled.
|
||||
- **Collapsible preview section** above the submit button:
|
||||
- Toggle: `▶ Preview (2/6 done)` — collapsed by default
|
||||
- Expands to show the formatted markdown message that will be sent to the AI
|
||||
- Updates in real-time as items are answered/skipped
|
||||
- Uses the same formatting logic as `handleTaskSubmit` (question answers in blockquotes, command output in code fences, skipped items noted)
|
||||
- Styled as a `bg-code` block with `font-mono text-xs`, max-height ~150px with overflow scroll
|
||||
- **Done items are re-editable.** Clicking anywhere on a completed (green) card reopens it in `active` state for editing. Cards get `cursor-pointer` and a subtle hover state (`hover:border-success/40`) to signal editability. This uses the existing `updateTask(idx, { state: 'active' })` mechanism — no new logic needed, just making the done card itself a click target.
|
||||
- **No change to the TaskLane header or pending/active card behavior.** Changes affect the footer area and done card interactivity.
|
||||
|
||||
**Submission logic changes in `TaskLane.tsx`:**
|
||||
- `allHandled` check on submit button changes from "all done/skipped" to "at least 1 done/skipped"
|
||||
- `handleSubmit` sends only items that have state `done` or `skipped`
|
||||
- Items still in `pending` or `active` state are excluded from the submission payload
|
||||
|
||||
**Submission logic in `AssistantChatPage.tsx` (`handleTaskSubmit`):**
|
||||
- No changes needed — it already formats based on `r.state === 'done'` and `r.state === 'skipped'`, ignoring pending items.
|
||||
|
||||
### 2. TaskLane Reset on New Chat
|
||||
|
||||
**Problem:** Starting a new chat via `handleNewChat` doesn't clear the TaskLane. The previous session's questions/actions persist visually.
|
||||
|
||||
**Fix:** Add three lines to `handleNewChat` in `AssistantChatPage.tsx`:
|
||||
```typescript
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
```
|
||||
|
||||
Same pattern already exists in `handleTaskSubmit`. Also add to `selectChat` for switching between chats.
|
||||
|
||||
### 3. Conditional Chat Input Border
|
||||
|
||||
**Problem:** The chat input area has `border-t border-border` which creates a double-border effect alongside the TaskLane footer's `border-t border-default`.
|
||||
|
||||
**Fix:** Conditionally remove the chat input's top border when the TaskLane is open:
|
||||
```tsx
|
||||
<div className={cn("px-3 sm:px-6 py-3 shrink-0", !showTaskLane && "border-t border-border")}>
|
||||
```
|
||||
|
||||
The TaskLane's own left border and footer border provide sufficient visual separation when the panel is open. When closed, the chat input border returns.
|
||||
|
||||
### 4. Resizable TaskLane with Grip Handle
|
||||
|
||||
**Design:**
|
||||
|
||||
- **Drag zone:** 6px wide hit target on the left edge of the TaskLane, absolutely positioned.
|
||||
- **Grip indicator:** Centered vertically on the drag zone — a 2×3 grid of small dots (6 dots total, `w-1 h-1 rounded-full bg-current`). Subtle `text-muted/40` by default, `text-muted-foreground` on hover.
|
||||
- **Resize behavior:**
|
||||
- `onMouseDown` on the grip starts tracking
|
||||
- `mousemove` on `document` updates the width
|
||||
- `mouseUp` on `document` stops tracking
|
||||
- Width clamped between **280px** min and **50vw** max
|
||||
- `cursor: col-resize` applied to the grip and to `document.body` during drag (prevents cursor flicker)
|
||||
- `user-select: none` on `document.body` during drag (prevents text selection)
|
||||
- **Persistence:** Width saved to `localStorage` key `rf-tasklane-width`. Read on mount, written on drag end. Default: `340px`.
|
||||
- **Implementation:** All in `TaskLane.tsx` — no external libraries. Uses `useRef` for drag state (avoids re-renders during drag), `useState` for the width value.
|
||||
|
||||
**CSS change:** Replace `w-[340px]` with `style={{ width: panelWidth }}` on the outer div. Keep `shrink-0` so the chat area flexes.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/components/assistant/TaskLane.tsx` | Preview section, submit logic, resize handle, width persistence |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | TaskLane reset in `handleNewChat`/`selectChat`, conditional border |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TaskLane rendering when loading historical sessions (markers are stripped from stored messages — no TaskLane on reload)
|
||||
- Mobile-specific TaskLane layout (current: hidden on mobile, which is acceptable for now)
|
||||
- Keyboard accessibility for resize handle (future enhancement)
|
||||
@@ -29,7 +29,7 @@ test.describe('command palette smoke tests', () => {
|
||||
|
||||
test('searches and shows AI Assistant option', async ({ page }) => {
|
||||
const api = await createAuthenticatedApiContext()
|
||||
const tree = await createTroubleshootingTree(api, {
|
||||
await createTroubleshootingTree(api, {
|
||||
name: uniqueName('PW Palette Search Flow'),
|
||||
})
|
||||
|
||||
|
||||
67
frontend/src/api/branches.ts
Normal file
67
frontend/src/api/branches.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
BranchTreeResponse,
|
||||
ForkCreateRequest,
|
||||
ForkPointResponse,
|
||||
BranchSwitchResponse,
|
||||
ReviveRequest,
|
||||
BranchMessageRequest,
|
||||
BranchMessageResponse,
|
||||
} from '@/types/branching'
|
||||
|
||||
export const branchesApi = {
|
||||
async getBranches(sessionId: string): Promise<BranchTreeResponse> {
|
||||
const response = await apiClient.get<BranchTreeResponse>(
|
||||
`/ai-sessions/${sessionId}/branches`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createFork(sessionId: string, data: ForkCreateRequest): Promise<ForkPointResponse> {
|
||||
const response = await apiClient.post<ForkPointResponse>(
|
||||
`/ai-sessions/${sessionId}/branches/fork`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateBranchStatus(
|
||||
sessionId: string,
|
||||
branchId: string,
|
||||
status: string,
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
await apiClient.patch(
|
||||
`/ai-sessions/${sessionId}/branches/${branchId}`,
|
||||
{ status, status_reason: reason }
|
||||
)
|
||||
},
|
||||
|
||||
async switchBranch(sessionId: string, branchId: string): Promise<BranchSwitchResponse> {
|
||||
const response = await apiClient.post<BranchSwitchResponse>(
|
||||
`/ai-sessions/${sessionId}/branches/${branchId}/switch`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async reviveBranch(sessionId: string, branchId: string, data: ReviveRequest): Promise<void> {
|
||||
await apiClient.post(
|
||||
`/ai-sessions/${sessionId}/branches/${branchId}/revive`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
async sendBranchMessage(
|
||||
sessionId: string,
|
||||
branchId: string,
|
||||
data: BranchMessageRequest
|
||||
): Promise<BranchMessageResponse> {
|
||||
const response = await apiClient.post<BranchMessageResponse>(
|
||||
`/ai-sessions/${sessionId}/branches/${branchId}/message`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default branchesApi
|
||||
39
frontend/src/api/handoffs.ts
Normal file
39
frontend/src/api/handoffs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
QueueItemResponse,
|
||||
} from '@/types/branching'
|
||||
|
||||
export const handoffsApi = {
|
||||
async createHandoff(sessionId: string, data: HandoffCreateRequest): Promise<HandoffResponse> {
|
||||
const response = await apiClient.post<HandoffResponse>(
|
||||
`/ai-sessions/${sessionId}/handoff`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listHandoffs(sessionId: string): Promise<HandoffResponse[]> {
|
||||
const response = await apiClient.get<HandoffResponse[]>(
|
||||
`/ai-sessions/${sessionId}/handoffs`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async claimHandoff(sessionId: string, handoffId: string): Promise<HandoffResponse> {
|
||||
const response = await apiClient.post<HandoffResponse>(
|
||||
`/ai-sessions/${sessionId}/handoffs/${handoffId}/claim`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getQueue(params?: { intent?: 'park' | 'escalate'; limit?: number }): Promise<QueueItemResponse[]> {
|
||||
const response = await apiClient.get<QueueItemResponse[]>('/ai-sessions/queue', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default handoffsApi
|
||||
@@ -32,3 +32,6 @@ export { publicTemplatesApi } from './publicTemplates'
|
||||
export { uploadsApi, default as uploadsApiDefault } from './uploads'
|
||||
export { scriptBuilderApi } from './scriptBuilder'
|
||||
export { betaFeedbackApi } from './betaFeedback'
|
||||
export { branchesApi } from './branches'
|
||||
export { handoffsApi } from './handoffs'
|
||||
export { resolutionsApi } from './resolutions'
|
||||
|
||||
42
frontend/src/api/resolutions.ts
Normal file
42
frontend/src/api/resolutions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
AllResolutionOutputsResponse,
|
||||
ResolutionOutputResponse,
|
||||
ResolutionOutputEditRequest,
|
||||
ResolutionOutputPushRequest,
|
||||
} from '@/types/branching'
|
||||
|
||||
export const resolutionsApi = {
|
||||
async getOutputs(sessionId: string): Promise<AllResolutionOutputsResponse> {
|
||||
const response = await apiClient.get<AllResolutionOutputsResponse>(
|
||||
`/ai-sessions/${sessionId}/outputs`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async editOutput(
|
||||
sessionId: string,
|
||||
outputId: string,
|
||||
data: ResolutionOutputEditRequest
|
||||
): Promise<ResolutionOutputResponse> {
|
||||
const response = await apiClient.patch<ResolutionOutputResponse>(
|
||||
`/ai-sessions/${sessionId}/outputs/${outputId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async pushOutput(
|
||||
sessionId: string,
|
||||
outputId: string,
|
||||
data: ResolutionOutputPushRequest
|
||||
): Promise<ResolutionOutputResponse> {
|
||||
const response = await apiClient.post<ResolutionOutputResponse>(
|
||||
`/ai-sessions/${sessionId}/outputs/${outputId}/push`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default resolutionsApi
|
||||
300
frontend/src/components/assistant/ActionCardGroup.tsx
Normal file
300
frontend/src/components/assistant/ActionCardGroup.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Send, Clipboard, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ActionItem } from '@/types/ai-session'
|
||||
|
||||
type CardState = 'pending' | 'pasting' | 'typing' | 'skipped' | 'done'
|
||||
|
||||
interface CardResponse {
|
||||
label: string
|
||||
state: CardState
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ActionCardGroupProps {
|
||||
actions: ActionItem[]
|
||||
onSubmit: (responses: CardResponse[]) => void
|
||||
disabled?: boolean
|
||||
stale?: boolean
|
||||
}
|
||||
|
||||
export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCardGroupProps) {
|
||||
const [responses, setResponses] = useState<CardResponse[]>(
|
||||
actions.map(a => ({ label: a.label, state: 'pending', value: '' }))
|
||||
)
|
||||
const [showRunAll, setShowRunAll] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const anyPending = responses.some(r => r.state === 'pending')
|
||||
const isCollapsed = stale && anyPending && !expanded
|
||||
|
||||
const updateCard = (idx: number, updates: Partial<CardResponse>) => {
|
||||
setResponses(prev => prev.map((r, i) => i === idx ? { ...r, ...updates } : r))
|
||||
}
|
||||
|
||||
const allHandled = responses.every(r => r.state !== 'pending' && r.state !== 'pasting' && r.state !== 'typing')
|
||||
const anyInteracted = responses.some(r => r.state !== 'pending')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true)
|
||||
setSubmitError(false)
|
||||
try {
|
||||
onSubmit(responses)
|
||||
setSubmitted(true)
|
||||
} catch {
|
||||
setSubmitError(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
// Build combined script for "Run All"
|
||||
const commandActions = actions.filter(a => a.command)
|
||||
const combinedScript = commandActions.map((a, i) => (
|
||||
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
||||
)).join('\n\n')
|
||||
|
||||
const doneCount = responses.filter(r => r.state === 'done').length
|
||||
const skippedCount = responses.filter(r => r.state === 'skipped').length
|
||||
|
||||
// ── Collapsed state (stale cards from earlier in conversation) ──
|
||||
if (isCollapsed) {
|
||||
const pendingCount = responses.filter(r => r.state === 'pending').length
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<Terminal size={12} />
|
||||
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
||||
</div>
|
||||
<span className="text-[0.6875rem] text-accent-text opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Expand
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Submitted state ──
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="rounded-lg border border-success/20 bg-success-dim/20 p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-[0.8125rem] font-medium text-success">
|
||||
<Check size={14} />
|
||||
<span>{doneCount} checked, {skippedCount} skipped</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{responses.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
{r.state === 'done' ? (
|
||||
<Check size={10} className="text-success shrink-0" />
|
||||
) : (
|
||||
<SkipForward size={10} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={r.state === 'skipped' ? 'line-through opacity-60' : ''}>
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Run All button — only if multiple commands exist */}
|
||||
{commandActions.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Run All ({commandActions.length} commands)</span>
|
||||
{showRunAll ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Combined diagnostic script
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
<Copy size={11} />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.8125rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">
|
||||
{combinedScript}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual action cards */}
|
||||
{actions.map((action, idx) => {
|
||||
const response = responses[idx]
|
||||
const isExpanded = response.state === 'pasting' || response.state === 'typing'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-all',
|
||||
response.state === 'done' ? 'border-success/30 bg-success-dim/30' :
|
||||
response.state === 'skipped' ? 'border-default/50 bg-elevated/20 opacity-60' :
|
||||
'border-default bg-card hover:border-hover'
|
||||
)}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
||||
{action.description && (
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge for handled cards */}
|
||||
{response.state === 'done' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
)}
|
||||
{response.state === 'skipped' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command with copy button */}
|
||||
{action.command && response.state !== 'skipped' && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate">
|
||||
{action.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(action.command!)}
|
||||
className="shrink-0 text-muted-foreground hover:text-heading transition-colors"
|
||||
title="Copy command"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — only for pending cards */}
|
||||
{response.state === 'pending' && !disabled && (
|
||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pasting' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Paste Result
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'typing' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Type Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'skipped' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<SkipForward size={11} />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded input area */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={response.value}
|
||||
onChange={e => updateCard(idx, { value: e.target.value })}
|
||||
placeholder={response.state === 'pasting' ? 'Paste command output here...' : 'Type your answer...'}
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'done' })}
|
||||
disabled={!response.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} />
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Submit / Error / Loading */}
|
||||
{anyInteracted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allHandled || disabled || submitting}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-[0.8125rem] font-medium transition-colors',
|
||||
allHandled && !submitting
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={13} />
|
||||
Send Responses
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{submitError && (
|
||||
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger">
|
||||
<AlertCircle size={12} />
|
||||
<span>Failed to send</span>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, Pin, Trash2, MessageSquare } from 'lucide-react'
|
||||
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ChatListItem } from '@/types/assistant-chat'
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ChatSidebarProps {
|
||||
onTogglePin: (id: string, pinned: boolean) => void
|
||||
mobileOpen?: boolean
|
||||
onMobileClose?: () => void
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -22,6 +24,8 @@ export function ChatSidebar({
|
||||
onTogglePin,
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
}: ChatSidebarProps) {
|
||||
const pinnedChats = chats.filter(c => c.pinned)
|
||||
const unpinnedChats = chats.filter(c => !c.pinned)
|
||||
@@ -36,6 +40,11 @@ export function ChatSidebar({
|
||||
onMobileClose?.()
|
||||
}
|
||||
|
||||
// When collapsed on desktop, render nothing — parent renders the top bar
|
||||
if (collapsed && !mobileOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
@@ -52,14 +61,23 @@ export function ChatSidebar({
|
||||
style={{ background: 'var(--color-bg-sidebar)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<div className="px-3 py-3 border-b shrink-0 flex items-center gap-2" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 bg-primary text-white font-semibold text-sm rounded-lg px-4 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-primary text-white font-semibold text-sm rounded-lg px-4 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Chat
|
||||
</button>
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="hidden sm:flex p-1.5 rounded-lg text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
|
||||
title="Collapse to top bar"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
@@ -108,6 +126,51 @@ export function ChatSidebar({
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapsed top bar — rendered by the parent page above the chat area */
|
||||
export function ChatSidebarCollapsedBar({
|
||||
chats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onExpand,
|
||||
}: {
|
||||
chats: ChatListItem[]
|
||||
activeChatId: string | null
|
||||
onNewChat: () => void
|
||||
onExpand: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 border-b shrink-0"
|
||||
style={{ background: 'var(--color-bg-sidebar)', borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="flex items-center gap-1.5 bg-primary text-white font-semibold text-xs rounded-md px-3 py-1.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
onClick={onExpand}
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
|
||||
title="Show chat history"
|
||||
>
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
{chats.length > 0 && (
|
||||
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{activeChatId && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{chats.find(c => c.id === activeChatId)?.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatItem({
|
||||
chat,
|
||||
isActive,
|
||||
|
||||
@@ -152,7 +152,7 @@ export function ConcludeSessionModal({
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent-dim flex items-center justify-center">
|
||||
@@ -178,7 +178,7 @@ export function ConcludeSessionModal({
|
||||
{/* Step indicator */}
|
||||
<div
|
||||
className="px-6 py-3 border-b shrink-0 flex items-center gap-2"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
{(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
@@ -283,7 +283,7 @@ export function ConcludeSessionModal({
|
||||
}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +309,7 @@ export function ConcludeSessionModal({
|
||||
{/* Generated summary */}
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-card"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
@@ -328,7 +328,7 @@ export function ConcludeSessionModal({
|
||||
{/* Footer actions */}
|
||||
<div
|
||||
className="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-3"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
{step === 'select-outcome' && (
|
||||
<>
|
||||
|
||||
496
frontend/src/components/assistant/TaskLane.tsx
Normal file
496
frontend/src/components/assistant/TaskLane.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, X, MessageCircleQuestion, Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type TaskState = 'pending' | 'active' | 'done' | 'skipped'
|
||||
|
||||
interface QuestionResponse {
|
||||
type: 'question'
|
||||
text: string
|
||||
context?: string
|
||||
state: TaskState
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ActionResponse {
|
||||
type: 'action'
|
||||
label: string
|
||||
command?: string | null
|
||||
description: string
|
||||
state: TaskState
|
||||
value: string
|
||||
}
|
||||
|
||||
type TaskResponse = QuestionResponse | ActionResponse
|
||||
|
||||
interface TaskLaneProps {
|
||||
questions: QuestionItem[]
|
||||
actions: ActionItem[]
|
||||
onSubmit: (responses: TaskResponse[]) => void
|
||||
onClose: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => [
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||
})),
|
||||
...actions.map((a): ActionResponse => ({
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showRunAll, setShowRunAll] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
// ── Resize state ──
|
||||
const DEFAULT_WIDTH = 340
|
||||
const MIN_WIDTH = 280
|
||||
const MAX_WIDTH_RATIO = 0.5 // 50vw
|
||||
|
||||
const [panelWidth, setPanelWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem('rf-tasklane-width')
|
||||
return stored ? Math.max(MIN_WIDTH, parseInt(stored, 10) || DEFAULT_WIDTH) : DEFAULT_WIDTH
|
||||
})
|
||||
const isDragging = useRef(false)
|
||||
const startX = useRef(0)
|
||||
const startWidth = useRef(0)
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging.current) return
|
||||
const maxWidth = window.innerWidth * MAX_WIDTH_RATIO
|
||||
const deltaX = startX.current - e.clientX
|
||||
const newWidth = Math.min(maxWidth, Math.max(MIN_WIDTH, startWidth.current + deltaX))
|
||||
setPanelWidth(newWidth)
|
||||
}, [])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('rf-tasklane-width', String(Math.round(panelWidth)))
|
||||
}, [panelWidth])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
startX.current = e.clientX
|
||||
startWidth.current = panelWidth
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [panelWidth])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [handleMouseMove, handleMouseUp])
|
||||
|
||||
// Reset when new tasks come in from AI response
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||
useEffect(() => {
|
||||
setTasks([
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||
})),
|
||||
...actions.map((a): ActionResponse => ({
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
])
|
||||
setSubmitted(false)
|
||||
}, [questions, actions])
|
||||
|
||||
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
|
||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||
}
|
||||
|
||||
const questionTasks = tasks.filter(t => t.type === 'question')
|
||||
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
const anyHandled = tasks.some(t => t.state === 'done' || t.state === 'skipped')
|
||||
const handledCount = tasks.filter(t => t.state === 'done' || t.state === 'skipped').length
|
||||
const doneCount = tasks.filter(t => t.state === 'done').length
|
||||
const totalCount = tasks.length
|
||||
|
||||
const commandActions = actionTasks.filter(a => a.command)
|
||||
const combinedScript = commandActions.map((a, i) => (
|
||||
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
||||
)).join('\n\n')
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
const buildPreviewText = (): string => {
|
||||
const parts: string[] = []
|
||||
for (const t of tasks) {
|
||||
if (t.type === 'question') {
|
||||
const q = t as QuestionResponse
|
||||
const name = `Q: ${q.text}`
|
||||
if (q.state === 'done' && q.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${q.value.trim()}\n\`\`\``)
|
||||
} else if (q.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
} else {
|
||||
const a = t as ActionResponse
|
||||
const name = a.label || 'Check'
|
||||
if (a.state === 'done' && a.value.trim()) {
|
||||
parts.push(`**${name}:**\n\`\`\`\n${a.value.trim()}\n\`\`\``)
|
||||
} else if (a.state === 'skipped') {
|
||||
parts.push(`**${name}:** _(skipped)_`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n') || '(No responses yet)'
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitting(true)
|
||||
onSubmit(tasks)
|
||||
setSubmitted(true)
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
if (submitted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200"
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* Resize grip handle */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||
Tasks
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
||||
allHandled
|
||||
? 'bg-success-dim text-success'
|
||||
: 'bg-accent-dim text-accent-text'
|
||||
)}>
|
||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
|
||||
{/* ── Questions Section ── */}
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Questions
|
||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||
<Check size={10} className="text-success" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks.map((task, idx) => {
|
||||
if (task.type !== 'question') return null
|
||||
const q = task as QuestionResponse
|
||||
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="text-[0.8125rem] text-muted-foreground">{q.text}</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 italic">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (q.state === 'skipped') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2">
|
||||
<div className="text-[0.8125rem] text-heading leading-relaxed">{q.text}</div>
|
||||
{q.context && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-1">{q.context}</div>
|
||||
)}
|
||||
{q.state === 'active' ? (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={q.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<MessageCircleQuestion size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
<SkipForward size={11} /> Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Checks Section ── */}
|
||||
{actionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
<Check size={10} className="text-success" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run All */}
|
||||
{commandActions.length > 1 && (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Run All ({commandActions.length} commands)
|
||||
{showRunAll ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<button
|
||||
onClick={() => handleCopy(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.map((task, idx) => {
|
||||
if (task.type !== 'action') return null
|
||||
const a = task as ActionResponse
|
||||
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-success">✓ Done</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (a.state === 'skipped') {
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
{a.description && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
||||
)}
|
||||
|
||||
{a.command && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
<code className="flex-1 text-[0.6875rem] font-mono text-heading truncate">{a.command}</code>
|
||||
<button onClick={() => handleCopy(a.command!)} className="shrink-0 text-muted-foreground hover:text-heading" title="Copy">
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{a.state === 'active' ? (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={a.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
placeholder="Paste command output here..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<Clipboard size={11} /> Paste Result
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-1.5 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors"
|
||||
>
|
||||
Type Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
<SkipForward size={11} /> Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-default shrink-0">
|
||||
{/* Progress bar */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{tasks.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex-1 h-[3px] rounded-full',
|
||||
t.state === 'done' ? 'bg-success' :
|
||||
t.state === 'skipped' ? 'bg-muted' :
|
||||
t.state === 'active' ? 'bg-accent' :
|
||||
'bg-elevated'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Collapsible preview */}
|
||||
{anyHandled && (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Preview ({handledCount}/{totalCount} done)
|
||||
{showPreview ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="rounded-lg border border-default bg-code p-2.5 max-h-[150px] overflow-y-auto">
|
||||
<pre className="text-[0.6875rem] font-mono text-heading whitespace-pre-wrap">{buildPreviewText()}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-1.5 rounded-lg px-4 py-2.5 text-[0.8125rem] font-semibold transition-colors',
|
||||
anyHandled && !submitting
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<><Loader2 size={14} className="animate-spin" /> Sending...</>
|
||||
) : (
|
||||
<><Send size={14} /> {allHandled ? 'Send All Responses' : `Send ${handledCount} of ${totalCount} Responses`}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ interface BrandLogoProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand logo mark: gradient cyan square with rounded corners
|
||||
* Brand logo mark: gradient orange square with rounded corners
|
||||
* containing a white lightning bolt.
|
||||
*/
|
||||
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||
|
||||
@@ -234,7 +234,7 @@ export function RichTextInput({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full bg-card border border-border rounded-xl p-3 text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none transition-colors',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none transition-colors',
|
||||
isDragOver && 'border-primary/50 bg-primary/5',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
|
||||
@@ -96,13 +96,13 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
background: 'rgba(16, 17, 20, 0.95)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
borderColor: 'var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-primary" />
|
||||
@@ -155,7 +155,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@@ -165,7 +165,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
placeholder="Ask about this step..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
disabled={loading || initializing}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,7 @@ function timeAgo(dateStr: string): string {
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
export function ActiveFlowPilotSessions() {
|
||||
export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
|
||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
@@ -27,50 +27,33 @@ export function ActiveFlowPilotSessions() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card-flat">
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (loading || sessions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="card-flat">
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
||||
{sessions.length > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent-dim px-1.5 text-[0.625rem] font-bold text-primary">
|
||||
{sessions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/sessions?filter=active"
|
||||
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
View all <ArrowRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No active sessions</p>
|
||||
<p className="mt-1 text-[0.6875rem] text-text-muted">Start typing above to begin troubleshooting</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Active Sessions</h3>
|
||||
{sessions.length > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent-dim px-1.5 text-[0.625rem] font-bold text-primary">
|
||||
{sessions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/sessions?filter=active"
|
||||
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all <ArrowRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 p-4">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
@@ -95,7 +78,7 @@ export function ActiveFlowPilotSessions() {
|
||||
{session.confidence_tier || 'starting'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
<p className="text-sm font-medium text-foreground line-clamp-2">
|
||||
{session.session_type === 'chat'
|
||||
? (session.title || session.problem_summary || 'Chat in progress')
|
||||
: (session.problem_summary || 'Session in progress')}
|
||||
@@ -111,7 +94,6 @@ export function ActiveFlowPilotSessions() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
54
frontend/src/components/dashboard/GreetingStatStrip.tsx
Normal file
54
frontend/src/components/dashboard/GreetingStatStrip.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle, Clock, Zap } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { sidebarApi } from '@/api'
|
||||
|
||||
interface StatItem {
|
||||
icon: LucideIcon
|
||||
value: string | number | null
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export function GreetingStatStrip() {
|
||||
const [resolved, setResolved] = useState<number | null>(null)
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
const [avgMttr, setAvgMttr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
sidebarApi.getStats()
|
||||
.then((stats) => {
|
||||
setResolved(stats.resolved_today)
|
||||
setActive(stats.active_count)
|
||||
const avg = stats.resolved_today > 0
|
||||
? Math.round(stats.total_session_minutes_today / stats.resolved_today)
|
||||
: null
|
||||
setAvgMttr(avg != null ? `${avg}m` : null)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{ icon: CheckCircle, value: resolved, label: 'resolved today', color: '#34d399' },
|
||||
{ icon: Zap, value: active, label: 'active now', color: '#f97316' },
|
||||
{ icon: Clock, value: avgMttr, label: 'avg MTTR', color: '#848b9b' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="hidden sm:flex items-center gap-5 pb-1">
|
||||
{stats.map(({ icon: Icon, value, label, color }) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<Icon size={13} style={{ color }} className="shrink-0" />
|
||||
<div className="text-right">
|
||||
<p className="font-heading text-lg font-extrabold leading-none text-[#f0f2f5]">
|
||||
{value ?? '\u2014'}
|
||||
</p>
|
||||
<p className="font-sans text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground mt-0.5">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function KnowledgeBaseCards() {
|
||||
<div className="card-flat">
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--glass-border)' }}
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Knowledge Base</h3>
|
||||
<button
|
||||
@@ -33,7 +33,7 @@ export function KnowledgeBaseCards() {
|
||||
Browse <ArrowRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface OpenSessionsProps {
|
||||
export function OpenSessions({ sessions }: OpenSessionsProps) {
|
||||
return (
|
||||
<div className="card-flat flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">My Open Sessions</h3>
|
||||
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
View All
|
||||
@@ -35,7 +35,7 @@ export function OpenSessions({ sessions }: OpenSessionsProps) {
|
||||
key={session.id}
|
||||
className="flex items-center gap-3 px-5 py-3"
|
||||
style={{
|
||||
borderBottom: i < sessions.length - 1 ? '1px solid var(--glass-border)' : undefined,
|
||||
borderBottom: i < sessions.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PendingEscalations() {
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--glass-border)' }}
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-amber-400" />
|
||||
@@ -58,7 +58,7 @@ export function PendingEscalations() {
|
||||
className="flex items-center gap-3 px-5 py-3"
|
||||
style={{
|
||||
borderBottom: i < Math.min(escalations.length, 3) - 1
|
||||
? '1px solid var(--glass-border)'
|
||||
? '1px solid var(--color-border-default)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PerformanceCards() {
|
||||
label: 'Active Now',
|
||||
value: active,
|
||||
icon: TrendingUp,
|
||||
iconColor: '#38bdf8',
|
||||
iconColor: '#848b9b',
|
||||
href: '/sessions?filter=active',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ export function QuickActions() {
|
||||
|
||||
return (
|
||||
<div className="card-flat flex flex-col h-full">
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between p-3 gap-2">
|
||||
|
||||
@@ -25,7 +25,7 @@ const DEFAULT_ACTIVITIES: ActivityItem[] = [
|
||||
export function RecentActivity({ activities = DEFAULT_ACTIVITIES }: RecentActivityProps) {
|
||||
return (
|
||||
<div className="card-flat">
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Recent Activity</h3>
|
||||
</div>
|
||||
<div>
|
||||
@@ -35,7 +35,7 @@ export function RecentActivity({ activities = DEFAULT_ACTIVITIES }: RecentActivi
|
||||
className="flex items-start gap-3 px-5 py-3 fade-in"
|
||||
style={{
|
||||
animationDelay: `${750 + i * 40}ms`,
|
||||
borderBottom: i < activities.length - 1 ? '1px solid var(--glass-border)' : undefined,
|
||||
borderBottom: i < activities.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -22,7 +22,7 @@ const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }>
|
||||
abandoned: { icon: XCircle, color: '#8891a0' },
|
||||
}
|
||||
|
||||
export function RecentFlowPilotSessions() {
|
||||
export function RecentFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
|
||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -42,18 +42,20 @@ export function RecentFlowPilotSessions() {
|
||||
|
||||
return (
|
||||
<div className="card-flat">
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Recent Sessions</h3>
|
||||
<Link
|
||||
to="/sessions"
|
||||
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
History <ArrowRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Recent Sessions</h3>
|
||||
<Link
|
||||
to="/sessions"
|
||||
className="flex items-center gap-1 text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
History <ArrowRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{sessions.map((session, i) => {
|
||||
const config = STATUS_CONFIG[session.status] || STATUS_CONFIG.abandoned
|
||||
@@ -73,11 +75,14 @@ export function RecentFlowPilotSessions() {
|
||||
<StatusIcon size={14} style={{ color: config.color }} className="shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground truncate">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{session.session_type === 'chat'
|
||||
? (session.title || session.problem_summary || 'Chat')
|
||||
: (session.problem_summary || 'Session')}
|
||||
</p>
|
||||
{session.problem_domain && (
|
||||
<p className="text-[0.625rem] text-muted-foreground mt-0.5 truncate">{session.problem_domain}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 font-sans text-xs text-muted-foreground">
|
||||
{timeAgo(session.resolved_at || session.created_at)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SessionsPanel({ sessions, delay = 200 }: SessionsPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="card-flat fade-in" style={{ animationDelay: `${delay}ms` }}>
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Recent Sessions</h3>
|
||||
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
View All
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus } from 'lucide-react'
|
||||
import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus, Globe, Mail, Lock, Printer, Shield } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'VPN not connecting',
|
||||
'Outlook not syncing',
|
||||
'User locked out',
|
||||
'Slow internet',
|
||||
'Printer issues',
|
||||
'MFA problems',
|
||||
const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
|
||||
{ icon: Globe, label: 'VPN not connecting' },
|
||||
{ icon: Mail, label: 'Outlook not syncing' },
|
||||
{ icon: Lock, label: 'User locked out' },
|
||||
{ icon: Globe, label: 'Slow internet' },
|
||||
{ icon: Printer, label: 'Printer issues' },
|
||||
{ icon: Shield, label: 'MFA problems' },
|
||||
]
|
||||
|
||||
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
|
||||
@@ -199,7 +200,7 @@ export function StartSessionInput() {
|
||||
<div className={cn(
|
||||
'relative rounded-2xl border bg-card transition-all',
|
||||
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
|
||||
'focus-within:border-[rgba(6,182,212,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
'focus-within:border-[rgba(249,115,22,0.25)] focus-within:ring-1 focus-within:ring-[rgba(249,115,22,0.1)]'
|
||||
)}>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
@@ -277,7 +278,7 @@ export function StartSessionInput() {
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -337,15 +338,16 @@ export function StartSessionInput() {
|
||||
</div>
|
||||
|
||||
{/* Suggestion chips */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{SUGGESTIONS.map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={s}
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(s)}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/30 hover:bg-primary/5 transition-colors"
|
||||
onClick={() => handleSuggestionClick(label)}
|
||||
className="group flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-all hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)] hover:text-foreground active:scale-[0.97]"
|
||||
>
|
||||
{s}
|
||||
<Icon size={11} className="text-muted shrink-0 group-hover:text-[#f97316] transition-colors" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function TeamSummary() {
|
||||
<div className="card-flat">
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3"
|
||||
style={{ borderBottom: '1px solid var(--glass-border)' }}
|
||||
style={{ borderBottom: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Team Summary</h3>
|
||||
<button
|
||||
@@ -38,7 +38,7 @@ export function TeamSummary() {
|
||||
Manage <ArrowRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
|
||||
|
||||
return (
|
||||
<div className="card-flat flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="flex items-center gap-2 px-5 py-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
|
||||
<Calendar size={16} className="text-muted-foreground" />
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">This Week</h3>
|
||||
</div>
|
||||
@@ -47,13 +47,13 @@ export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
|
||||
key={day.dateStr}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
style={{
|
||||
borderRight: i < 4 ? '1px solid var(--glass-border)' : undefined,
|
||||
borderRight: i < 4 ? '1px solid var(--color-border-default)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-2 py-2 text-center"
|
||||
style={{
|
||||
borderBottom: day.isToday ? '2px solid #ea580c' : '1px solid var(--glass-border)',
|
||||
borderBottom: day.isToday ? '2px solid #ea580c' : '1px solid var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
<span className={`font-sans text-xs text-[0.625rem] uppercase tracking-widest ${day.isToday ? 'text-orange-400' : 'text-muted-foreground'}`}>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@@ -78,7 +78,7 @@ export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: C
|
||||
placeholder="Ask AI to help..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-hidden focus:border-primary/30"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -50,14 +50,14 @@ export function EditorAIPanel({
|
||||
background: 'rgba(16, 17, 20, 0.95)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
borderColor: 'var(--color-border-default)',
|
||||
animation: 'slideInRight 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-primary" />
|
||||
@@ -74,7 +74,7 @@ export function EditorAIPanel({
|
||||
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={cn(
|
||||
|
||||
@@ -22,7 +22,7 @@ const NODE_COLORS: Record<string, string> = {
|
||||
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
@@ -41,7 +41,7 @@ export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummary
|
||||
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -16,7 +16,7 @@ interface EscalateModalProps {
|
||||
|
||||
export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaTicket, sessionId }: EscalateModalProps) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [_escalateUploads, setEscalateUploads] = useState<FileUploadResponse[]>([])
|
||||
const [, setEscalateUploads] = useState<FileUploadResponse[]>([])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reason.trim() || reason.trim().length < 5) return
|
||||
|
||||
@@ -24,7 +24,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
const [psaChecked, setPsaChecked] = useState(false)
|
||||
|
||||
// Upload state (no session_id yet — uploads linked later)
|
||||
const [_intakeUploads, setIntakeUploads] = useState<FileUploadResponse[]>([])
|
||||
const [, setIntakeUploads] = useState<FileUploadResponse[]>([])
|
||||
|
||||
// Selected ticket state
|
||||
const [selectedTicket, setSelectedTicket] = useState<PSATicketInfo | null>(null)
|
||||
@@ -166,7 +166,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
value={additionalContext}
|
||||
onChange={(e) => setAdditionalContext(e.target.value)}
|
||||
placeholder="Add extra context (optional) — e.g. 'User called back and said it's also affecting their second monitor'"
|
||||
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
value={logContent}
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste log output, error messages, or Event Viewer entries here..."
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
rows={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -194,7 +194,7 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
|
||||
? 'border-border/50 opacity-50'
|
||||
: isDragOver
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border focus-within:border-[rgba(6,182,212,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
: 'border-border focus-within:border-[rgba(249,115,22,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)}
|
||||
style={{ background: 'var(--color-bg-card)' }}
|
||||
>
|
||||
@@ -275,7 +275,7 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function FlowPilotOptions({ options, onSelect, disabled }: FlowPilotOptio
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group relative rounded-xl border p-3 sm:p-4 text-left transition-all min-h-[44px]',
|
||||
'hover:border-[rgba(6,182,212,0.3)] hover:shadow-[0_0_20px_rgba(6,182,212,0.08)]',
|
||||
'hover:border-[rgba(249,115,22,0.3)] hover:shadow-[0_0_20px_rgba(249,115,22,0.08)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
isSelected
|
||||
? 'border-primary/40 bg-accent-dim'
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
StatusUpdateContext,
|
||||
StatusUpdateResponse,
|
||||
} from '@/types/ai-session'
|
||||
import type { BranchResponse } from '@/types/branching'
|
||||
import { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
import { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
import { FlowPilotMessageBar } from './FlowPilotMessageBar'
|
||||
@@ -17,6 +18,8 @@ import { SessionDocView } from './SessionDocView'
|
||||
import { StatusUpdateModal } from './StatusUpdateModal'
|
||||
import { SessionTicketCard } from './SessionTicketCard'
|
||||
import { SimilarSessions } from './SimilarSessions'
|
||||
import { BranchMap } from '@/components/session/BranchMap'
|
||||
import { BranchTransitionBar } from '@/components/session/BranchTransitionBar'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -36,6 +39,10 @@ interface FlowPilotSessionProps {
|
||||
onRate: (rating: number) => void
|
||||
onReloadSession?: () => Promise<void>
|
||||
onGenerateStatusUpdate?: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
|
||||
// Branching props (optional — only present for branching sessions)
|
||||
branches?: BranchResponse[]
|
||||
activeBranchId?: string | null
|
||||
onBranchSwitch?: (branchId: string) => void
|
||||
}
|
||||
|
||||
export function FlowPilotSession({
|
||||
@@ -52,14 +59,36 @@ export function FlowPilotSession({
|
||||
onRate,
|
||||
onReloadSession,
|
||||
onGenerateStatusUpdate,
|
||||
branches,
|
||||
activeBranchId,
|
||||
onBranchSwitch,
|
||||
}: FlowPilotSessionProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [showTicketPicker, setShowTicketPicker] = useState(false)
|
||||
const [linkingTicket, setLinkingTicket] = useState(false)
|
||||
const [showShareCommunication, setShowShareCommunication] = useState(false)
|
||||
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
|
||||
const prevBranchIdRef = useRef<string | null>(null)
|
||||
const [branchTransition, setBranchTransition] = useState<{
|
||||
from: BranchResponse | null
|
||||
to: BranchResponse
|
||||
} | null>(null)
|
||||
|
||||
const handleLinkTicket = async (ticketId: string, _ticket: PSATicketInfo) => {
|
||||
// Track branch switches and show transition bar
|
||||
useEffect(() => {
|
||||
if (!activeBranchId || !branches?.length) return
|
||||
const prev = prevBranchIdRef.current
|
||||
if (prev && prev !== activeBranchId) {
|
||||
const fromBranch = branches.find(b => b.id === prev) ?? null
|
||||
const toBranch = branches.find(b => b.id === activeBranchId)
|
||||
if (toBranch) {
|
||||
setBranchTransition({ from: fromBranch, to: toBranch })
|
||||
}
|
||||
}
|
||||
prevBranchIdRef.current = activeBranchId
|
||||
}, [activeBranchId, branches])
|
||||
|
||||
const handleLinkTicket = async (ticketId: string, _ticket?: PSATicketInfo) => {
|
||||
if (!session.psa_connection_id && !session.ticket_data) {
|
||||
// Need a connection ID — try to get it from the integrations API
|
||||
// For now, we'll need it passed in. This will work when ticket_data has it.
|
||||
@@ -218,6 +247,14 @@ export function FlowPilotSession({
|
||||
{/* Conversation column — pb-24 provides clearance for the fixed message bar */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-24 sm:p-4 sm:pb-24 lg:p-6 lg:pb-24">
|
||||
<div className="mx-auto max-w-2xl space-y-3">
|
||||
{/* Branch transition bar */}
|
||||
{branchTransition && (
|
||||
<BranchTransitionBar
|
||||
fromBranch={branchTransition.from}
|
||||
toBranch={branchTransition.to}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allSteps.map((step) => (
|
||||
<FlowPilotStepCard
|
||||
key={step.step_id}
|
||||
@@ -226,6 +263,8 @@ export function FlowPilotSession({
|
||||
isProcessing={isProcessing && currentStep?.step_id === step.step_id}
|
||||
sessionId={session.id}
|
||||
onRespond={onRespond}
|
||||
onBranchSwitch={onBranchSwitch}
|
||||
activeBranchId={activeBranchId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -236,6 +275,15 @@ export function FlowPilotSession({
|
||||
className="hidden w-72 shrink-0 overflow-y-auto border-l border-border p-4 lg:block"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Branch map (branching sessions only) */}
|
||||
{session.is_branching && branches && branches.length > 0 && onBranchSwitch && (
|
||||
<BranchMap
|
||||
branches={branches}
|
||||
activeBranchId={activeBranchId ?? null}
|
||||
onSelectBranch={onBranchSwitch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ticket context */}
|
||||
{session.psa_ticket_id ? (
|
||||
<SessionTicketCard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp, GitFork } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
|
||||
import { isScriptGenerationAction, isScriptBuilderAction, getActionType } from '@/types/ai-session'
|
||||
@@ -13,6 +13,8 @@ interface FlowPilotStepCardProps {
|
||||
isProcessing: boolean
|
||||
sessionId?: string
|
||||
onRespond: (response: StepResponseRequest) => void
|
||||
onBranchSwitch?: (branchId: string) => void
|
||||
activeBranchId?: string | null
|
||||
}
|
||||
|
||||
const STEP_TYPE_ICONS = {
|
||||
@@ -23,9 +25,10 @@ const STEP_TYPE_ICONS = {
|
||||
info_request: MessageSquare,
|
||||
script_generation: Zap,
|
||||
note: MessageSquare,
|
||||
fork: GitFork,
|
||||
} as const
|
||||
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond }: FlowPilotStepCardProps) {
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond, onBranchSwitch, activeBranchId }: FlowPilotStepCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
|
||||
|
||||
const content = step.content as Record<string, unknown>
|
||||
@@ -94,6 +97,65 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
|
||||
)
|
||||
}
|
||||
|
||||
// Fork step — special rendering with branch options
|
||||
if (contentType === 'fork') {
|
||||
const forkReason = (content.fork_reason as string) || stepText
|
||||
const forkBranches = (content.fork_branches as Array<{ branch_id: string; label: string }>) || []
|
||||
|
||||
return (
|
||||
<div className="card-flat p-3 sm:p-4 lg:p-5 border-accent/30">
|
||||
{/* Context message */}
|
||||
{step.context_message && (
|
||||
<div className="mb-3 rounded-lg bg-primary/5 px-3 py-2 border border-primary/10">
|
||||
<MarkdownContent content={step.context_message} className="text-xs text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fork header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-accent-dim">
|
||||
<GitFork size={14} className="text-accent" />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
||||
Diagnostic Fork
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fork reason */}
|
||||
<MarkdownContent content={forkReason} className="text-sm mb-4" />
|
||||
|
||||
{/* Branch options */}
|
||||
{forkBranches.length > 0 && onBranchSwitch && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{forkBranches.map((branch) => {
|
||||
const isActive = branch.branch_id === activeBranchId
|
||||
return (
|
||||
<button
|
||||
key={branch.branch_id}
|
||||
onClick={() => onBranchSwitch(branch.branch_id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-[5px] border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-elevated',
|
||||
isActive
|
||||
? 'border-accent bg-accent-dim'
|
||||
: 'border-default bg-elevated/50'
|
||||
)}
|
||||
>
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
isActive ? 'text-accent-text' : 'text-heading'
|
||||
)}>
|
||||
{branch.label}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Current active step
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -96,7 +96,7 @@ export function InSessionScriptGenerator({
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setParams(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b px-6 py-4" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="border-b px-6 py-4" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">{proposal.title}</h2>
|
||||
{proposal.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{proposal.description}</p>
|
||||
@@ -187,7 +187,7 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
|
||||
value={reviewNotes}
|
||||
onChange={(e) => setReviewNotes(e.target.value)}
|
||||
placeholder="Reviewer notes (optional)"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SessionBriefing({
|
||||
value={freshContext}
|
||||
onChange={(e) => setFreshContext(e.target.value)}
|
||||
placeholder="What additional information do you have, or what would you like to investigate first?"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ export function SimilarSessions({ sessionId }: SimilarSessionsProps) {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from props
|
||||
setLoading(true)
|
||||
aiSessionsApi
|
||||
.getSimilar(sessionId, 5)
|
||||
|
||||
@@ -89,7 +89,7 @@ export function ReviewScreen({ kbImport, onEditNode, onApproveAll, onCommit, onD
|
||||
|
||||
{/* Nodes panel */}
|
||||
<div className="flex flex-col card-flat overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<span className="text-sm font-medium text-foreground">Generated Flow</span>
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
|
||||
{kbImport.target_type === 'troubleshooting' ? 'Troubleshooting' : 'Project'}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function SourcePanel({ sourceText, sourceFormat, highlightExcerpt }: Sour
|
||||
|
||||
return (
|
||||
<div className="card-flat flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<FileText size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Source Document</span>
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground ml-auto">
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ViewTransitionOutlet() {
|
||||
const routeKey = segments.slice(0, 2).join('/') || '/'
|
||||
|
||||
return (
|
||||
<div key={routeKey} className="flex-1 min-h-0 flex flex-col animate-fade-in-up">
|
||||
<div key={routeKey} className="flex-1 min-h-0 flex flex-col animate-fade-in">
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ScriptBuilderInput({
|
||||
const canSend = value.trim().length > 0 && !disabled
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -56,7 +56,7 @@ export function ScriptBuilderInput({
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
|
||||
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors",
|
||||
"focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors",
|
||||
"disabled:opacity-50"
|
||||
)}
|
||||
style={{ maxHeight: 120 }}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ParameterCard({
|
||||
value={param.type}
|
||||
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
|
||||
@@ -174,7 +174,7 @@ export function ParameterDetectorStepper({
|
||||
<select
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value as ScriptParameter['type'])}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ParameterSchemaBuilder({ schema, onChange, disabled }: Props) {
|
||||
onChange={e => { setJsonText(e.target.value); setJsonError(null) }}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[300px] resize-y font-sans text-xs text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full min-h-[300px] resize-y font-sans text-xs text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder='{ "parameters": [...] }'
|
||||
/>
|
||||
{jsonError && (
|
||||
|
||||
@@ -356,7 +356,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
<select
|
||||
value={form.category_id}
|
||||
onChange={e => updateField('category_id', e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
>
|
||||
<option value="">Select category…</option>
|
||||
{categories.map(c => (
|
||||
@@ -369,7 +369,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
<select
|
||||
value={form.complexity}
|
||||
onChange={e => updateField('complexity', e.target.value as FormState['complexity'])}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ScriptTemplateListView({ onEdit, onCreate }: Props) {
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search templates…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)]"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(249,115,22,0.3)] focus:ring-1 focus:ring-[rgba(249,115,22,0.2)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ScriptParameterField({ param, value, error, disabled }: Props) {
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{(param.options ?? []).map(opt => (
|
||||
|
||||
@@ -166,7 +166,7 @@ export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }:
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +186,7 @@ export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }:
|
||||
className={cn(
|
||||
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'font-mono text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
81
frontend/src/components/session/BranchMap.tsx
Normal file
81
frontend/src/components/session/BranchMap.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import { BranchNode } from './BranchNode'
|
||||
import type { BranchResponse } from '@/types/branching'
|
||||
|
||||
interface BranchTreeNode {
|
||||
branch: BranchResponse
|
||||
depth: number
|
||||
children: BranchTreeNode[]
|
||||
}
|
||||
|
||||
function buildTree(branches: BranchResponse[]): BranchTreeNode[] {
|
||||
const map = new Map<string, BranchTreeNode>()
|
||||
const roots: BranchTreeNode[] = []
|
||||
|
||||
for (const branch of branches) {
|
||||
map.set(branch.id, { branch, depth: 0, children: [] })
|
||||
}
|
||||
|
||||
for (const branch of branches) {
|
||||
const node = map.get(branch.id)!
|
||||
if (branch.parent_branch_id && map.has(branch.parent_branch_id)) {
|
||||
const parent = map.get(branch.parent_branch_id)!
|
||||
node.depth = parent.depth + 1
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
function flattenTree(nodes: BranchTreeNode[]): Array<{ branch: BranchResponse; depth: number }> {
|
||||
const result: Array<{ branch: BranchResponse; depth: number }> = []
|
||||
for (const node of nodes) {
|
||||
result.push({ branch: node.branch, depth: node.depth })
|
||||
if (node.children.length > 0) {
|
||||
result.push(...flattenTree(node.children))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface BranchMapProps {
|
||||
branches: BranchResponse[]
|
||||
activeBranchId: string | null
|
||||
onSelectBranch: (branchId: string) => void
|
||||
}
|
||||
|
||||
export function BranchMap({ branches, activeBranchId, onSelectBranch }: BranchMapProps) {
|
||||
const roots = buildTree(branches)
|
||||
const flat = flattenTree(roots)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 px-2 pb-1">
|
||||
<GitBranch size={14} className="text-accent-text" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Branch Map
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] font-medium text-muted-foreground">{branches.length}</span>
|
||||
</div>
|
||||
|
||||
{flat.length === 0 ? (
|
||||
<p className="px-2 text-xs text-muted-foreground">No branches yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{flat.map(({ branch, depth }) => (
|
||||
<BranchNode
|
||||
key={branch.id}
|
||||
branch={branch}
|
||||
depth={depth}
|
||||
isActive={branch.id === activeBranchId}
|
||||
onClick={onSelectBranch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
frontend/src/components/session/BranchNode.tsx
Normal file
199
frontend/src/components/session/BranchNode.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react'
|
||||
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BranchResponse } from '@/types/branching'
|
||||
|
||||
type BranchStatus = BranchResponse['status']
|
||||
|
||||
interface StatusConfig {
|
||||
icon: React.ElementType
|
||||
textClass: string
|
||||
badgeClass: string
|
||||
borderClass: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<BranchStatus, StatusConfig> = {
|
||||
active: {
|
||||
icon: CircleDot,
|
||||
textClass: 'text-accent',
|
||||
badgeClass: 'bg-accent-dim text-accent-text',
|
||||
borderClass: 'border-accent/50',
|
||||
label: 'Active',
|
||||
},
|
||||
solved: {
|
||||
icon: CheckCircle2,
|
||||
textClass: 'text-success',
|
||||
badgeClass: 'bg-success-dim text-success',
|
||||
borderClass: 'border-success/30',
|
||||
label: 'Solved',
|
||||
},
|
||||
dead_end: {
|
||||
icon: XCircle,
|
||||
textClass: 'text-danger',
|
||||
badgeClass: 'bg-danger-dim text-danger',
|
||||
borderClass: 'border-danger/30',
|
||||
label: 'Dead End',
|
||||
},
|
||||
untried: {
|
||||
icon: Circle,
|
||||
textClass: 'text-muted-foreground',
|
||||
badgeClass: 'bg-elevated text-muted-foreground',
|
||||
borderClass: 'border-default',
|
||||
label: 'Untried',
|
||||
},
|
||||
revived: {
|
||||
icon: RotateCcw,
|
||||
textClass: 'text-warning',
|
||||
badgeClass: 'bg-warning-dim text-warning',
|
||||
borderClass: 'border-warning/30',
|
||||
label: 'Revived',
|
||||
},
|
||||
}
|
||||
|
||||
interface BranchNodeProps {
|
||||
branch: BranchResponse
|
||||
depth: number
|
||||
isActive: boolean
|
||||
onClick: (branchId: string) => void
|
||||
}
|
||||
|
||||
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const config = STATUS_CONFIG[branch.status]
|
||||
const Icon = config.icon
|
||||
|
||||
const hasDetail = branch.context_summary || branch.status_reason
|
||||
const showExpanded = isHovered && hasDetail && !isActive
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ marginLeft: `${depth * 12}px` }}
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Base card — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(branch.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-2.5 transition-all duration-150 cursor-pointer',
|
||||
isActive
|
||||
? cn('bg-card', config.borderClass)
|
||||
: 'bg-card/60 border-default hover:bg-card hover:border-hover',
|
||||
)}
|
||||
>
|
||||
<CardHeader icon={Icon} config={config} branch={branch} isActive={isActive} />
|
||||
|
||||
{/* Inline detail for active branch */}
|
||||
{isActive && hasDetail && (
|
||||
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
|
||||
<BranchDetail branch={branch} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded card — floats directly on top of the base card */}
|
||||
{showExpanded && (
|
||||
<>
|
||||
{/* Visual dim — pointer-events-none so clicks pass through to cards */}
|
||||
<div className="fixed inset-0 z-40 bg-black/30 pointer-events-none" />
|
||||
|
||||
{/* Expanded card positioned over the original */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onClick(branch.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick(branch.id) }}
|
||||
className={cn(
|
||||
'absolute z-50 inset-x-0 top-0 cursor-pointer',
|
||||
'bg-card border rounded-lg p-2.5',
|
||||
'shadow-[0_8px_32px_rgba(0,0,0,0.5)]',
|
||||
config.borderClass,
|
||||
)}
|
||||
style={{
|
||||
/* Grow slightly wider than the base card */
|
||||
marginLeft: -8,
|
||||
marginRight: -8,
|
||||
width: 'calc(100% + 16px)',
|
||||
}}
|
||||
>
|
||||
<CardHeader icon={Icon} config={config} branch={branch} isActive={false} />
|
||||
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
|
||||
<BranchDetail branch={branch} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Card header row — shared between base and expanded states */
|
||||
function CardHeader({
|
||||
icon: Icon,
|
||||
config,
|
||||
branch,
|
||||
isActive,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
config: StatusConfig
|
||||
branch: BranchResponse
|
||||
isActive: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className={cn('shrink-0', config.textClass)} />
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
isActive ? 'text-heading font-medium' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{branch.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
|
||||
config.badgeClass
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Detail content — shared between inline (active) and expanded (hover) */
|
||||
function BranchDetail({ branch }: { branch: BranchResponse }) {
|
||||
return (
|
||||
<>
|
||||
{branch.context_summary && (
|
||||
<>
|
||||
{branch.context_summary.tried.length > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
<span className="text-foreground font-medium">Tried:</span>{' '}
|
||||
{branch.context_summary.tried.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{branch.context_summary.concluded && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
<span className="text-foreground font-medium">Result:</span>{' '}
|
||||
{branch.context_summary.concluded}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{branch.status_reason && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
<span className="text-foreground font-medium">Reason:</span>{' '}
|
||||
{branch.status_reason}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pt-0.5">
|
||||
<span>{branch.step_count} step{branch.step_count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/session/BranchRevivalCard.tsx
Normal file
26
frontend/src/components/session/BranchRevivalCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import type { BranchResponse } from '@/types/branching'
|
||||
|
||||
interface BranchRevivalCardProps {
|
||||
branch: BranchResponse
|
||||
evidenceSource: BranchResponse | null
|
||||
}
|
||||
|
||||
export function BranchRevivalCard({ branch, evidenceSource }: BranchRevivalCardProps) {
|
||||
if (branch.status !== 'revived') return null
|
||||
|
||||
return (
|
||||
<div className="bg-warning-dim border border-warning/20 rounded-md px-3 py-2 my-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<RotateCcw size={14} className="text-warning" />
|
||||
<span className="text-warning font-medium">Branch Revived</span>
|
||||
</div>
|
||||
{branch.evidence_description && (
|
||||
<p className="text-xs text-primary mt-1">{branch.evidence_description}</p>
|
||||
)}
|
||||
{evidenceSource && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Evidence from: {evidenceSource.label}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
frontend/src/components/session/BranchTransitionBar.tsx
Normal file
22
frontend/src/components/session/BranchTransitionBar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type { BranchResponse } from '@/types/branching'
|
||||
|
||||
interface BranchTransitionBarProps {
|
||||
fromBranch: BranchResponse | null
|
||||
toBranch: BranchResponse
|
||||
}
|
||||
|
||||
export function BranchTransitionBar({ fromBranch, toBranch }: BranchTransitionBarProps) {
|
||||
return (
|
||||
<div className="bg-accent-dim border border-accent/20 rounded-md px-3 py-2 my-2 flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">Switched to</span>
|
||||
<span className="text-accent-text font-medium">{toBranch.label}</span>
|
||||
{fromBranch && (
|
||||
<>
|
||||
<ArrowRight size={12} className="text-muted" />
|
||||
<span className="text-muted">from {fromBranch.label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/session/ForkCard.tsx
Normal file
64
frontend/src/components/session/ForkCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { GitFork } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ForkPointResponse } from '@/types/branching'
|
||||
|
||||
interface ForkCardProps {
|
||||
fork: ForkPointResponse
|
||||
selectedBranchId: string | null
|
||||
onSelectOption: (branchId: string) => void
|
||||
}
|
||||
|
||||
export function ForkCard({ fork, selectedBranchId, onSelectOption }: ForkCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-default bg-card p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GitFork size={16} className="text-accent shrink-0" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
||||
Fork Point
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fork reason */}
|
||||
<p className="text-sm text-heading leading-snug">{fork.fork_reason}</p>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{fork.options.map((option) => {
|
||||
const isSelected = option.branch_id === selectedBranchId
|
||||
return (
|
||||
<button
|
||||
key={option.branch_id}
|
||||
type="button"
|
||||
onClick={() => onSelectOption(option.branch_id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-[5px] border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-elevated',
|
||||
isSelected
|
||||
? 'border-accent bg-accent-dim'
|
||||
: 'border-default bg-elevated/50'
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
isSelected ? 'text-accent-text' : 'text-heading'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</p>
|
||||
{option.description && (
|
||||
<p className={cn(
|
||||
'text-xs mt-0.5 leading-snug',
|
||||
isSelected ? 'text-primary' : 'text-primary'
|
||||
)}>
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
frontend/src/components/session/HandoffModal.tsx
Normal file
167
frontend/src/components/session/HandoffModal.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HandoffCreateRequest } from '@/types/branching'
|
||||
|
||||
interface HandoffModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: HandoffCreateRequest) => Promise<void>
|
||||
}
|
||||
|
||||
type HandoffIntent = 'park' | 'escalate'
|
||||
|
||||
export function HandoffModal({ onClose, onSubmit }: HandoffModalProps) {
|
||||
const [intent, setIntent] = useState<HandoffIntent>('park')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [elevated, setElevated] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const data: HandoffCreateRequest = {
|
||||
intent,
|
||||
engineer_notes: notes.trim() || undefined,
|
||||
priority: intent === 'escalate' && elevated ? 'elevated' : 'normal',
|
||||
}
|
||||
await onSubmit(data)
|
||||
onClose()
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 w-full max-w-full sm:max-w-lg',
|
||||
'bg-card border border-default rounded-lg',
|
||||
'flex flex-col gap-0'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Hand off session"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-default">
|
||||
<h2 className="text-sm font-heading font-semibold text-heading">Hand Off Session</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-primary transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
{/* Intent toggle */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted">
|
||||
Handoff Type
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntent('park')}
|
||||
className={cn(
|
||||
'flex-1 rounded-[5px] border px-3 py-2 text-sm font-medium transition-colors',
|
||||
intent === 'park'
|
||||
? 'border-accent bg-accent-dim text-accent-text'
|
||||
: 'border-default bg-transparent text-muted-foreground hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
Park
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntent('escalate')}
|
||||
className={cn(
|
||||
'flex-1 rounded-[5px] border px-3 py-2 text-sm font-medium transition-colors',
|
||||
intent === 'escalate'
|
||||
? 'border-accent bg-accent-dim text-accent-text'
|
||||
: 'border-default bg-transparent text-muted-foreground hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
Escalate
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{intent === 'park'
|
||||
? 'Park this session to resume later or hand to another engineer.'
|
||||
: 'Escalate to a senior engineer with full context and branch history.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="handoff-notes"
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted"
|
||||
>
|
||||
Engineer Notes
|
||||
<span className="ml-1 normal-case font-normal text-muted">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="handoff-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add context for whoever picks this up…"
|
||||
className={cn(
|
||||
'w-full resize-none rounded-[5px] border border-default bg-input',
|
||||
'px-3 py-2 text-sm text-primary placeholder:text-muted',
|
||||
'focus:outline-none focus:border-accent focus:shadow-[0_0_0_2px_var(--color-accent-dim)]',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority (escalate only) */}
|
||||
{intent === 'escalate' && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={elevated}
|
||||
onChange={e => setElevated(e.target.checked)}
|
||||
className="accent-accent w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-primary">Mark as elevated priority</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-default">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[5px] border border-default px-4 py-2 text-sm text-muted-foreground hover:bg-elevated hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-[5px] bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Submitting…' : intent === 'park' ? 'Park Session' : 'Escalate Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
frontend/src/components/session/ResolutionOutputPanel.tsx
Normal file
239
frontend/src/components/session/ResolutionOutputPanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FileText, BookOpen, MessageSquare, Pencil, Copy, Send, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { resolutionsApi } from '@/api/resolutions'
|
||||
import type {
|
||||
ResolutionOutputResponse,
|
||||
ResolutionOutputType,
|
||||
} from '@/types/branching'
|
||||
|
||||
interface Tab {
|
||||
type: ResolutionOutputType
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ type: 'psa_ticket_notes', label: 'PSA Notes', icon: FileText },
|
||||
{ type: 'knowledge_base', label: 'KB Article', icon: BookOpen },
|
||||
{ type: 'client_summary', label: 'Client Summary', icon: MessageSquare },
|
||||
]
|
||||
|
||||
interface ResolutionOutputPanelProps {
|
||||
sessionId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ResolutionOutputPanel({ sessionId, className }: ResolutionOutputPanelProps) {
|
||||
const [outputs, setOutputs] = useState<ResolutionOutputResponse[]>([])
|
||||
const [activeType, setActiveType] = useState<ResolutionOutputType>('psa_ticket_notes')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isPushing, setIsPushing] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const loadOutputs = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await resolutionsApi.getOutputs(sessionId)
|
||||
setOutputs(result.outputs)
|
||||
} catch {
|
||||
toast.error('Failed to load resolution outputs')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
loadOutputs()
|
||||
}, [loadOutputs])
|
||||
|
||||
const activeOutput = outputs.find(o => o.output_type === activeType) ?? null
|
||||
const displayContent = activeOutput?.edited_content ?? activeOutput?.generated_content ?? ''
|
||||
|
||||
function handleTabChange(type: ResolutionOutputType) {
|
||||
setActiveType(type)
|
||||
setIsEditing(false)
|
||||
setEditValue('')
|
||||
}
|
||||
|
||||
function handleEditToggle() {
|
||||
if (!isEditing) {
|
||||
setEditValue(displayContent)
|
||||
setIsEditing(true)
|
||||
} else {
|
||||
setIsEditing(false)
|
||||
setEditValue('')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!activeOutput) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const updated = await resolutionsApi.editOutput(sessionId, activeOutput.id, {
|
||||
edited_content: editValue,
|
||||
})
|
||||
setOutputs(prev => prev.map(o => (o.id === updated.id ? updated : o)))
|
||||
setIsEditing(false)
|
||||
setEditValue('')
|
||||
toast.success('Output saved')
|
||||
} catch {
|
||||
toast.error('Failed to save edit')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayContent)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePushToPsa() {
|
||||
if (!activeOutput) return
|
||||
setIsPushing(true)
|
||||
try {
|
||||
const updated = await resolutionsApi.pushOutput(sessionId, activeOutput.id, {
|
||||
destination: 'psa',
|
||||
})
|
||||
setOutputs(prev => prev.map(o => (o.id === updated.id ? updated : o)))
|
||||
toast.success('Pushed to PSA')
|
||||
} catch {
|
||||
toast.error('Failed to push to PSA')
|
||||
} finally {
|
||||
setIsPushing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col bg-card border border-default rounded-lg overflow-hidden', className)}>
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-default shrink-0">
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const isActive = tab.type === activeType
|
||||
return (
|
||||
<button
|
||||
key={tab.type}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab.type)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-accent text-accent-text'
|
||||
: 'border-transparent text-muted-foreground hover:text-primary hover:border-hover'
|
||||
)}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-3 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<span className="text-xs text-muted">Loading…</span>
|
||||
</div>
|
||||
) : !activeOutput ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<span className="text-xs text-muted">No output generated yet.</span>
|
||||
</div>
|
||||
) : isEditing ? (
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
className={cn(
|
||||
'w-full h-full min-h-[160px] resize-none rounded-[5px] border border-default bg-input',
|
||||
'px-3 py-2 text-sm text-primary font-mono leading-relaxed',
|
||||
'focus:outline-none focus:border-accent focus:shadow-[0_0_0_2px_var(--color-accent-dim)]',
|
||||
'transition-colors'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-sm text-primary font-mono whitespace-pre-wrap leading-relaxed">
|
||||
{displayContent}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-default shrink-0">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={isSaving}
|
||||
className="rounded-[5px] bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditToggle}
|
||||
disabled={isSaving}
|
||||
className="rounded-[5px] border border-default px-3 py-1.5 text-xs text-muted-foreground hover:bg-elevated hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditToggle}
|
||||
disabled={!activeOutput}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs text-muted-foreground',
|
||||
'hover:bg-elevated hover:text-primary transition-colors disabled:opacity-40'
|
||||
)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!activeOutput}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs transition-colors disabled:opacity-40',
|
||||
copied
|
||||
? 'border-success text-success'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePushToPsa}
|
||||
disabled={!activeOutput || isPushing || activeOutput.status === 'pushed'}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs transition-colors disabled:opacity-40',
|
||||
activeOutput?.status === 'pushed'
|
||||
? 'border-success text-success'
|
||||
: 'text-muted-foreground hover:bg-elevated hover:text-primary'
|
||||
)}
|
||||
>
|
||||
<Send size={12} />
|
||||
{isPushing ? 'Pushing…' : activeOutput?.status === 'pushed' ? 'Pushed' : 'Push to PSA'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
className={cn(
|
||||
'mt-2 w-full max-w-md rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ActivityItem({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors',
|
||||
'hover:bg-[rgba(255,255,255,0.03)]',
|
||||
isRecent ? 'text-text-rail-label text-[0.72rem]' : 'text-[#e2e8f0] text-[0.8rem]'
|
||||
isRecent ? 'text-text-rail-label text-[0.72rem]' : 'text-text-primary text-[0.8rem]'
|
||||
)}
|
||||
title={`${treeName}${ticketNumber ? ` (${ticketNumber})` : ''} — click to resume`}
|
||||
aria-label={
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user