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

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