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