Merge main into feat/flowpilot-migration
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 36s
CI / frontend (pull_request) Failing after 1m7s
CI / e2e (pull_request) Has been skipped

Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top
of a unified main. Two manual conflict resolutions:

1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven
   protocol). The pre-rewrite reference content (CW integration notes,
   lessons archive, env vars table) lives in `docs/connectwise/`,
   `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design.

2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions
   were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks
   (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with
   PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint).
   Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay,
   NewTicketModal) kept. All setters wired by either branch are intact.

Verification:
- `tsc -b` clean across the merged tree.
- Browser smoke-test (Session B fixture): Phase 9 ProposalBanner
  ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside
  PSA's new Tickets sidebar icon. Console clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 01:03:33 -04:00
45 changed files with 9951 additions and 106 deletions

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Ticket, ChevronDown, Check, Loader2, AlertCircle } from 'lucide-react'
import { useNavigate, Link } from 'react-router-dom'
import { Ticket, ChevronDown, Check, AlertCircle } from 'lucide-react'
import { integrationsApi } from '@/api/integrations'
import type { PSABoard, PSATicketSearchResult } from '@/types/integrations'
import { cn } from '@/lib/utils'
const PAGE_SIZE = 10
const PAGE_SIZE = 5
type Tab = 'mine' | 'unassigned'
@@ -188,14 +188,12 @@ function TicketRow({ ticket, isLast, onStartSession }: TicketRowProps) {
export function TicketQueue() {
const navigate = useNavigate()
const [hasConnection, setHasConnection] = useState<boolean | null>(null)
const [hasMemberMapping, setHasMemberMapping] = useState<boolean | null>(null) // null = loading
const [boards, setBoards] = useState<PSABoard[]>([])
const [selectedBoardIds, setSelectedBoardIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<Tab>('mine')
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check connection on mount
@@ -208,6 +206,15 @@ export function TicketQueue() {
.catch(() => setHasConnection(false))
}, [])
// Detect member mapping on mount
useEffect(() => {
integrationsApi.getMemberMappings()
.then(mappings => {
setHasMemberMapping(mappings.length > 0)
})
.catch(() => setHasMemberMapping(false))
}, [])
// Fetch boards once connection confirmed
useEffect(() => {
if (!hasConnection) return
@@ -217,9 +224,9 @@ export function TicketQueue() {
}, [hasConnection])
const fetchTickets = useCallback(
async (tab: Tab, boardIds: number[], pageNum: number, append: boolean) => {
async (tab: Tab, boardIds: number[]) => {
const params: Parameters<typeof integrationsApi.searchTicketsQueue>[0] = {
page: pageNum,
page: 1,
page_size: PAGE_SIZE,
}
if (tab === 'mine') {
@@ -233,12 +240,7 @@ export function TicketQueue() {
try {
const results = await integrationsApi.searchTicketsQueue(params)
if (append) {
setTickets((prev) => [...prev, ...results])
} else {
setTickets(results)
}
setHasMore(results.length === PAGE_SIZE)
setTickets(results.items)
setError(null)
} catch {
setError('Failed to load tickets. Check your PSA connection.')
@@ -250,20 +252,11 @@ export function TicketQueue() {
// Initial + reset fetch when tab or board selection changes
useEffect(() => {
if (!hasConnection) return
setPage(1)
if (activeTab === 'mine' && hasMemberMapping !== true) return
setTickets([])
setHasMore(false)
setLoading(true)
fetchTickets(activeTab, selectedBoardIds, 1, false).finally(() => setLoading(false))
}, [activeTab, selectedBoardIds, hasConnection, fetchTickets])
const handleLoadMore = async () => {
const nextPage = page + 1
setPage(nextPage)
setLoadingMore(true)
await fetchTickets(activeTab, selectedBoardIds, nextPage, true)
setLoadingMore(false)
}
fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false))
}, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets])
const handleStartSession = (ticket: PSATicketSearchResult) => {
navigate('/pilot', {
@@ -327,6 +320,18 @@ export function TicketQueue() {
{/* Content */}
<div>
{/* Mapping prompt for "mine" tab when no member mapping configured */}
{activeTab === 'mine' && hasMemberMapping === false && (
<div className="px-5 py-6 text-center">
<p className="text-sm text-muted-foreground">
<Link to="/account/integrations" className="text-accent hover:underline">
Map your PSA member
</Link>{' '}
to see your ticket queue.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center gap-2 px-5 py-4 text-sm text-danger">
@@ -345,13 +350,25 @@ export function TicketQueue() {
<TicketRow
key={ticket.id}
ticket={ticket}
isLast={i === tickets.length - 1 && !hasMore}
isLast={i === tickets.length - 1}
onStartSession={handleStartSession}
/>
))}
</>
)}
{/* View all tickets link */}
{tickets.length > 0 && (
<div className="px-5 py-3 border-t border-default">
<Link
to="/tickets?assigned=me"
className="text-xs text-accent hover:text-accent/80 transition-colors"
>
View all tickets
</Link>
</div>
)}
{/* Empty states */}
{!error && !loading && tickets.length === 0 && (
<div className="px-5 py-8 text-center">
@@ -369,28 +386,6 @@ export function TicketQueue() {
</div>
)}
{/* Load more */}
{!error && !loading && hasMore && (
<div
className="px-5 py-3"
style={{ borderTop: '1px solid var(--color-border-default)' }}
>
<button
onClick={handleLoadMore}
disabled={loadingMore}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-transparent py-2 text-xs text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.14)] disabled:opacity-50 transition-colors"
>
{loadingMore ? (
<>
<Loader2 size={12} className="animate-spin" />
Loading...
</>
) : (
'Load more'
)}
</button>
</div>
)}
</div>
</div>
)