From c24e84d8a0ad6aa3f926d30517ca1c3eb24eedb6 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:06:31 -0500 Subject: [PATCH 01/14] feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/sessions.ts | 22 ++++++++++++++++- frontend/src/lib/sessionShare.ts | 27 ++++++++++++++++++++ frontend/src/types/session.ts | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/sessionShare.ts diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 43691ef1..635d593a 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,5 +1,5 @@ import apiClient from './client' -import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary } from '@/types' +import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView } from '@/types' export interface SessionListParams { page?: number @@ -85,6 +85,26 @@ export const sessionsApi = { const response = await apiClient.post(`/sessions/${id}/save-as-tree`, data) return response.data }, + + // Session Sharing + async createShare(sessionId: string, data: SessionShareCreate): Promise { + const response = await apiClient.post(`/sessions/${sessionId}/shares`, data) + return response.data + }, + + async listMyShares(): Promise { + const response = await apiClient.get('/shares/my-shares') + return response.data + }, + + async revokeShare(shareId: string): Promise { + await apiClient.delete(`/shares/${shareId}`) + }, + + async getSharedSession(shareToken: string): Promise { + const response = await apiClient.get(`/share/${shareToken}`) + return response.data + }, } export default sessionsApi diff --git a/frontend/src/lib/sessionShare.ts b/frontend/src/lib/sessionShare.ts new file mode 100644 index 00000000..040564c6 --- /dev/null +++ b/frontend/src/lib/sessionShare.ts @@ -0,0 +1,27 @@ +import type { SessionShare } from '@/types' + +/** + * Build the full share URL from a SessionShare object. + * Uses share.share_url if present, otherwise constructs from token. + */ +export function buildSessionShareUrl(share: SessionShare): string { + if (share.share_url) return share.share_url + return `${window.location.origin}/share/${share.share_token}` +} + +/** + * Filter shares to only those belonging to a specific session. + */ +export function filterSharesForSession(shares: SessionShare[], sessionId: string): SessionShare[] { + return shares.filter(s => s.session_id === sessionId && s.is_active) +} + +/** + * Get the most recent active share for a given session. + * Returns null if no active shares exist. + */ +export function getLatestActiveShareForSession(shares: SessionShare[], sessionId: string): SessionShare | null { + const sessionShares = filterSharesForSession(shares, sessionId) + if (sessionShares.length === 0) return null + return sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0] +} diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index d262b3d7..193e1c52 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -125,3 +125,45 @@ export interface SaveAsTreeResponse { tree_name: string message: string } + +// Session Sharing +export type SessionShareVisibility = 'public' | 'account' + +export interface SessionShareCreate { + visibility: SessionShareVisibility + share_name?: string + expires_at?: string // ISO datetime string +} + +export interface SessionShare { + id: string + session_id: string + account_id: string + share_token: string + share_name: string | null + visibility: SessionShareVisibility + created_by: string + created_at: string + updated_at: string + expires_at: string | null + view_count: number + last_viewed_at: string | null + is_active: boolean + share_url: string | null +} + +export interface SharedSessionView { + session_id: string + tree_name: string + tree_description: string | null + tree_structure: Record + path_taken: string[] + decisions: DecisionRecord[] + custom_steps: CustomStep[] + started_at: string + completed_at: string | null + ticket_number: string | null + client_name: string | null + share_name: string | null + visibility: SessionShareVisibility +} -- 2.49.1 From d1b849a5f877172b14d9b2697d649e067a86f9a8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:08:53 -0500 Subject: [PATCH 02/14] feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/common/ActionMenu.tsx | 100 +++++++++ .../components/session/SessionTimeline.tsx | 207 ++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 frontend/src/components/common/ActionMenu.tsx create mode 100644 frontend/src/components/session/SessionTimeline.tsx diff --git a/frontend/src/components/common/ActionMenu.tsx b/frontend/src/components/common/ActionMenu.tsx new file mode 100644 index 00000000..f7f5a20d --- /dev/null +++ b/frontend/src/components/common/ActionMenu.tsx @@ -0,0 +1,100 @@ +import { useState, useRef, useEffect } from 'react' +import { MoreVertical } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface MenuAction { + label: string + icon?: React.ComponentType<{ className?: string }> + onClick: () => void + disabled?: boolean + variant?: 'default' | 'destructive' +} + +interface ActionMenuProps { + actions: MenuAction[] + align?: 'left' | 'right' // default 'right' +} + +export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) { + const [isOpen, setIsOpen] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const handleItemClick = (action: MenuAction) => { + if (action.disabled) return + action.onClick() + setIsOpen(false) + } + + return ( +
+ + + {isOpen && ( +
+ {actions.map((action, index) => { + const Icon = action.icon + return ( + + ) + })} +
+ )} +
+ ) +} + +export type { MenuAction, ActionMenuProps } + +export default ActionMenu diff --git a/frontend/src/components/session/SessionTimeline.tsx b/frontend/src/components/session/SessionTimeline.tsx new file mode 100644 index 00000000..90811010 --- /dev/null +++ b/frontend/src/components/session/SessionTimeline.tsx @@ -0,0 +1,207 @@ +import { useState } from 'react' +import { Copy, Check } from 'lucide-react' +import type { DecisionRecord } from '@/types' +import { cn } from '@/lib/utils' + +interface SessionTimelineProps { + decisions: DecisionRecord[] + treeType?: string // 'procedural' or 'troubleshooting' (default) + startedAt: string + completedAt: string | null + showCopyButtons?: boolean // default true +} + +function formatDate(dateString: string) { + return new Date(dateString).toLocaleString() +} + +function formatDuration(durationSeconds: number | null | undefined) { + if (durationSeconds == null || durationSeconds < 0) return null + if (durationSeconds < 60) return `${durationSeconds}s` + const hours = Math.floor(durationSeconds / 3600) + const minutes = Math.floor((durationSeconds % 3600) / 60) + const seconds = durationSeconds % 60 + if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m` + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m` +} + +export function SessionTimeline({ + decisions, + treeType, + startedAt, + completedAt, + showCopyButtons = true, +}: SessionTimelineProps) { + const [copiedStepIndex, setCopiedStepIndex] = useState(null) + + const handleCopyStep = async (decision: DecisionRecord, index: number) => { + const lines: string[] = [] + if (decision.question) lines.push(`Question: ${decision.question}`) + if (decision.answer) lines.push(`Answer: ${decision.answer}`) + if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`) + if (decision.notes) lines.push(`Notes: ${decision.notes}`) + if (decision.command_output) lines.push(`Output:\n${decision.command_output}`) + try { + await navigator.clipboard.writeText(lines.join('\n')) + setCopiedStepIndex(index) + setTimeout(() => setCopiedStepIndex(null), 2000) + } catch { + // Clipboard access denied + } + } + + if (treeType === 'procedural') { + return ( +
+

Procedure Steps

+
+ {decisions.map((decision, index) => { + const isCompleted = decision.answer === 'completed' + return ( +
+
+ + {isCompleted ? '\u2713' : index + 1} + +
+

{decision.question || 'Step'}

+ {decision.notes && ( +

+ Notes: {decision.notes} +

+ )} + {decision.command_output && ( +

+ Verification: {decision.command_output} +

+ )} + {decision.duration_seconds != null && ( +

+ Duration: {formatDuration(decision.duration_seconds)} +

+ )} +
+ {showCopyButtons && ( + + )} +
+
+ ) + })} + {completedAt && ( +
+ + + Procedure completed: {formatDate(completedAt)} + +
+ )} +
+
+ ) + } + + // Default: troubleshooting decision timeline + return ( +
+

Decision Timeline

+
+
+ + + Session started: {formatDate(startedAt)} + +
+ + {decisions.map((decision, index) => ( +
+
+ +
+
+
+ {decision.question && ( +

{decision.question}

+ )} + {decision.answer && ( +

Answer: {decision.answer}

+ )} + {decision.action_performed && ( +

+ Action: {decision.action_performed} +

+ )} + {decision.notes && ( +

+ Notes: {decision.notes} +

+ )} + {decision.command_output && ( +
+

Command Output

+
+                          {decision.command_output}
+                        
+
+ )} + {decision.duration_seconds != null && ( +

+ Duration: {formatDuration(decision.duration_seconds)} +

+ )} +

+ {formatDate(decision.timestamp)} +

+
+ {showCopyButtons && ( + + )} +
+
+
+
+ ))} + + {completedAt && ( +
+ + + Session completed: {formatDate(completedAt)} + +
+ )} +
+
+ ) +} + +export default SessionTimeline -- 2.49.1 From 7eb77dd782aeef6b9f585348021ef79397276666 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:13:32 -0500 Subject: [PATCH 03/14] feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 --- .../components/session/ShareSessionModal.tsx | 431 ++++++++++++++++++ frontend/src/pages/SessionDetailPage.tsx | 215 ++------- 2 files changed, 465 insertions(+), 181 deletions(-) create mode 100644 frontend/src/components/session/ShareSessionModal.tsx diff --git a/frontend/src/components/session/ShareSessionModal.tsx b/frontend/src/components/session/ShareSessionModal.tsx new file mode 100644 index 00000000..89f9501e --- /dev/null +++ b/frontend/src/components/session/ShareSessionModal.tsx @@ -0,0 +1,431 @@ +import { useState, useEffect } from 'react' +import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react' +import type { SessionShare, SessionShareVisibility } from '@/types' +import { sessionsApi } from '@/api/sessions' +import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface ShareSessionModalProps { + sessionId: string + sessionLabel: string // e.g. ticket number or "Session Details" + isOpen: boolean + onClose: () => void +} + +type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom' + +function getRelativeTime(dateString: string): string { + const now = Date.now() + const date = new Date(dateString).getTime() + const diffMs = now - date + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return 'just now' + if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago` + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago` + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago` + const diffMonths = Math.floor(diffDays / 30) + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago` +} + +function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } { + if (!expiresAt) return { text: 'No expiration', isExpired: false } + const now = Date.now() + const expiry = new Date(expiresAt).getTime() + if (expiry <= now) return { text: 'Expired', isExpired: true } + + const diffMs = expiry - now + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false } + if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false } + return { text: 'Expires soon', isExpired: false } +} + +function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined { + if (preset === 'never') return undefined + if (preset === 'custom') { + if (!customDatetime) return undefined + return new Date(customDatetime).toISOString() + } + + const now = new Date() + switch (preset) { + case '1day': + now.setDate(now.getDate() + 1) + break + case '7days': + now.setDate(now.getDate() + 7) + break + case '30days': + now.setDate(now.getDate() + 30) + break + } + return now.toISOString() +} + +export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) { + const [shares, setShares] = useState([]) + const [isLoadingShares, setIsLoadingShares] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const [copiedShareId, setCopiedShareId] = useState(null) + + // Form state + const [visibility, setVisibility] = useState('account') + const [shareName, setShareName] = useState('') + const [expirationPreset, setExpirationPreset] = useState('never') + const [customDatetime, setCustomDatetime] = useState('') + const [visibilityError, setVisibilityError] = useState(null) + + useEffect(() => { + if (isOpen) { + loadShares() + // Reset form state + setVisibility('account') + setShareName('') + setExpirationPreset('never') + setCustomDatetime('') + setVisibilityError(null) + setCopiedShareId(null) + } + }, [isOpen, sessionId]) + + const loadShares = async () => { + setIsLoadingShares(true) + try { + const allShares = await sessionsApi.listMyShares() + const sessionShares = filterSharesForSession(allShares, sessionId) + // Sort newest first + sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + setShares(sessionShares) + } catch (err) { + console.error('Failed to load shares:', err) + } finally { + setIsLoadingShares(false) + } + } + + const handleGenerateLink = async () => { + setIsGenerating(true) + setVisibilityError(null) + try { + const expires_at = computeExpiresAt(expirationPreset, customDatetime) + const newShare = await sessionsApi.createShare(sessionId, { + visibility, + share_name: shareName.trim() || undefined, + expires_at, + }) + setShares([newShare, ...shares]) + toast.success('Share link generated') + // Reset form + setShareName('') + setExpirationPreset('never') + setCustomDatetime('') + } catch (err: unknown) { + const error = err as { response?: { status?: number; data?: { detail?: string } } } + if ( + error.response?.status === 403 && + error.response?.data?.detail?.toLowerCase().includes('public session sharing') + ) { + setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing') + } else { + console.error('Failed to generate share link:', err) + toast.error('Failed to generate share link') + } + } finally { + setIsGenerating(false) + } + } + + const handleCopyUrl = async (share: SessionShare) => { + try { + const url = buildSessionShareUrl(share) + await navigator.clipboard.writeText(url) + setCopiedShareId(share.id) + toast.success('Link copied to clipboard') + setTimeout(() => setCopiedShareId(null), 2000) + } catch (err) { + console.error('Failed to copy link:', err) + toast.error('Failed to copy link') + } + } + + const handleRevoke = async (shareId: string) => { + try { + await sessionsApi.revokeShare(shareId) + setShares(shares.filter((s) => s.id !== shareId)) + toast.success('Share link revoked') + } catch (err) { + console.error('Failed to revoke share:', err) + toast.error('Failed to revoke share') + } + } + + if (!isOpen) return null + + const presetButtons: { value: ExpirationPreset; label: string }[] = [ + { value: 'never', label: 'Never' }, + { value: '1day', label: '1 day' }, + { value: '7days', label: '7 days' }, + { value: '30days', label: '30 days' }, + { value: 'custom', label: 'Custom' }, + ] + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

Share Session

+

{sessionLabel}

+
+ +
+ + {/* Body */} +
+ {/* Create Share Form */} +
+ {/* Visibility */} +
+ +
+ + +
+ {visibilityError && ( +

{visibilityError}

+ )} +
+ + {/* Share Name */} +
+ + setShareName(e.target.value.slice(0, 100))} + placeholder="e.g. Training link, Customer escalation" + className={cn( + 'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30', + 'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20' + )} + maxLength={100} + /> +
+ + {/* Expiration */} +
+ +
+ {presetButtons.map((preset) => ( + + ))} +
+ {expirationPreset === 'custom' && ( + setCustomDatetime(e.target.value)} + className={cn( + 'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', + 'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20', + '[color-scheme:dark]' + )} + /> + )} +
+ + {/* Generate Button */} + +
+ + {/* Existing Shares */} + {shares.length > 0 && ( +
+

+ Active Shares ({shares.length}) +

+
+ {shares.map((share) => { + const expiration = getExpirationLabel(share.expires_at) + const isCopied = copiedShareId === share.id + return ( +
+
+
+
+ + {share.visibility === 'public' ? ( + + ) : ( + + )} + {share.visibility === 'public' ? 'Public' : 'Account'} + + + {share.share_name || 'Untitled share'} + +
+
+ {getRelativeTime(share.created_at)} + + {share.view_count > 0 + ? `${share.view_count} view${share.view_count === 1 ? '' : 's'}` + : 'Not viewed yet'} + + + + {expiration.text} + +
+
+
+ + +
+
+
+ ) + })} +
+
+ )} + + {/* Loading state */} + {isLoadingShares && shares.length === 0 && ( +
+
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + +export default ShareSessionModal diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index 6adc2f5b..0f1cd966 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -1,11 +1,15 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { Copy, Check, Eye, Save } from 'lucide-react' +import { Copy, Check, Eye, Save, Share2 } from 'lucide-react' import { sessionsApi } from '@/api/sessions' import { stepsApi } from '@/api/steps' import { ExportPreviewModal } from '@/components/session/ExportPreviewModal' import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal' +import { ShareSessionModal } from '@/components/session/ShareSessionModal' +import { SessionTimeline } from '@/components/session/SessionTimeline' import { StepRatingModal } from '@/components/session/StepRatingModal' +import { ActionMenu } from '@/components/common/ActionMenu' +import type { MenuAction } from '@/components/common/ActionMenu' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' @@ -30,7 +34,7 @@ export function SessionDetailPage() { const [showRatingModal, setShowRatingModal] = useState(false) const [isSavingRatings, setIsSavingRatings] = useState(false) const [librarySteps, setLibrarySteps] = useState([]) - const [copiedStepIndex, setCopiedStepIndex] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) const [maxStepIndex, setMaxStepIndex] = useState(null) const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard') const [includeSummary, setIncludeSummary] = useState(false) @@ -257,26 +261,6 @@ export function SessionDetailPage() { } } - const handleCopyStep = async (decision: Session['decisions'][number], index: number) => { - const lines: string[] = [] - if (decision.question) lines.push(`Question: ${decision.question}`) - if (decision.answer) lines.push(`Answer: ${decision.answer}`) - if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`) - if (decision.notes) lines.push(`Notes: ${decision.notes}`) - if (decision.command_output) lines.push(`Output:\n${decision.command_output}`) - try { - await navigator.clipboard.writeText(lines.join('\n')) - setCopiedStepIndex(index) - setTimeout(() => setCopiedStepIndex(null), 2000) - } catch { - // Clipboard access denied - } - } - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString() - } - const formatDuration = (durationSeconds: number | null | undefined) => { if (durationSeconds == null || durationSeconds < 0) return null if (durationSeconds < 60) return `${durationSeconds}s` @@ -381,20 +365,20 @@ export function SessionDetailPage() { {/* Actions */}
- {/* Save as Tree - Only for completed sessions */} - {session.completed_at && ( - - )} + setShowShareModal(true), + }, + ...(session.completed_at ? [{ + label: 'Save as Tree', + icon: Save, + onClick: () => setShowSaveAsTreeModal(true), + }] as MenuAction[] : []), + ]} + /> {/* Copy for Ticket */} -
-
- ) - })} - {session.completed_at && ( -
- - - Procedure completed: {formatDate(session.completed_at)} - -
- )} -
- - ) : ( - <> -

Decision Timeline

-
-
- - - Session started: {formatDate(session.started_at)} - -
- - {session.decisions.map((decision, index) => ( -
-
- -
-
-
- {decision.question && ( -

{decision.question}

- )} - {decision.answer && ( -

Answer: {decision.answer}

- )} - {decision.action_performed && ( -

- Action: {decision.action_performed} -

- )} - {decision.notes && ( -

- Notes: {decision.notes} -

- )} - {decision.command_output && ( -
-

Command Output

-
-                                {decision.command_output}
-                              
-
- )} - {decision.duration_seconds != null && ( -

- Duration: {formatDuration(decision.duration_seconds)} -

- )} -

- {formatDate(decision.timestamp)} -

-
- -
-
-
-
- ))} - - {session.completed_at && ( -
- - - Session completed: {formatDate(session.completed_at)} - -
- )} -
- - )} - + ).tree_type as string} + startedAt={session.started_at} + completedAt={session.completed_at} + /> {/* Export Preview Modal */} + + {/* Share Session Modal */} + setShowShareModal(false)} + /> ) } -- 2.49.1 From 91ba4fccdf934d8c280a3d25493701940d8d6fa6 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:15:59 -0500 Subject: [PATCH 04/14] feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/TreeNavigationPage.tsx | 144 ++++++++++++++++++++-- 1 file changed, 131 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 3a11fb20..e8a1c0f6 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' @@ -10,9 +10,11 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session' -import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle } from 'lucide-react' +import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react' import { toast } from '@/lib/toast' import { Modal } from '@/components/common/Modal' +import { ShareSessionModal } from '@/components/session/ShareSessionModal' +import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare' interface LocationState { sessionId?: string @@ -48,6 +50,11 @@ export function TreeNavigationPage() { const [selectingOption, setSelectingOption] = useState(null) const [copiedForTicket, setCopiedForTicket] = useState(false) const [isCopyingForTicket, setIsCopyingForTicket] = useState(false) + const [showSharePopover, setShowSharePopover] = useState(false) + const [showShareModal, setShowShareModal] = useState(false) + const [copiedShareLink, setCopiedShareLink] = useState(false) + const [isCopyingShareLink, setIsCopyingShareLink] = useState(false) + const sharePopoverRef = useRef(null) const handleCopyCommand = (text: string) => { navigator.clipboard.writeText(text) @@ -78,6 +85,55 @@ export function TreeNavigationPage() { } } + const handleCopyShareLink = async () => { + if (!session || isCopyingShareLink) return + setIsCopyingShareLink(true) + try { + const allShares = await sessionsApi.listMyShares() + const existingShare = getLatestActiveShareForSession(allShares, session.id) + let shareUrl: string + if (existingShare) { + shareUrl = buildSessionShareUrl(existingShare) + } else { + const newShare = await sessionsApi.createShare(session.id, { visibility: 'account' }) + shareUrl = buildSessionShareUrl(newShare) + } + await navigator.clipboard.writeText(shareUrl) + setCopiedShareLink(true) + toast.success('Share link copied to clipboard') + setTimeout(() => setCopiedShareLink(false), 2000) + } catch (err) { + console.error('Copy share link failed:', err) + toast.error('Failed to copy share link') + } finally { + setIsCopyingShareLink(false) + } + } + + // Close share popover on outside click + useEffect(() => { + if (!showSharePopover) return + const handleMouseDown = (e: MouseEvent) => { + if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) { + setShowSharePopover(false) + } + } + document.addEventListener('mousedown', handleMouseDown) + return () => document.removeEventListener('mousedown', handleMouseDown) + }, [showSharePopover]) + + // Close share popover on Escape key + useEffect(() => { + if (!showSharePopover) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setShowSharePopover(false) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [showSharePopover]) + // Session metadata (prefill from Repeat Last Session) const [ticketNumber, setTicketNumber] = useState(locationState?.prefillTicketNumber || '') const [clientName, setClientName] = useState(locationState?.prefillClientName || '') @@ -576,18 +632,70 @@ export function TreeNavigationPage() { )}
- + {showSharePopover && ( +
+ {/* Copy Progress Summary */} + + {/* Copy Share Link */} + + {/* Divider */} +
+ {/* Manage Share Links */} + +
)} - > - {copiedForTicket ? : } - {copiedForTicket ? 'Copied!' : 'Copy for Ticket'} - +
+ + {/* Share Session Modal */} + {session && ( + setShowShareModal(false)} + /> + )} -- 2.49.1 From d7641f2f84dc9e5e56371a58d6aa98bd6c97b30c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:20:28 -0500 Subject: [PATCH 05/14] feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 --- .../session/SharedSessionTreePreview.tsx | 88 ++++++ frontend/src/pages/SharedSessionPage.tsx | 263 ++++++++++++++++++ frontend/src/router.tsx | 12 + 3 files changed, 363 insertions(+) create mode 100644 frontend/src/components/session/SharedSessionTreePreview.tsx create mode 100644 frontend/src/pages/SharedSessionPage.tsx diff --git a/frontend/src/components/session/SharedSessionTreePreview.tsx b/frontend/src/components/session/SharedSessionTreePreview.tsx new file mode 100644 index 00000000..0f6a3123 --- /dev/null +++ b/frontend/src/components/session/SharedSessionTreePreview.tsx @@ -0,0 +1,88 @@ +import { cn } from '@/lib/utils' + +interface SharedSessionTreePreviewProps { + treeStructure: Record + pathTaken: string[] +} + +const nodeTypeColors: Record = { + root: 'bg-white', + decision: 'bg-blue-400', + action: 'bg-yellow-400', + solution: 'bg-emerald-400', + information: 'bg-white/50', +} + +function getNodeTitle(node: Record): string { + return ( + (node.question as string) || + (node.title as string) || + (node.node_type as string) || + 'Untitled' + ) +} + +function TreeNode({ + node, + depth, + pathTaken, +}: { + node: Record + depth: number + pathTaken: string[] +}) { + const nodeId = (node.id as string) || '' + const nodeType = (node.node_type as string) || 'decision' + const isInPath = pathTaken.includes(nodeId) + const children = (node.children as Record[]) || [] + const colorClass = nodeTypeColors[nodeType] || 'bg-white/50' + + return ( + <> +
+ + {getNodeTitle(node)} +
+ {children.map((child, index) => ( + + ))} + + ) +} + +export function SharedSessionTreePreview({ + treeStructure, + pathTaken, +}: SharedSessionTreePreviewProps) { + if (!treeStructure) { + return null + } + + return ( +
+
+

Tree Structure

+
+
+ +
+
+ ) +} + +export default SharedSessionTreePreview diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx new file mode 100644 index 00000000..252d94e2 --- /dev/null +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -0,0 +1,263 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react' +import { isAxiosError } from 'axios' +import { sessionsApi } from '@/api/sessions' +import { BrandLogo } from '@/components/common/BrandLogo' +import { SessionTimeline } from '@/components/session/SessionTimeline' +import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview' +import type { SharedSessionView } from '@/types' + +function formatDate(dateString: string) { + return new Date(dateString).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +function formatDuration(startedAt: string, completedAt: string): string { + const start = new Date(startedAt).getTime() + const end = new Date(completedAt).getTime() + const totalSeconds = Math.floor((end - start) / 1000) + if (totalSeconds < 0) return '0s' + if (totalSeconds < 60) return `${totalSeconds}s` + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m` + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m` +} + +type ErrorState = { + type: 'access_denied' | 'not_found' | 'expired' | 'generic' + message: string +} + +function ErrorCard({ error }: { error: ErrorState }) { + const iconMap = { + access_denied: ShieldAlert, + not_found: FileX, + expired: Clock, + generic: FileX, + } + const titleMap = { + access_denied: 'Access Denied', + not_found: 'Not Found', + expired: 'Link Expired', + generic: 'Error', + } + + const Icon = iconMap[error.type] + + return ( +
+
+
+ +
+

{titleMap[error.type]}

+

{error.message}

+ + Go to ResolutionFlow + +
+
+ ) +} + +export function SharedSessionPage() { + const { shareToken } = useParams<{ shareToken: string }>() + const navigate = useNavigate() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!shareToken) return + + let cancelled = false + + async function fetchSharedSession() { + try { + const result = await sessionsApi.getSharedSession(shareToken!) + if (!cancelled) { + setData(result) + setLoading(false) + } + } catch (err) { + if (cancelled) return + + if (isAxiosError(err)) { + const status = err.response?.status + if (status === 401) { + navigate('/login', { + state: { from: `/share/${shareToken}` }, + replace: true, + }) + return + } + if (status === 403) { + setError({ + type: 'access_denied', + message: + 'This session is private to the account. You need to be a member of the account to view it.', + }) + } else if (status === 404) { + setError({ + type: 'not_found', + message: 'This share link was not found or has been revoked.', + }) + } else if (status === 410) { + setError({ + type: 'expired', + message: 'This share link has expired.', + }) + } else { + setError({ + type: 'generic', + message: 'Failed to load shared session. Please try again.', + }) + } + } else { + setError({ + type: 'generic', + message: 'Failed to load shared session. Please try again.', + }) + } + setLoading(false) + } + } + + fetchSharedSession() + return () => { + cancelled = true + } + }, [shareToken, navigate]) + + if (loading) { + return ( +
+
+ +

Loading shared session...

+
+
+ ) + } + + if (error) { + return + } + + if (!data) { + return null + } + + return ( +
+ {/* Minimal header */} +
+
+ + + ResolutionFlow + + + Sign In + +
+
+ + {/* Content */} +
+ {/* Metadata section */} +
+ {data.share_name && ( +

{data.share_name}

+ )} +

+ Tree: {data.tree_name} +

+ + {(data.ticket_number || data.client_name) && ( +

+ {data.ticket_number && ( + Ticket: #{data.ticket_number} + )} + {data.ticket_number && data.client_name && ( + · + )} + {data.client_name && ( + Client: {data.client_name} + )} +

+ )} + +
+ Started: {formatDate(data.started_at)} + {data.completed_at && ( + <> + Completed: {formatDate(data.completed_at)} + Duration: {formatDuration(data.started_at, data.completed_at)} + + )} + + {data.visibility === 'public' ? ( + <> + + Public + + ) : ( + <> + + Account + + )} + +
+
+ + {/* Two-column layout */} +
+ {/* Decision Timeline (2 cols) */} +
+ +
+ + {/* Tree Preview (1 col) */} +
+ +
+
+
+ + {/* Footer */} +
+ Powered by{' '} + + ResolutionFlow + +
+
+ ) +} + +export default SharedSessionPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 607bbec6..f8bbce1a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,6 +8,9 @@ import { RegisterPage, } from '@/pages' +// Public pages +const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage')) + // Standalone auth pages const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage')) const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')) @@ -69,6 +72,15 @@ export const router = createBrowserRouter([ ), errorElement: , }, + { + path: '/share/:shareToken', + element: ( + }> + + + ), + errorElement: , + }, { path: '/change-password', element: ( -- 2.49.1 From d9734a11bf5027a57cdeb914af834a9980115b91 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:23:27 -0500 Subject: [PATCH 06/14] feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/layout/AppLayout.tsx | 1 + frontend/src/pages/MySharesPage.tsx | 240 +++++++++++++++++++ frontend/src/router.tsx | 9 + 3 files changed, 250 insertions(+) create mode 100644 frontend/src/pages/MySharesPage.tsx diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5240506c..8ee7639f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -84,6 +84,7 @@ export function AppLayout() { }, { path: '/my-trees', label: 'My Flows' }, { path: '/sessions', label: 'Sessions' }, + { path: '/shares', label: 'My Shares' }, { path: '/account', label: 'Account' }, ...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []), ] diff --git a/frontend/src/pages/MySharesPage.tsx b/frontend/src/pages/MySharesPage.tsx new file mode 100644 index 00000000..31c34c57 --- /dev/null +++ b/frontend/src/pages/MySharesPage.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { sessionsApi } from '@/api/sessions' +import { buildSessionShareUrl } from '@/lib/sessionShare' +import type { SessionShare } from '@/types' + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMinutes = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMinutes < 1) return 'just now' + if (diffHours < 1) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago` + if (diffDays < 1) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago` + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago` + const diffMonths = Math.floor(diffDays / 30) + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago` +} + +function formatExpiration(expiresAt: string | null): { text: string; isExpired: boolean } { + if (!expiresAt) return { text: 'No expiration', isExpired: false } + + const expiry = new Date(expiresAt) + const now = new Date() + const diffMs = expiry.getTime() - now.getTime() + + if (diffMs <= 0) return { text: 'Expired', isExpired: true } + + const diffMinutes = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffHours < 1) return { text: `Expires in ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}`, isExpired: false } + if (diffDays < 1) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false } + return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false } +} + +export default function MySharesPage() { + const navigate = useNavigate() + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [copiedId, setCopiedId] = useState(null) + + const fetchShares = useCallback(async () => { + try { + setLoading(true) + setError(null) + const data = await sessionsApi.listMyShares() + setShares(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load shares') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchShares() + }, [fetchShares]) + + const handleCopyLink = async (share: SessionShare) => { + try { + const url = buildSessionShareUrl(share) + await navigator.clipboard.writeText(url) + setCopiedId(share.id) + toast.success('Link copied') + setTimeout(() => setCopiedId(null), 2000) + } catch { + toast.error('Failed to copy link') + } + } + + const handleRevoke = async (share: SessionShare) => { + const confirmed = window.confirm( + 'Revoke this share link? Anyone with the link will no longer be able to access the session.' + ) + if (!confirmed) return + + try { + await sessionsApi.revokeShare(share.id) + setShares((prev) => prev.filter((s) => s.id !== share.id)) + toast.success('Share link revoked') + } catch { + toast.error('Failed to revoke share link') + } + } + + // Loading state + if (loading) { + return ( +
+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+
+

{error}

+ +
+
+
+ ) + } + + return ( +
+ {/* Back link */} + + + Back to sessions + + + {/* Header */} +
+

My Shared Sessions

+

Manage your session share links

+
+ + {/* Empty state */} + {shares.length === 0 ? ( +
+ +

No shared sessions

+

+ Share a session from the session detail page to create a link +

+ +
+ ) : ( +
+ {shares.map((share) => { + const expiration = formatExpiration(share.expires_at) + const isCopied = copiedId === share.id + + return ( +
+ {/* Top row: badge + name */} +
+ + {share.visibility === 'public' ? ( + + ) : ( + + )} + {share.visibility === 'public' ? 'Public' : 'Account Only'} + + + {share.share_name || 'Untitled share'} + +
+ + {/* Session info */} +

+ Session ID: {share.session_id.slice(0, 8)}... +

+ + {/* Meta row */} +
+ Created {formatRelativeTime(share.created_at)} + · + + {share.view_count > 0 + ? `${share.view_count} view${share.view_count === 1 ? '' : 's'}` + : 'Not viewed yet'} + + · + + {expiration.text} + +
+ + {/* Actions */} +
+ + + + + View Session + + + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f8bbce1a..9d6b3232 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -26,6 +26,7 @@ const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage')) const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage')) const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage')) const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage')) +const MySharesPage = lazy(() => import('@/pages/MySharesPage')) const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage')) // Admin pages const AdminLayout = lazy(() => import('@/components/admin/AdminLayout')) @@ -189,6 +190,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'shares', + element: ( + }> + + + ), + }, // Admin routes { path: 'admin', -- 2.49.1 From b97a283ea3835329de078e59c8ccfa9d3504ad06 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:28:43 -0500 Subject: [PATCH 07/14] fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 --- .../components/session/ShareSessionModal.tsx | 32 +++++++++---------- .../session/SharedSessionTreePreview.tsx | 3 +- frontend/src/pages/SharedSessionPage.tsx | 4 +-- frontend/src/types/session.ts | 2 +- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/session/ShareSessionModal.tsx b/frontend/src/components/session/ShareSessionModal.tsx index 89f9501e..0adf3298 100644 --- a/frontend/src/components/session/ShareSessionModal.tsx +++ b/frontend/src/components/session/ShareSessionModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react' import type { SessionShare, SessionShareVisibility } from '@/types' import { sessionsApi } from '@/api/sessions' @@ -82,20 +82,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: const [customDatetime, setCustomDatetime] = useState('') const [visibilityError, setVisibilityError] = useState(null) - useEffect(() => { - if (isOpen) { - loadShares() - // Reset form state - setVisibility('account') - setShareName('') - setExpirationPreset('never') - setCustomDatetime('') - setVisibilityError(null) - setCopiedShareId(null) - } - }, [isOpen, sessionId]) - - const loadShares = async () => { + const loadShares = useCallback(async () => { setIsLoadingShares(true) try { const allShares = await sessionsApi.listMyShares() @@ -108,7 +95,20 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: } finally { setIsLoadingShares(false) } - } + }, [sessionId]) + + useEffect(() => { + if (isOpen) { + loadShares() + // Reset form state + setVisibility('account') + setShareName('') + setExpirationPreset('never') + setCustomDatetime('') + setVisibilityError(null) + setCopiedShareId(null) + } + }, [isOpen, sessionId, loadShares]) const handleGenerateLink = async () => { setIsGenerating(true) diff --git a/frontend/src/components/session/SharedSessionTreePreview.tsx b/frontend/src/components/session/SharedSessionTreePreview.tsx index 0f6a3123..1bbb0dbc 100644 --- a/frontend/src/components/session/SharedSessionTreePreview.tsx +++ b/frontend/src/components/session/SharedSessionTreePreview.tsx @@ -1,7 +1,8 @@ +import type { TreeStructure } from '@/types' import { cn } from '@/lib/utils' interface SharedSessionTreePreviewProps { - treeStructure: Record + treeStructure: TreeStructure pathTaken: string[] } diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx index 252d94e2..1d81187b 100644 --- a/frontend/src/pages/SharedSessionPage.tsx +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -97,7 +97,7 @@ export function SharedSessionPage() { const status = err.response?.status if (status === 401) { navigate('/login', { - state: { from: `/share/${shareToken}` }, + state: { from: { pathname: `/share/${shareToken}` } }, replace: true, }) return @@ -232,7 +232,7 @@ export function SharedSessionPage() {
+ tree_structure: TreeStructure path_taken: string[] decisions: DecisionRecord[] custom_steps: CustomStep[] -- 2.49.1 From cf74c868c0f94beed3a7f4f45145921aa5374499 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 18:39:47 -0500 Subject: [PATCH 08/14] test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/sessions.test.ts | 72 +++++++++++++++++++++++ frontend/src/lib/sessionShare.test.ts | 85 +++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 frontend/src/api/sessions.test.ts create mode 100644 frontend/src/lib/sessionShare.test.ts diff --git a/frontend/src/api/sessions.test.ts b/frontend/src/api/sessions.test.ts new file mode 100644 index 00000000..b1cb0e12 --- /dev/null +++ b/frontend/src/api/sessions.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { sessionsApi } from './sessions' +import apiClient from './client' + +vi.mock('./client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + }, +})) + +const mockClient = apiClient as unknown as { + get: ReturnType + post: ReturnType + delete: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('sessionsApi sharing methods', () => { + it('createShare hits POST /sessions/{id}/shares with correct payload', async () => { + const mockShare = { id: 'share-1', share_token: 'tok-123', visibility: 'public' } + mockClient.post.mockResolvedValue({ data: mockShare }) + + const payload = { visibility: 'public' as const, share_name: 'My Share' } + const result = await sessionsApi.createShare('session-42', payload) + + expect(mockClient.post).toHaveBeenCalledWith('/sessions/session-42/shares', payload) + expect(result).toEqual(mockShare) + }) + + it('listMyShares hits GET /shares/my-shares', async () => { + const mockShares = [ + { id: 'share-1', share_token: 'tok-1' }, + { id: 'share-2', share_token: 'tok-2' }, + ] + mockClient.get.mockResolvedValue({ data: mockShares }) + + const result = await sessionsApi.listMyShares() + + expect(mockClient.get).toHaveBeenCalledWith('/shares/my-shares') + expect(result).toEqual(mockShares) + }) + + it('revokeShare hits DELETE /shares/{id}', async () => { + mockClient.delete.mockResolvedValue({}) + + await sessionsApi.revokeShare('share-99') + + expect(mockClient.delete).toHaveBeenCalledWith('/shares/share-99') + }) + + it('getSharedSession hits GET /share/{token}', async () => { + const mockView = { + session_id: 'sess-1', + tree_name: 'DNS Troubleshooting', + path_taken: ['root', 'node-1'], + decisions: [], + } + mockClient.get.mockResolvedValue({ data: mockView }) + + const result = await sessionsApi.getSharedSession('tok-abc') + + expect(mockClient.get).toHaveBeenCalledWith('/share/tok-abc') + expect(result).toEqual(mockView) + }) +}) diff --git a/frontend/src/lib/sessionShare.test.ts b/frontend/src/lib/sessionShare.test.ts new file mode 100644 index 00000000..c4f2cbc8 --- /dev/null +++ b/frontend/src/lib/sessionShare.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import type { SessionShare } from '@/types' +import { buildSessionShareUrl, filterSharesForSession, getLatestActiveShareForSession } from './sessionShare' + +function makeMockShare(overrides: Partial = {}): SessionShare { + return { + id: 'share-1', + session_id: 'session-1', + account_id: 'account-1', + share_token: 'abc123', + share_name: null, + visibility: 'public', + created_by: 'user-1', + created_at: '2026-02-14T10:00:00Z', + updated_at: '2026-02-14T10:00:00Z', + expires_at: null, + view_count: 0, + last_viewed_at: null, + is_active: true, + share_url: null, + ...overrides, + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('buildSessionShareUrl', () => { + it('returns share_url when present', () => { + const share = makeMockShare({ share_url: 'https://resolutionflow.com/share/abc123' }) + expect(buildSessionShareUrl(share)).toBe('https://resolutionflow.com/share/abc123') + }) + + it('constructs URL from token when share_url is null', () => { + vi.stubGlobal('location', { origin: 'http://localhost:5173' }) + const share = makeMockShare({ share_token: 'tok-xyz', share_url: null }) + expect(buildSessionShareUrl(share)).toBe('http://localhost:5173/share/tok-xyz') + }) +}) + +describe('filterSharesForSession', () => { + it('filters to matching session_id and active shares', () => { + const shares = [ + makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }), + makeMockShare({ id: 's2', session_id: 'sess-B', is_active: true }), + makeMockShare({ id: 's3', session_id: 'sess-A', is_active: true }), + ] + const result = filterSharesForSession(shares, 'sess-A') + expect(result).toHaveLength(2) + expect(result.map(s => s.id)).toEqual(['s1', 's3']) + }) + + it('excludes inactive shares', () => { + const shares = [ + makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }), + makeMockShare({ id: 's2', session_id: 'sess-A', is_active: false }), + ] + const result = filterSharesForSession(shares, 'sess-A') + expect(result).toHaveLength(1) + expect(result[0].id).toBe('s1') + }) +}) + +describe('getLatestActiveShareForSession', () => { + it('returns the most recently created share', () => { + const shares = [ + makeMockShare({ id: 'old', session_id: 'sess-A', created_at: '2026-02-10T10:00:00Z', is_active: true }), + makeMockShare({ id: 'newest', session_id: 'sess-A', created_at: '2026-02-14T12:00:00Z', is_active: true }), + makeMockShare({ id: 'mid', session_id: 'sess-A', created_at: '2026-02-12T10:00:00Z', is_active: true }), + ] + const result = getLatestActiveShareForSession(shares, 'sess-A') + expect(result).not.toBeNull() + expect(result!.id).toBe('newest') + }) + + it('returns null when no shares match', () => { + const shares = [ + makeMockShare({ session_id: 'sess-B', is_active: true }), + makeMockShare({ session_id: 'sess-A', is_active: false }), + ] + const result = getLatestActiveShareForSession(shares, 'sess-A') + expect(result).toBeNull() + }) +}) -- 2.49.1 From 86542c641081c2c4edde274b259fd90efaf64be5 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 18:42:32 -0500 Subject: [PATCH 09/14] fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/session/SharedSessionTreePreview.tsx | 2 +- frontend/src/pages/SharedSessionPage.tsx | 2 +- frontend/src/types/session.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/session/SharedSessionTreePreview.tsx b/frontend/src/components/session/SharedSessionTreePreview.tsx index 1bbb0dbc..5887ad05 100644 --- a/frontend/src/components/session/SharedSessionTreePreview.tsx +++ b/frontend/src/components/session/SharedSessionTreePreview.tsx @@ -80,7 +80,7 @@ export function SharedSessionTreePreview({

Tree Structure

- + } depth={0} pathTaken={pathTaken} />
) diff --git a/frontend/src/pages/SharedSessionPage.tsx b/frontend/src/pages/SharedSessionPage.tsx index 1d81187b..7eeed622 100644 --- a/frontend/src/pages/SharedSessionPage.tsx +++ b/frontend/src/pages/SharedSessionPage.tsx @@ -232,7 +232,7 @@ export function SharedSessionPage() {
path_taken: string[] decisions: DecisionRecord[] custom_steps: CustomStep[] -- 2.49.1 From 6bc85542020b3e61348bb808dc5e4887b12536c8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 19:14:49 -0500 Subject: [PATCH 10/14] docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fadde848..e8483730 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,7 +139,7 @@ pytest --override-ini="addopts=" # First time only: create test database docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;" -# Frontend build +# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check) cd frontend && npm run build # Database migrations @@ -222,6 +222,10 @@ markSaved() // Clear isDirty BEFORE navigate() navigate(`/trees/${newTree.id}/edit`) ``` +**12. TreeStructure vs Tree types:** `TreeStructure` is for node structure only — it does NOT have `tree_type`, `name`, etc. Those are on `Tree`. JSONB tree snapshots need `TreeStructure & Record` for extra fields. + +**13. Login redirect state format:** `navigate('/login', { state: { from: { pathname: '/path' } } })` — LoginPage expects `state.from.pathname` (object), NOT a plain string. + --- ## RBAC & Permissions @@ -260,6 +264,7 @@ navigate(`/trees/${newTree.id}/edit`) - **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client - **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx` +- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children. - **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head` - **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts` -- 2.49.1 From db5d8a81c1863095edc0389b2150f3dd6a47850c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Feb 2026 20:48:41 -0500 Subject: [PATCH 11/14] feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 --- .../procedural-editor/IntakeFieldEditor.tsx | 4 +- .../procedural-editor/IntakeFormBuilder.tsx | 7 +- .../procedural-editor/StepEditor.tsx | 283 ++++++++++-------- .../components/procedural-editor/StepList.tsx | 84 ++++-- .../components/procedural/IntakeFormModal.tsx | 12 + frontend/src/store/proceduralEditorStore.ts | 85 ++++-- frontend/src/types/tree.ts | 4 +- 7 files changed, 306 insertions(+), 173 deletions(-) diff --git a/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx b/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx index 0a9c94a6..9705702d 100644 --- a/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx +++ b/frontend/src/components/procedural-editor/IntakeFieldEditor.tsx @@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [ { value: 'number', label: 'Number' }, { value: 'ip_address', label: 'IP Address' }, { value: 'email', label: 'Email' }, + { value: 'url', label: 'URL' }, { value: 'select', label: 'Select (Dropdown)' }, { value: 'multi_select', label: 'Multi-Select' }, { value: 'checkbox', label: 'Checkbox' }, @@ -16,11 +17,12 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [ interface IntakeFieldEditorProps { field: IntakeFormField + index: number onUpdate: (updates: Partial) => void onRemove: () => void } -export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEditorProps) { +export function IntakeFieldEditor({ field, index: _index, onUpdate, onRemove }: IntakeFieldEditorProps) { const [expanded, setExpanded] = useState(false) const needsOptions = field.field_type === 'select' || field.field_type === 'multi_select' diff --git a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx index 0e337179..867f4314 100644 --- a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx +++ b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx @@ -36,10 +36,11 @@ export function IntakeFormBuilder() {
{intakeForm.map((field, index) => ( updateField(field.variable_name, updates)} - onRemove={() => removeField(field.variable_name)} + index={index} + onUpdate={(updates) => updateField(index, updates)} + onRemove={() => removeField(index)} /> ))}
diff --git a/frontend/src/components/procedural-editor/StepEditor.tsx b/frontend/src/components/procedural-editor/StepEditor.tsx index 2d958944..c5d80db0 100644 --- a/frontend/src/components/procedural-editor/StepEditor.tsx +++ b/frontend/src/components/procedural-editor/StepEditor.tsx @@ -1,4 +1,5 @@ -import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react' +import { useState } from 'react' +import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react' import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types' import { cn } from '@/lib/utils' @@ -18,6 +19,35 @@ interface StepEditorProps { } export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) { + const [showMore, setShowMore] = useState(false) + + // Section header steps get a minimal editor + if (step.type === 'section_header') { + return ( +
+
+ Edit Section Header + +
+
+ + onUpdate({ title: e.target.value })} + placeholder="Section title" + className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20" + /> +
+
+ ) + } + return (
{/* Header */} @@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa />
- {/* Content type + Section header row */} -
-
- -
- {CONTENT_TYPE_OPTIONS.map((opt) => ( - - ))} -
-
- -
- - onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })} - placeholder="—" - min={1} - className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20" - /> -
-
- - {/* Section Header */} -
+ {/* Est. Minutes */} +
onUpdate({ section_header: e.target.value || undefined })} - placeholder="e.g. Phase 2: AD Configuration" + type="number" + value={step.estimated_minutes || ''} + onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="—" + min={1} className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20" />
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa )}
- {/* Warning text */} - {(step.content_type === 'warning' || step.warning_text) && ( -
- -