diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index e957c82f..dbdc5c16 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -22,4 +22,4 @@ export { assistantChatApi } from './assistantChat'
export { flowTransferApi } from './flowTransfer'
export { kbAcceleratorApi } from './kbAccelerator'
export { scriptsApi } from './scripts'
-export { integrationsApi } from './integrations'
+export { integrationsApi, sessionPsaApi } from './integrations'
diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts
index f290979c..4e17029b 100644
--- a/frontend/src/api/integrations.ts
+++ b/frontend/src/api/integrations.ts
@@ -1,5 +1,6 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
+import type { TicketLinkResponse } from '@/types/integrations'
export const integrationsApi = {
getConnection: () =>
@@ -13,3 +14,8 @@ export const integrationsApi = {
testConnection: (id: string) =>
apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data),
}
+
+export const sessionPsaApi = {
+ linkTicket: (sessionId: string, psaTicketId: string | null) =>
+ apiClient.patch(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data),
+}
diff --git a/frontend/src/components/session/TicketLinkIndicator.tsx b/frontend/src/components/session/TicketLinkIndicator.tsx
new file mode 100644
index 00000000..862a587e
--- /dev/null
+++ b/frontend/src/components/session/TicketLinkIndicator.tsx
@@ -0,0 +1,74 @@
+import { Ticket, Unlink, Link2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import type { Session } from '@/types'
+import type { PSATicketInfo } from '@/types/integrations'
+
+interface Props {
+ session: Session
+ hasConnection: boolean
+ onLinkClick: () => void
+ onUnlink: () => void
+ ticketInfo?: PSATicketInfo | null
+}
+
+export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, ticketInfo }: Props) {
+ // No connection — show nothing
+ if (!hasConnection) return null
+
+ // No ticket linked — show subtle "Link Ticket" button
+ if (!session.psa_ticket_id) {
+ return (
+
+ )
+ }
+
+ // Ticket linked
+ return (
+
+
+
+
+ CW #{session.psa_ticket_id}
+ {ticketInfo?.summary && (
+ — {ticketInfo.summary}
+ )}
+
+ {ticketInfo && (
+
+ {ticketInfo.company_name && {ticketInfo.company_name}}
+ {ticketInfo.board_name && (
+ <>
+ •
+ {ticketInfo.board_name}
+ >
+ )}
+ {ticketInfo.status_name && (
+ <>
+ •
+ {ticketInfo.status_name}
+ >
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx
new file mode 100644
index 00000000..fc9f9c8e
--- /dev/null
+++ b/frontend/src/components/session/TicketPickerModal.tsx
@@ -0,0 +1,185 @@
+import { useState } from 'react'
+import { Ticket, Search, AlertCircle, CheckCircle2 } from 'lucide-react'
+import { Modal } from '@/components/common/Modal'
+import { Input } from '@/components/ui/Input'
+import { Button } from '@/components/ui/Button'
+import { cn } from '@/lib/utils'
+import { sessionPsaApi } from '@/api/integrations'
+import type { PSATicketInfo } from '@/types/integrations'
+
+interface Props {
+ open: boolean
+ onClose: () => void
+ sessionId: string
+ onLinked: (ticketId: string, ticket: PSATicketInfo) => void
+}
+
+export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) {
+ const [ticketId, setTicketId] = useState('')
+ const [isLooking, setIsLooking] = useState(false)
+ const [isLinking, setIsLinking] = useState(false)
+ const [ticketInfo, setTicketInfo] = useState(null)
+ const [error, setError] = useState(null)
+
+ const handleLookup = async () => {
+ const trimmed = ticketId.trim()
+ if (!trimmed) return
+
+ setIsLooking(true)
+ setError(null)
+ setTicketInfo(null)
+
+ try {
+ const result = await sessionPsaApi.linkTicket(sessionId, trimmed)
+ if (result.ticket) {
+ setTicketInfo(result.ticket)
+ } else {
+ setError('Ticket not found in ConnectWise')
+ }
+ } catch (err: unknown) {
+ const message =
+ err && typeof err === 'object' && 'response' in err
+ ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
+ : null
+ setError(message || 'Failed to look up ticket. Please check the ticket number and try again.')
+ } finally {
+ setIsLooking(false)
+ }
+ }
+
+ const handleLink = () => {
+ if (!ticketInfo) return
+ onLinked(ticketId.trim(), ticketInfo)
+ handleReset()
+ }
+
+ const handleReset = () => {
+ setTicketId('')
+ setTicketInfo(null)
+ setError(null)
+ setIsLooking(false)
+ setIsLinking(false)
+ }
+
+ const handleClose = () => {
+ handleReset()
+ onClose()
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && ticketId.trim() && !isLooking && !ticketInfo) {
+ handleLookup()
+ }
+ }
+
+ return (
+
+
+ {/* Ticket ID input */}
+
+
+
+ {
+ setTicketId(e.target.value)
+ if (ticketInfo) {
+ setTicketInfo(null)
+ }
+ if (error) {
+ setError(null)
+ }
+ }}
+ onKeyDown={handleKeyDown}
+ disabled={isLooking || isLinking}
+ className="flex-1"
+ />
+
+
+
+
+ {/* Error */}
+ {error && (
+
+ )}
+
+ {/* Ticket info card */}
+ {ticketInfo && (
+
+
+
+
+
+ CW #{ticketId.trim()} — {ticketInfo.summary}
+
+
+ {ticketInfo.company_name && (
+ {ticketInfo.company_name}
+ )}
+ {ticketInfo.board_name && (
+ <>
+ •
+ {ticketInfo.board_name}
+ >
+ )}
+ {ticketInfo.status_name && (
+ <>
+ •
+ {ticketInfo.status_name}
+ >
+ )}
+ {ticketInfo.priority_name && (
+ <>
+ •
+ {ticketInfo.priority_name}
+ >
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Skip link */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx
index 9184e177..90226fbd 100644
--- a/frontend/src/pages/ProceduralNavigationPage.tsx
+++ b/frontend/src/pages/ProceduralNavigationPage.tsx
@@ -24,6 +24,10 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
+import { integrationsApi, sessionPsaApi } from '@/api/integrations'
+import { TicketPickerModal } from '@/components/session/TicketPickerModal'
+import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
+import type { PSATicketInfo } from '@/types/integrations'
interface StepState {
notes: string
@@ -86,6 +90,11 @@ export function ProceduralNavigationPage() {
const [isSavingStep, setIsSavingStep] = useState(false)
const [copilotOpen, setCopilotOpen] = useState(false)
+ // PSA ticket link state
+ const [hasConnection, setHasConnection] = useState(false)
+ const [showTicketPicker, setShowTicketPicker] = useState(false)
+ const [psaTicketInfo, setPsaTicketInfo] = useState(null)
+
// Editable variables panel state
const [editingVarName, setEditingVarName] = useState(null)
const [editingVarValue, setEditingVarValue] = useState('')
@@ -131,6 +140,32 @@ export function ProceduralNavigationPage() {
}
}, [treeId])
+ // Check for PSA connection on mount
+ useEffect(() => {
+ integrationsApi.getConnection()
+ .then((conn) => setHasConnection(!!conn))
+ .catch(() => setHasConnection(false))
+ }, [])
+
+ const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
+ setPsaTicketInfo(ticket)
+ setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev)
+ setShowTicketPicker(false)
+ toast.success(`Linked to CW #${linkedTicketId}`)
+ }
+
+ const handleTicketUnlink = async () => {
+ if (!session) return
+ try {
+ await sessionPsaApi.linkTicket(session.id, null)
+ setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev)
+ setPsaTicketInfo(null)
+ toast.success('Ticket unlinked')
+ } catch {
+ toast.error('Failed to unlink ticket')
+ }
+ }
+
// Parse backend timestamp — ensure UTC if no timezone info
const parseTimestamp = (ts: string) => {
if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) {
@@ -584,6 +619,17 @@ export function ProceduralNavigationPage() {
Exit
+ {session && (
+
+ setShowTicketPicker(true)}
+ onUnlink={handleTicketUnlink}
+ ticketInfo={psaTicketInfo}
+ />
+
+ )}
setCopilotOpen(true)} />
)}
+
+ {/* Ticket Picker Modal */}
+ {session && (
+ setShowTicketPicker(false)}
+ sessionId={session.id}
+ onLinked={handleTicketLinked}
+ />
+ )}
)
}
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx
index ff389130..2511f0d2 100644
--- a/frontend/src/pages/TreeNavigationPage.tsx
+++ b/frontend/src/pages/TreeNavigationPage.tsx
@@ -22,6 +22,10 @@ import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sess
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { Button } from '@/components/ui/Button'
+import { integrationsApi, sessionPsaApi } from '@/api/integrations'
+import { TicketPickerModal } from '@/components/session/TicketPickerModal'
+import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
+import type { PSATicketInfo } from '@/types/integrations'
interface LocationState {
sessionId?: string
@@ -65,6 +69,11 @@ export function TreeNavigationPage() {
const sharePopoverRef = useRef(null)
const [copilotOpen, setCopilotOpen] = useState(false)
+ // PSA ticket link state
+ const [hasConnection, setHasConnection] = useState(false)
+ const [showTicketPicker, setShowTicketPicker] = useState(false)
+ const [ticketInfo, setTicketInfo] = useState(null)
+
const handleCopyCommand = (text: string) => {
navigator.clipboard.writeText(text)
setCopiedCommand(text)
@@ -272,6 +281,32 @@ export function TreeNavigationPage() {
}
}, [treeId])
+ // Check for PSA connection on mount
+ useEffect(() => {
+ integrationsApi.getConnection()
+ .then((conn) => setHasConnection(!!conn))
+ .catch(() => setHasConnection(false))
+ }, [])
+
+ const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
+ setTicketInfo(ticket)
+ setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev)
+ setShowTicketPicker(false)
+ toast.success(`Linked to CW #${linkedTicketId}`)
+ }
+
+ const handleTicketUnlink = async () => {
+ if (!session) return
+ try {
+ await sessionPsaApi.linkTicket(session.id, null)
+ setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev)
+ setTicketInfo(null)
+ toast.success('Ticket unlinked')
+ } catch {
+ toast.error('Failed to unlink ticket')
+ }
+ }
+
const loadTreeAndSession = async () => {
setIsLoading(true)
setError(null)
@@ -656,6 +691,15 @@ export function TreeNavigationPage() {
{clientName && `Client: ${clientName}`}
)}
+ {session && (
+ setShowTicketPicker(true)}
+ onUnlink={handleTicketUnlink}
+ ticketInfo={ticketInfo}
+ />
+ )}
{/* Share Progress Popover */}
@@ -1251,6 +1295,16 @@ export function TreeNavigationPage() {
onClose={() => setShowShareModal(false)}
/>
)}
+
+ {/* Ticket Picker Modal */}
+ {session && (
+ setShowTicketPicker(false)}
+ sessionId={session.id}
+ onLinked={handleTicketLinked}
+ />
+ )}
diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts
index 95b7c03b..ef5542da 100644
--- a/frontend/src/types/integrations.ts
+++ b/frontend/src/types/integrations.ts
@@ -37,3 +37,18 @@ export interface PsaConnectionTestResponse {
message: string
server_version: string | null
}
+
+export interface PSATicketInfo {
+ id: string
+ summary: string
+ company_name: string | null
+ board_name: string | null
+ status_name: string | null
+ priority_name: string | null
+}
+
+export interface TicketLinkResponse {
+ session_id: string
+ psa_ticket_id: string | null
+ ticket: PSATicketInfo | null
+}
diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts
index 8089d781..9f87b191 100644
--- a/frontend/src/types/session.ts
+++ b/frontend/src/types/session.ts
@@ -64,6 +64,8 @@ export interface Session {
assigned_to_id?: string | null
batch_id?: string
target_label?: string
+ psa_ticket_id?: string | null
+ psa_connection_id?: string | null
}
export interface SessionCreate {