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:
chihlasm
2026-03-27 09:16:44 -04:00
committed by GitHub
132 changed files with 12581 additions and 508 deletions

32
.github/copilot-instructions.md vendored Normal file
View 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.

View File

@@ -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
View 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.

View File

@@ -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 |

View File

@@ -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

View 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")

View File

@@ -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

View 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,
)

View 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,
)

View 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,
)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -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,
)

View 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])

View 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])

View 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])

View 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")

View File

@@ -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):

View 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

View 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}

View 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]

View File

@@ -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

View 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,
}

View 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)

View File

@@ -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,

View 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

View 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}"
)

View File

@@ -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

View 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

View 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

View 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"

View 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"

View 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

View 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

View 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"

View File

@@ -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:

File diff suppressed because it is too large Load Diff

View 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
```

View File

@@ -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)

View File

@@ -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'),
})

View 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

View 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

View File

@@ -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'

View 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

View 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>
)
}

View File

@@ -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,

View File

@@ -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' && (
<>

View 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>
)
}

View File

@@ -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) {

View File

@@ -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'
)}

View File

@@ -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

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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,
}}
>

View File

@@ -52,7 +52,7 @@ export function PerformanceCards() {
label: 'Active Now',
value: active,
icon: TrendingUp,
iconColor: '#38bdf8',
iconColor: '#848b9b',
href: '/sessions?filter=active',
},
{

View File

@@ -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">

View File

@@ -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

View File

@@ -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)}

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View File

@@ -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'}`}>

View File

@@ -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

View File

@@ -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(

View File

@@ -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">

View File

@@ -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

View File

@@ -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}
/>
)}

View File

@@ -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>
)}

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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 */}

View File

@@ -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
/>

View File

@@ -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)

View File

@@ -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'}

View File

@@ -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">

View File

@@ -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>
)

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 => (

View File

@@ -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>

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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