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:
chihlasm
2026-02-14 23:08:17 -05:00
committed by GitHub
parent 6b4304ab92
commit 57f429f33b
32 changed files with 2199 additions and 426 deletions

View 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>
)
}

View File

@@ -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"
>
&times;
</button>
</span>
))}
</div>
)}
<TagInput tags={tags} onChange={setTags} />
</div>
<div className="flex items-end pb-1">

View File

@@ -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 || []

View File

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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'

View 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">&middot;</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

View File

@@ -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" />

View File

@@ -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>