feat: session sharing frontend (#76)
* feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record<string, unknown> for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused index prop from IntakeFieldEditor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #76.
This commit is contained in:
240
frontend/src/pages/MySharesPage.tsx
Normal file
240
frontend/src/pages/MySharesPage.tsx
Normal file
@@ -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<SessionShare[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="glass-card rounded-xl p-6 border border-red-400/20">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchShares}
|
||||
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/sessions"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white/70 transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sessions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">My Shared Sessions</h1>
|
||||
<p className="text-white/40 mt-1">Manage your session share links</p>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{shares.length === 0 ? (
|
||||
<div className="glass-card rounded-xl p-12 text-center">
|
||||
<Link2 className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-white mb-2">No shared sessions</h2>
|
||||
<p className="text-white/40 text-sm mb-6">
|
||||
Share a session from the session detail page to create a link
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Go to Sessions
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shares.map((share) => {
|
||||
const expiration = formatExpiration(share.expires_at)
|
||||
const isCopied = copiedId === share.id
|
||||
|
||||
return (
|
||||
<div key={share.id} className="glass-card rounded-xl p-5">
|
||||
{/* Top row: badge + name */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-white/10 text-white/60">
|
||||
{share.visibility === 'public' ? (
|
||||
<Globe className="h-3 w-3" />
|
||||
) : (
|
||||
<Users className="h-3 w-3" />
|
||||
)}
|
||||
{share.visibility === 'public' ? 'Public' : 'Account Only'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{share.share_name || 'Untitled share'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session info */}
|
||||
<p className="text-sm text-white/50 mb-2">
|
||||
Session ID: {share.session_id.slice(0, 8)}...
|
||||
</p>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-white/40 mb-4">
|
||||
<span>Created {formatRelativeTime(share.created_at)}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span>
|
||||
{share.view_count > 0
|
||||
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||
: 'Not viewed yet'}
|
||||
</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className={cn(expiration.isExpired && 'text-red-400')}>
|
||||
{expiration.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleCopyLink(share)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isCopied
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
: 'bg-white text-black hover:bg-white/90'
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isCopied ? 'Copied' : 'Copy Link'}
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`/sessions/${share.session_id}`}
|
||||
className="inline-flex items-center gap-1.5 border border-white/10 text-white/60 hover:bg-white/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
View Session
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => handleRevoke(share)}
|
||||
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
||||
import { StepList } from '@/components/procedural-editor/StepList'
|
||||
import { TagInput } from '@/components/common/TagInput'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function ProceduralEditorPage() {
|
||||
@@ -33,8 +34,6 @@ export function ProceduralEditorPage() {
|
||||
getTreeForSave,
|
||||
} = useProceduralEditorStore()
|
||||
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Load tree or init new
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
@@ -95,18 +94,6 @@ export function ProceduralEditorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !tags.includes(tag)) {
|
||||
setTags([...tags, tag])
|
||||
setTagInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
@@ -187,34 +174,7 @@ export function ProceduralEditorPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1 rounded-lg 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"
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end pb-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
@@ -21,6 +21,8 @@ interface StepState {
|
||||
export function ProceduralNavigationPage() {
|
||||
const { id: treeId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationState = location.state as { sessionId?: string } | undefined
|
||||
|
||||
const [tree, setTree] = useState<Tree | null>(null)
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
@@ -98,6 +100,12 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
setTree(treeData)
|
||||
|
||||
// If resuming an existing session
|
||||
if (locationState?.sessionId) {
|
||||
await resumeSession(treeData, locationState.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if intake form exists
|
||||
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
||||
setShowIntakeForm(true)
|
||||
@@ -134,6 +142,42 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resumeSession = async (treeData: Tree, sessionId: string) => {
|
||||
try {
|
||||
const sessionData = await sessionsApi.get(sessionId)
|
||||
setSession(sessionData)
|
||||
setSessionVariables(sessionData.session_variables || {})
|
||||
setShowIntakeForm(false)
|
||||
|
||||
// Initialize step states from session decisions
|
||||
const allSteps = getStepsFromTree(treeData)
|
||||
const initialStates = new Map<string, StepState>()
|
||||
for (const step of allSteps) {
|
||||
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||
}
|
||||
|
||||
// Hydrate completed steps from decisions
|
||||
for (const decision of sessionData.decisions || []) {
|
||||
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
|
||||
initialStates.set(decision.node_id, {
|
||||
notes: decision.notes || '',
|
||||
verificationValue: decision.command_output || '',
|
||||
completedAt: decision.exited_at || decision.timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
setStepStates(initialStates)
|
||||
|
||||
// Set current step to first incomplete step
|
||||
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
|
||||
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||
} catch {
|
||||
toast.error('Failed to resume session')
|
||||
navigate('/my-trees')
|
||||
}
|
||||
}
|
||||
|
||||
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
||||
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
||||
return structure.steps || []
|
||||
|
||||
@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
@@ -27,7 +28,7 @@ export function QuickStartPage() {
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
|
||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -44,7 +45,7 @@ export function QuickStartPage() {
|
||||
|
||||
// Deduplicate recent sessions by tree_id, max 5
|
||||
const seen = new Set<string>()
|
||||
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
|
||||
const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = []
|
||||
for (const s of recent) {
|
||||
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
||||
seen.add(s.tree_id)
|
||||
@@ -52,6 +53,7 @@ export function QuickStartPage() {
|
||||
tree_id: s.tree_id,
|
||||
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
||||
lastUsed: s.started_at,
|
||||
tree_type: s.tree_snapshot?.tree_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -164,7 +166,7 @@ export function QuickStartPage() {
|
||||
{searchResults.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
@@ -210,7 +212,7 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
navigate(`/trees/${activeSessions[0].tree_id}/navigate`, {
|
||||
navigate(getTreeNavigatePath(activeSessions[0].tree_id, activeSessions[0].tree_snapshot?.tree_type), {
|
||||
state: { sessionId: activeSessions[0].id },
|
||||
})
|
||||
}
|
||||
@@ -234,7 +236,7 @@ export function QuickStartPage() {
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() =>
|
||||
navigate(`/trees/${session.tree_id}/navigate`, {
|
||||
navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), {
|
||||
state: { sessionId: session.id },
|
||||
})
|
||||
}
|
||||
@@ -282,7 +284,7 @@ export function QuickStartPage() {
|
||||
{recentTrees.map((tree) => (
|
||||
<button
|
||||
key={tree.tree_id}
|
||||
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.tree_id, tree.tree_type))}
|
||||
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
|
||||
@@ -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<Step[]>([])
|
||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(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 */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* Save as Tree - Only for completed sessions */}
|
||||
{session.completed_at && (
|
||||
<button
|
||||
onClick={() => setShowSaveAsTreeModal(true)}
|
||||
disabled={isSavingTree}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Tree
|
||||
</button>
|
||||
)}
|
||||
<ActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: 'Share',
|
||||
icon: Share2,
|
||||
onClick: () => setShowShareModal(true),
|
||||
},
|
||||
...(session.completed_at ? [{
|
||||
label: 'Save as Tree',
|
||||
icon: Save,
|
||||
onClick: () => setShowSaveAsTreeModal(true),
|
||||
}] as MenuAction[] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Copy for Ticket */}
|
||||
<button
|
||||
@@ -482,151 +466,12 @@ export function SessionDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Timeline / Step Checklist */}
|
||||
<div className="mb-8">
|
||||
{(session.tree_snapshot as unknown as Record<string, unknown>).tree_type === 'procedural' ? (
|
||||
<>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||
<div className="space-y-2">
|
||||
{session.decisions.map((decision, index) => {
|
||||
const isCompleted = decision.answer === 'completed'
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'glass-card rounded-xl p-4',
|
||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||
)}>
|
||||
{isCompleted ? '\u2713' : index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||
{decision.notes && (
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Verification: {decision.command_output}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{session.completed_at && (
|
||||
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-400">
|
||||
Procedure completed: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-white" />
|
||||
<span className="text-white/40">
|
||||
Session started: {formatDate(session.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{session.completed_at && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-emerald-400">
|
||||
Session completed: {formatDate(session.completed_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SessionTimeline
|
||||
decisions={session.decisions}
|
||||
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
|
||||
startedAt={session.started_at}
|
||||
completedAt={session.completed_at}
|
||||
/>
|
||||
|
||||
{/* Export Preview Modal */}
|
||||
<ExportPreviewModal
|
||||
@@ -660,6 +505,14 @@ export function SessionDetailPage() {
|
||||
librarySteps={librarySteps}
|
||||
isSaving={isSavingRatings}
|
||||
/>
|
||||
|
||||
{/* Share Session Modal */}
|
||||
<ShareSessionModal
|
||||
sessionId={session.id}
|
||||
sessionLabel={session.ticket_number || 'Session Details'}
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
|
||||
export function SessionHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -284,7 +285,7 @@ export function SessionHistoryPage() {
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
|
||||
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||
className={cn(
|
||||
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
|
||||
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
@@ -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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
<div className="glass-card w-full max-w-md rounded-2xl p-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/5">
|
||||
<Icon className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-semibold text-white">{titleMap[error.type]}</h1>
|
||||
<p className="mb-6 text-sm text-white/50">{error.message}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-block rounded-lg bg-white px-6 py-2.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
Go to ResolutionFlow
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedSessionPage() {
|
||||
const { shareToken } = useParams<{ shareToken: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<SharedSessionView | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<ErrorState | null>(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: { pathname: `/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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||
<p className="text-sm text-white/40">Loading shared session...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorCard error={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
{/* Minimal header */}
|
||||
<header className="border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<BrandLogo size="sm" />
|
||||
<span className="text-lg font-semibold text-white">ResolutionFlow</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="container mx-auto max-w-7xl px-4 py-8">
|
||||
{/* Metadata section */}
|
||||
<div className="mb-8">
|
||||
{data.share_name && (
|
||||
<h1 className="mb-2 text-2xl font-bold text-white">{data.share_name}</h1>
|
||||
)}
|
||||
<p className="text-lg text-white/70">
|
||||
<span className="text-white/40">Tree:</span> {data.tree_name}
|
||||
</p>
|
||||
|
||||
{(data.ticket_number || data.client_name) && (
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
{data.ticket_number && (
|
||||
<span>Ticket: #{data.ticket_number}</span>
|
||||
)}
|
||||
{data.ticket_number && data.client_name && (
|
||||
<span className="mx-1.5">·</span>
|
||||
)}
|
||||
{data.client_name && (
|
||||
<span>Client: {data.client_name}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/40">
|
||||
<span>Started: {formatDate(data.started_at)}</span>
|
||||
{data.completed_at && (
|
||||
<>
|
||||
<span>Completed: {formatDate(data.completed_at)}</span>
|
||||
<span>Duration: {formatDuration(data.started_at, data.completed_at)}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{data.visibility === 'public' ? (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Public
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Account
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Decision Timeline (2 cols) */}
|
||||
<div className="lg:col-span-2">
|
||||
<SessionTimeline
|
||||
decisions={data.decisions}
|
||||
treeType={data.tree_structure?.tree_type as string | undefined}
|
||||
startedAt={data.started_at}
|
||||
completedAt={data.completed_at}
|
||||
showCopyButtons={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tree Preview (1 col) */}
|
||||
<div className="lg:col-span-1">
|
||||
<SharedSessionTreePreview
|
||||
treeStructure={data.tree_structure}
|
||||
pathTaken={data.path_taken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 text-center text-sm text-white/30">
|
||||
Powered by{' '}
|
||||
<Link to="/" className="underline hover:text-white/50">
|
||||
ResolutionFlow
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedSessionPage
|
||||
@@ -15,6 +15,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -450,7 +451,7 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
|
||||
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
||||
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -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<string | null>(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<HTMLDivElement>(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<string>(locationState?.prefillTicketNumber || '')
|
||||
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
||||
@@ -205,6 +261,16 @@ export function TreeNavigationPage() {
|
||||
setError(null)
|
||||
try {
|
||||
const treeData = await treesApi.get(treeId!)
|
||||
|
||||
// Safety redirect: procedural trees should use the procedural navigator
|
||||
if (treeData.tree_type === 'procedural') {
|
||||
navigate(`/flows/${treeId}/navigate`, {
|
||||
replace: true,
|
||||
state: locationState,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTree(treeData)
|
||||
|
||||
// If resuming a session
|
||||
@@ -576,18 +642,70 @@ export function TreeNavigationPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopyForTicket}
|
||||
disabled={isCopyingForTicket}
|
||||
title="Copy progress notes for ticket"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors disabled:opacity-50'
|
||||
{/* Share Progress Popover */}
|
||||
<div className="relative" ref={sharePopoverRef}>
|
||||
<button
|
||||
onClick={() => setShowSharePopover(!showSharePopover)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white transition-colors'
|
||||
)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Share Progress
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{showSharePopover && (
|
||||
<div className="absolute right-0 mt-1 z-50 glass-card rounded-lg p-1 min-w-[220px]">
|
||||
{/* Copy Progress Summary */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCopyForTicket()
|
||||
setShowSharePopover(false)
|
||||
}}
|
||||
disabled={isCopyingForTicket}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copiedForTicket ? <Check className="h-4 w-4 text-emerald-400" /> : <Clipboard className="h-4 w-4" />}
|
||||
{copiedForTicket ? 'Copied!' : 'Copy Progress Summary'}
|
||||
</button>
|
||||
{/* Copy Share Link */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCopyShareLink()
|
||||
setShowSharePopover(false)
|
||||
}}
|
||||
disabled={isCopyingShareLink}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{copiedShareLink ? <Check className="h-4 w-4 text-emerald-400" /> : <Link2 className="h-4 w-4" />}
|
||||
{isCopyingShareLink ? 'Loading...' : copiedShareLink ? 'Copied!' : 'Copy Share Link'}
|
||||
</button>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/[0.06] my-1" />
|
||||
{/* Manage Share Links */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSharePopover(false)
|
||||
setShowShareModal(true)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Manage Share Links...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{copiedForTicket ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copiedForTicket ? 'Copied!' : 'Copy for Ticket'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/sessions')}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
|
||||
@@ -1092,6 +1210,16 @@ export function TreeNavigationPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Share Session Modal */}
|
||||
{session && (
|
||||
<ShareSessionModal
|
||||
sessionId={session.id}
|
||||
sessionLabel={ticketNumber || tree?.name || 'Session'}
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user