feat: AI marker system prompt fixes, TaskLane activation, and FlowPilot updates
- Fix system prompt to ensure [QUESTIONS]/[ACTIONS] markers in AI responses - Add format reminder injection to user messages for marker compliance - Wire TaskLane activation in prefill and resume paths - Add ActionCardGroup component for structured question/action rendering - Update FlowPilot session and step card components - Update ai-session schemas and types for marker data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md - Patherly / ResolutionFlow Project Context
|
||||
|
||||
> **Last Updated:** March 23, 2026
|
||||
> **Last Updated:** March 25, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -320,7 +320,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.
|
||||
|
||||
@@ -386,6 +386,10 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
@@ -408,7 +412,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`
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -250,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -428,6 +442,43 @@ async def process_response(
|
||||
|
||||
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
|
||||
@@ -1239,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
|
||||
|
||||
@@ -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(
|
||||
@@ -124,10 +249,47 @@ async def send_chat_message(
|
||||
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 ai_content, suggested_flows, session
|
||||
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():
|
||||
@@ -161,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
|
||||
@@ -174,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
300
frontend/src/components/assistant/ActionCardGroup.tsx
Normal file
300
frontend/src/components/assistant/ActionCardGroup.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Send, Clipboard, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ActionItem } from '@/types/ai-session'
|
||||
|
||||
type CardState = 'pending' | 'pasting' | 'typing' | 'skipped' | 'done'
|
||||
|
||||
interface CardResponse {
|
||||
label: string
|
||||
state: CardState
|
||||
value: string
|
||||
}
|
||||
|
||||
interface ActionCardGroupProps {
|
||||
actions: ActionItem[]
|
||||
onSubmit: (responses: CardResponse[]) => void
|
||||
disabled?: boolean
|
||||
stale?: boolean
|
||||
}
|
||||
|
||||
export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCardGroupProps) {
|
||||
const [responses, setResponses] = useState<CardResponse[]>(
|
||||
actions.map(a => ({ label: a.label, state: 'pending', value: '' }))
|
||||
)
|
||||
const [showRunAll, setShowRunAll] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const anyPending = responses.some(r => r.state === 'pending')
|
||||
const isCollapsed = stale && anyPending && !expanded
|
||||
|
||||
const updateCard = (idx: number, updates: Partial<CardResponse>) => {
|
||||
setResponses(prev => prev.map((r, i) => i === idx ? { ...r, ...updates } : r))
|
||||
}
|
||||
|
||||
const allHandled = responses.every(r => r.state !== 'pending' && r.state !== 'pasting' && r.state !== 'typing')
|
||||
const anyInteracted = responses.some(r => r.state !== 'pending')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true)
|
||||
setSubmitError(false)
|
||||
try {
|
||||
onSubmit(responses)
|
||||
setSubmitted(true)
|
||||
} catch {
|
||||
setSubmitError(true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyCommand = (command: string) => {
|
||||
navigator.clipboard.writeText(command)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
// Build combined script for "Run All"
|
||||
const commandActions = actions.filter(a => a.command)
|
||||
const combinedScript = commandActions.map((a, i) => (
|
||||
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
||||
)).join('\n\n')
|
||||
|
||||
const doneCount = responses.filter(r => r.state === 'done').length
|
||||
const skippedCount = responses.filter(r => r.state === 'skipped').length
|
||||
|
||||
// ── Collapsed state (stale cards from earlier in conversation) ──
|
||||
if (isCollapsed) {
|
||||
const pendingCount = responses.filter(r => r.state === 'pending').length
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<Terminal size={12} />
|
||||
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
||||
</div>
|
||||
<span className="text-[0.6875rem] text-accent-text opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Expand
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Submitted state ──
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="rounded-lg border border-success/20 bg-success-dim/20 p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-[0.8125rem] font-medium text-success">
|
||||
<Check size={14} />
|
||||
<span>{doneCount} checked, {skippedCount} skipped</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{responses.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
{r.state === 'done' ? (
|
||||
<Check size={10} className="text-success shrink-0" />
|
||||
) : (
|
||||
<SkipForward size={10} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={r.state === 'skipped' ? 'line-through opacity-60' : ''}>
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Run All button — only if multiple commands exist */}
|
||||
{commandActions.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Run All ({commandActions.length} commands)</span>
|
||||
{showRunAll ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Combined diagnostic script
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
<Copy size={11} />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.8125rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">
|
||||
{combinedScript}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual action cards */}
|
||||
{actions.map((action, idx) => {
|
||||
const response = responses[idx]
|
||||
const isExpanded = response.state === 'pasting' || response.state === 'typing'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-all',
|
||||
response.state === 'done' ? 'border-success/30 bg-success-dim/30' :
|
||||
response.state === 'skipped' ? 'border-default/50 bg-elevated/20 opacity-60' :
|
||||
'border-default bg-card hover:border-hover'
|
||||
)}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
||||
{action.description && (
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge for handled cards */}
|
||||
{response.state === 'done' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
)}
|
||||
{response.state === 'skipped' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command with copy button */}
|
||||
{action.command && response.state !== 'skipped' && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate">
|
||||
{action.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(action.command!)}
|
||||
className="shrink-0 text-muted-foreground hover:text-heading transition-colors"
|
||||
title="Copy command"
|
||||
>
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — only for pending cards */}
|
||||
{response.state === 'pending' && !disabled && (
|
||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pasting' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Paste Result
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'typing' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Type Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'skipped' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<SkipForward size={11} />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded input area */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
autoFocus
|
||||
value={response.value}
|
||||
onChange={e => updateCard(idx, { value: e.target.value })}
|
||||
placeholder={response.state === 'pasting' ? 'Paste command output here...' : 'Type your answer...'}
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'done' })}
|
||||
disabled={!response.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} />
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Submit / Error / Loading */}
|
||||
{anyInteracted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!allHandled || disabled || submitting}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-[0.8125rem] font-medium transition-colors',
|
||||
allHandled && !submitting
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send size={13} />
|
||||
Send Responses
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{submitError && (
|
||||
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger">
|
||||
<AlertCircle size={12} />
|
||||
<span>Failed to send</span>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="underline hover:no-underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,12 +59,34 @@ 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)
|
||||
|
||||
// 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) {
|
||||
@@ -218,6 +247,14 @@ export function FlowPilotSession({
|
||||
{/* Conversation column — pb-24 provides clearance for the fixed message bar */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-24 sm:p-4 sm:pb-24 lg:p-6 lg:pb-24">
|
||||
<div className="mx-auto max-w-2xl space-y-3">
|
||||
{/* Branch transition bar */}
|
||||
{branchTransition && (
|
||||
<BranchTransitionBar
|
||||
fromBranch={branchTransition.from}
|
||||
toBranch={branchTransition.to}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allSteps.map((step) => (
|
||||
<FlowPilotStepCard
|
||||
key={step.step_id}
|
||||
@@ -226,6 +263,8 @@ export function FlowPilotSession({
|
||||
isProcessing={isProcessing && currentStep?.step_id === step.step_id}
|
||||
sessionId={session.id}
|
||||
onRespond={onRespond}
|
||||
onBranchSwitch={onBranchSwitch}
|
||||
activeBranchId={activeBranchId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -236,6 +275,15 @@ export function FlowPilotSession({
|
||||
className="hidden w-72 shrink-0 overflow-y-auto border-l border-border p-4 lg:block"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Branch map (branching sessions only) */}
|
||||
{session.is_branching && branches && branches.length > 0 && onBranchSwitch && (
|
||||
<BranchMap
|
||||
branches={branches}
|
||||
activeBranchId={activeBranchId ?? null}
|
||||
onSelectBranch={onBranchSwitch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ticket context */}
|
||||
{session.psa_ticket_id ? (
|
||||
<SessionTicketCard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp, GitFork } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
|
||||
import { isScriptGenerationAction, isScriptBuilderAction, getActionType } from '@/types/ai-session'
|
||||
@@ -13,6 +13,8 @@ interface FlowPilotStepCardProps {
|
||||
isProcessing: boolean
|
||||
sessionId?: string
|
||||
onRespond: (response: StepResponseRequest) => void
|
||||
onBranchSwitch?: (branchId: string) => void
|
||||
activeBranchId?: string | null
|
||||
}
|
||||
|
||||
const STEP_TYPE_ICONS = {
|
||||
@@ -23,9 +25,10 @@ const STEP_TYPE_ICONS = {
|
||||
info_request: MessageSquare,
|
||||
script_generation: Zap,
|
||||
note: MessageSquare,
|
||||
fork: GitFork,
|
||||
} as const
|
||||
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond }: FlowPilotStepCardProps) {
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond, onBranchSwitch, activeBranchId }: FlowPilotStepCardProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
|
||||
|
||||
const content = step.content as Record<string, unknown>
|
||||
@@ -94,6 +97,65 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
|
||||
)
|
||||
}
|
||||
|
||||
// Fork step — special rendering with branch options
|
||||
if (contentType === 'fork') {
|
||||
const forkReason = (content.fork_reason as string) || stepText
|
||||
const forkBranches = (content.fork_branches as Array<{ branch_id: string; label: string }>) || []
|
||||
|
||||
return (
|
||||
<div className="card-flat p-3 sm:p-4 lg:p-5 border-accent/30">
|
||||
{/* Context message */}
|
||||
{step.context_message && (
|
||||
<div className="mb-3 rounded-lg bg-primary/5 px-3 py-2 border border-primary/10">
|
||||
<MarkdownContent content={step.context_message} className="text-xs text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fork header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-accent-dim">
|
||||
<GitFork size={14} className="text-accent" />
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
||||
Diagnostic Fork
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fork reason */}
|
||||
<MarkdownContent content={forkReason} className="text-sm mb-4" />
|
||||
|
||||
{/* Branch options */}
|
||||
{forkBranches.length > 0 && onBranchSwitch && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{forkBranches.map((branch) => {
|
||||
const isActive = branch.branch_id === activeBranchId
|
||||
return (
|
||||
<button
|
||||
key={branch.branch_id}
|
||||
onClick={() => onBranchSwitch(branch.branch_id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-[5px] border px-3 py-2.5 transition-colors',
|
||||
'hover:bg-elevated',
|
||||
isActive
|
||||
? 'border-accent bg-accent-dim'
|
||||
: 'border-default bg-elevated/50'
|
||||
)}
|
||||
>
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
isActive ? 'text-accent-text' : 'text-heading'
|
||||
)}>
|
||||
{branch.label}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Current active step
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -78,7 +78,7 @@ export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps
|
||||
type="button"
|
||||
onClick={() => onClick(branch.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-2.5 transition-all duration-150',
|
||||
'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',
|
||||
@@ -102,8 +102,12 @@ export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps
|
||||
|
||||
{/* 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',
|
||||
'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,
|
||||
|
||||
@@ -79,9 +79,9 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
),
|
||||
// Style horizontal rules
|
||||
hr: () => <hr className="my-4 border-border" />,
|
||||
// Style blockquotes
|
||||
// Style blockquotes — used for AI questions/action items that need to stand out
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-3 border-l-4 border-border pl-4 italic text-muted-foreground last:mb-0">
|
||||
<blockquote className="mb-3 rounded-lg border border-accent/20 bg-accent-dim/50 px-4 py-3 not-italic text-heading last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2, AlertTriangle, CheckCircle2, ArrowUpRight, FileText, MoreHorizontal, Pause, X } from 'lucide-react'
|
||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
|
||||
import { EscalateModal } from '@/components/flowpilot/EscalateModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { HandoffModal } from '@/components/session/HandoffModal'
|
||||
import { handoffsApi } from '@/api/handoffs'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
@@ -16,12 +19,14 @@ export default function FlowPilotSessionPage() {
|
||||
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
|
||||
const isPickup = searchParams.get('pickup') === 'true'
|
||||
const fp = useFlowPilotSession()
|
||||
const branching = useBranching()
|
||||
const prefillHandledRef = useRef(false)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
const [showAbandon, setShowAbandon] = useState(false)
|
||||
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
|
||||
const [showHandoff, setShowHandoff] = useState(false)
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -48,6 +53,13 @@ export default function FlowPilotSessionPage() {
|
||||
}
|
||||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Load branches when session is branching
|
||||
useEffect(() => {
|
||||
if (fp.session?.is_branching && fp.session.id) {
|
||||
branching.loadBranches(fp.session.id)
|
||||
}
|
||||
}, [fp.session?.is_branching, fp.session?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handlePickupContinue = async () => {
|
||||
if (!sessionId) return
|
||||
setPickingUp(true)
|
||||
@@ -64,6 +76,15 @@ export default function FlowPilotSessionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBranchSwitch = async (branchId: string) => {
|
||||
if (!fp.session) return
|
||||
const result = await branching.switchBranch(fp.session.id, branchId)
|
||||
if (result) {
|
||||
// Reload session to get updated steps for the switched branch
|
||||
await fp.loadSession(fp.session.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickupFresh = async (context: string) => {
|
||||
if (!sessionId) return
|
||||
setPickingUp(true)
|
||||
@@ -375,6 +396,9 @@ export default function FlowPilotSessionPage() {
|
||||
onRate={fp.rateSession}
|
||||
onReloadSession={() => fp.loadSession(fp.session!.id)}
|
||||
onGenerateStatusUpdate={(audience, length, context) => fp.generateStatusUpdate({ audience, length, context })}
|
||||
branches={branching.branches}
|
||||
activeBranchId={branching.activeBranchId}
|
||||
onBranchSwitch={handleBranchSwitch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -476,6 +500,18 @@ export default function FlowPilotSessionPage() {
|
||||
context="status"
|
||||
hasPsaTicket={!!fp.session.psa_ticket_id}
|
||||
/>
|
||||
|
||||
{/* Handoff modal (branching sessions) */}
|
||||
{fp.session.is_branching && showHandoff && (
|
||||
<HandoffModal
|
||||
onClose={() => setShowHandoff(false)}
|
||||
onSubmit={async (data) => {
|
||||
await handoffsApi.createHandoff(fp.session!.id, data)
|
||||
setShowHandoff(false)
|
||||
toast.success('Handoff created')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,9 +221,30 @@ export interface ChatMessageRequest {
|
||||
upload_ids?: string[]
|
||||
}
|
||||
|
||||
export interface ForkMetadata {
|
||||
fork_point_id: string
|
||||
fork_reason: string
|
||||
branches: Array<{ branch_id: string; label: string }>
|
||||
active_branch_id: string
|
||||
}
|
||||
|
||||
export interface ActionItem {
|
||||
label: string
|
||||
command?: string | null
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface QuestionItem {
|
||||
text: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface ChatMessageResponse {
|
||||
content: string
|
||||
suggested_flows: Array<{ tree_id: string; tree_name: string; tree_type: string; relevance_snippet: string }>
|
||||
fork?: ForkMetadata | null
|
||||
actions?: ActionItem[] | null
|
||||
questions?: QuestionItem[] | null
|
||||
}
|
||||
|
||||
export interface SimilarSession {
|
||||
|
||||
Reference in New Issue
Block a user