feat: session sharing frontend #76

Merged
chihlasm merged 14 commits from feat/session-sharing-frontend into main 2026-02-15 04:08:17 +00:00
32 changed files with 2199 additions and 426 deletions

View File

@@ -139,7 +139,7 @@ pytest --override-ini="addopts="
# First time only: create test database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
# Frontend build
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build
# Database migrations
@@ -222,6 +222,10 @@ markSaved() // Clear isDirty BEFORE navigate()
navigate(`/trees/${newTree.id}/edit`)
```
**12. TreeStructure vs Tree types:** `TreeStructure` is for node structure only — it does NOT have `tree_type`, `name`, etc. Those are on `Tree`. JSONB tree snapshots need `TreeStructure & Record<string, unknown>` for extra fields.
**13. Login redirect state format:** `navigate('/login', { state: { from: { pathname: '/path' } } })` — LoginPage expects `state.from.pathname` (object), NOT a plain string.
---
## RBAC & Permissions
@@ -260,6 +264,7 @@ navigate(`/trees/${newTree.id}/edit`)
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head`
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`

View File

@@ -115,7 +115,7 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
# --- Procedural Tree Validation ---
VALID_STEP_TYPES = {"procedure_step", "procedure_end"}
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}

View File

@@ -12,7 +12,7 @@ TreeType = Literal['troubleshooting', 'procedural']
# --- Intake Form Schemas ---
FIELD_TYPES = Literal[
'text', 'textarea', 'number', 'ip_address', 'email',
'text', 'textarea', 'number', 'ip_address', 'email', 'url',
'select', 'multi_select', 'checkbox', 'password'
]

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sessionsApi } from './sessions'
import apiClient from './client'
vi.mock('./client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
}))
const mockClient = apiClient as unknown as {
get: ReturnType<typeof vi.fn>
post: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('sessionsApi sharing methods', () => {
it('createShare hits POST /sessions/{id}/shares with correct payload', async () => {
const mockShare = { id: 'share-1', share_token: 'tok-123', visibility: 'public' }
mockClient.post.mockResolvedValue({ data: mockShare })
const payload = { visibility: 'public' as const, share_name: 'My Share' }
const result = await sessionsApi.createShare('session-42', payload)
expect(mockClient.post).toHaveBeenCalledWith('/sessions/session-42/shares', payload)
expect(result).toEqual(mockShare)
})
it('listMyShares hits GET /shares/my-shares', async () => {
const mockShares = [
{ id: 'share-1', share_token: 'tok-1' },
{ id: 'share-2', share_token: 'tok-2' },
]
mockClient.get.mockResolvedValue({ data: mockShares })
const result = await sessionsApi.listMyShares()
expect(mockClient.get).toHaveBeenCalledWith('/shares/my-shares')
expect(result).toEqual(mockShares)
})
it('revokeShare hits DELETE /shares/{id}', async () => {
mockClient.delete.mockResolvedValue({})
await sessionsApi.revokeShare('share-99')
expect(mockClient.delete).toHaveBeenCalledWith('/shares/share-99')
})
it('getSharedSession hits GET /share/{token}', async () => {
const mockView = {
session_id: 'sess-1',
tree_name: 'DNS Troubleshooting',
path_taken: ['root', 'node-1'],
decisions: [],
}
mockClient.get.mockResolvedValue({ data: mockView })
const result = await sessionsApi.getSharedSession('tok-abc')
expect(mockClient.get).toHaveBeenCalledWith('/share/tok-abc')
expect(result).toEqual(mockView)
})
})

View File

@@ -1,5 +1,5 @@
import apiClient from './client'
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary } from '@/types'
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView } from '@/types'
export interface SessionListParams {
page?: number
@@ -85,6 +85,26 @@ export const sessionsApi = {
const response = await apiClient.post<SaveAsTreeResponse>(`/sessions/${id}/save-as-tree`, data)
return response.data
},
// Session Sharing
async createShare(sessionId: string, data: SessionShareCreate): Promise<SessionShare> {
const response = await apiClient.post<SessionShare>(`/sessions/${sessionId}/shares`, data)
return response.data
},
async listMyShares(): Promise<SessionShare[]> {
const response = await apiClient.get<SessionShare[]>('/shares/my-shares')
return response.data
},
async revokeShare(shareId: string): Promise<void> {
await apiClient.delete(`/shares/${shareId}`)
},
async getSharedSession(shareToken: string): Promise<SharedSessionView> {
const response = await apiClient.get<SharedSessionView>(`/share/${shareToken}`)
return response.data
},
}
export default sessionsApi

View File

@@ -0,0 +1,100 @@
import { useState, useRef, useEffect } from 'react'
import { MoreVertical } from 'lucide-react'
import { cn } from '@/lib/utils'
interface MenuAction {
label: string
icon?: React.ComponentType<{ className?: string }>
onClick: () => void
disabled?: boolean
variant?: 'default' | 'destructive'
}
interface ActionMenuProps {
actions: MenuAction[]
align?: 'left' | 'right' // default 'right'
}
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const handleItemClick = (action: MenuAction) => {
if (action.disabled) return
action.onClick()
setIsOpen(false)
}
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'rounded-md border border-white/10 p-2 text-white/60',
'hover:bg-white/10 hover:text-white'
)}
aria-label="Actions"
>
<MoreVertical className="h-4 w-4" />
</button>
{isOpen && (
<div
className={cn(
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
align === 'right' ? 'right-0' : 'left-0'
)}
>
{actions.map((action, index) => {
const Icon = action.icon
return (
<button
key={index}
onClick={() => handleItemClick(action)}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
action.disabled
? 'cursor-not-allowed opacity-40'
: action.variant === 'destructive'
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)}
>
{Icon && <Icon className="h-4 w-4" />}
{action.label}
</button>
)
})}
</div>
)}
</div>
)
}
export type { MenuAction, ActionMenuProps }
export default ActionMenu

View File

@@ -106,10 +106,14 @@ export function TagInput({
} else if (e.key === 'Escape') {
setShowSuggestions(false)
setSelectedIndex(-1)
} else if (e.key === ',' || e.key === 'Tab') {
} else if (e.key === ',' || e.key === ';' || e.key === 'Tab') {
if (inputValue.trim()) {
e.preventDefault()
addTag(inputValue)
// Support multiple tags separated by commas or semicolons
const parts = inputValue.split(/[,;]/).map(s => s.trim()).filter(Boolean)
for (const part of parts) {
addTag(part)
}
}
}
}
@@ -157,7 +161,20 @@ export function TagInput({
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={(e) => {
const val = e.target.value
// If pasted text contains delimiters, split into tags immediately
if (val.includes(',') || val.includes(';')) {
const parts = val.split(/[,;]/).map(s => s.trim()).filter(Boolean)
const last = parts.pop()
for (const part of parts) {
addTag(part)
}
setInputValue(last || '')
} else {
setInputValue(val)
}
}}
onKeyDown={handleKeyDown}
onFocus={() => {
if (inputValue.length >= 1 && suggestions.length > 0) {
@@ -221,7 +238,7 @@ export function TagInput({
{/* Helper text */}
<p className="mt-1 text-xs text-white/40">
{tags.length}/{maxTags} tags. Press Enter or comma to add.
{tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
</p>
</div>
)

View File

@@ -84,6 +84,7 @@ export function AppLayout() {
},
{ path: '/my-trees', label: 'My Flows' },
{ path: '/sessions', label: 'Sessions' },
{ path: '/shares', label: 'My Shares' },
{ path: '/account', label: 'Account' },
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
]

View File

@@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
{ value: 'number', label: 'Number' },
{ value: 'ip_address', label: 'IP Address' },
{ value: 'email', label: 'Email' },
{ value: 'url', label: 'URL' },
{ value: 'select', label: 'Select (Dropdown)' },
{ value: 'multi_select', label: 'Multi-Select' },
{ value: 'checkbox', label: 'Checkbox' },

View File

@@ -36,10 +36,10 @@ export function IntakeFormBuilder() {
<div className="space-y-2">
{intakeForm.map((field, index) => (
<IntakeFieldEditor
key={field.variable_name + '-' + index}
key={`field-${index}-${field.display_order}`}
field={field}
onUpdate={(updates) => updateField(field.variable_name, updates)}
onRemove={() => removeField(field.variable_name)}
onUpdate={(updates) => updateField(index, updates)}
onRemove={() => removeField(index)}
/>
))}
</div>

View File

@@ -1,4 +1,5 @@
import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react'
import { useState } from 'react'
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
import { cn } from '@/lib/utils'
@@ -18,6 +19,35 @@ interface StepEditorProps {
}
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
const [showMore, setShowMore] = useState(false)
// Section header steps get a minimal editor
if (step.type === 'section_header') {
return (
<div className="glass-card rounded-xl border border-white/10 p-4">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
<button
onClick={onCollapse}
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
<input
type="text"
value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Section title"
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
</div>
)
}
return (
<div className="glass-card rounded-xl border border-white/10 p-4">
{/* Header */}
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
/>
</div>
{/* Content type + Section header row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Clock className="h-3 w-3" />
Est. Minutes
</label>
<input
type="number"
value={step.estimated_minutes || ''}
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="—"
min={1}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
</div>
{/* Section Header */}
<div>
{/* Est. Minutes */}
<div className="w-40">
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Type className="h-3 w-3" />
Section Header (optional)
<Clock className="h-3 w-3" />
Est. Minutes
</label>
<input
type="text"
value={step.section_header || ''}
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
placeholder="e.g. Phase 2: AD Configuration"
type="number"
value={step.estimated_minutes || ''}
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder=""
min={1}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
)}
</div>
{/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && (
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
<AlertTriangle className="h-3 w-3" />
Warning Text
</label>
<textarea
value={step.warning_text || ''}
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
placeholder="Caution: This will restart the service..."
rows={2}
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
/>
</div>
)}
{/* Commands */}
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
@@ -159,74 +135,127 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
/>
</div>
{/* Expected Outcome */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
<input
type="text"
value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
placeholder="Server should respond with..."
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
{/* More Options toggle */}
<button
type="button"
onClick={() => setShowMore(!showMore)}
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
>
<Settings2 className="h-3 w-3" />
More Options
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{/* Verification */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<CheckSquare className="h-3 w-3" />
Verification Prompt (optional)
</label>
<input
type="text"
value={step.verification_prompt || ''}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
placeholder="Confirm the role was installed"
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
<select
value={step.verification_type || ''}
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
>
<option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option>
<option value="text_input">Text input (enter value)</option>
</select>
</div>
</div>
{showMore && (
<div className="space-y-4 border-t border-white/[0.06] pt-4">
{/* Content Type */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Reference URL + Notes toggle */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<ExternalLink className="h-3 w-3" />
Reference URL (optional)
</label>
<input
type="url"
value={step.reference_url || ''}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
placeholder="https://learn.microsoft.com/..."
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-white/60">
{/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && (
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
<AlertTriangle className="h-3 w-3" />
Warning Text
</label>
<textarea
value={step.warning_text || ''}
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
placeholder="Caution: This will restart the service..."
rows={2}
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
/>
</div>
)}
{/* Expected Outcome */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
<input
type="checkbox"
checked={step.notes_enabled !== false}
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
className="rounded border-white/20"
type="text"
value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
placeholder="Server should respond with..."
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
Allow tech notes
</label>
</div>
{/* Verification */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<CheckSquare className="h-3 w-3" />
Verification Prompt (optional)
</label>
<input
type="text"
value={step.verification_prompt || ''}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
placeholder="Confirm the role was installed"
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
<select
value={step.verification_type || ''}
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
>
<option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option>
<option value="text_input">Text input (enter value)</option>
</select>
</div>
</div>
{/* Reference URL + Notes toggle */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<ExternalLink className="h-3 w-3" />
Reference URL (optional)
</label>
<input
type="url"
value={step.reference_url || ''}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
placeholder="https://learn.microsoft.com/..."
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={step.notes_enabled !== false}
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
className="rounded border-white/20"
/>
Allow tech notes
</label>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)

View File

@@ -1,4 +1,4 @@
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield } from 'lucide-react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import type { StepContentType } from '@/types'
import { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -18,11 +18,13 @@ export function StepList() {
expandedStepId,
setExpandedStepId,
addStep,
addSectionHeader,
removeStep,
updateStep,
} = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
let stepCounter = 0
return (
<div className="glass-card rounded-2xl p-4 sm:p-6">
@@ -34,19 +36,27 @@ export function StepList() {
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
</span>
</div>
<button
onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
>
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
<div className="flex items-center gap-2">
<button
onClick={() => addSectionHeader()}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
>
<SeparatorHorizontal className="h-3.5 w-3.5" />
Add Section
</button>
<button
onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
>
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
</div>
</div>
<div className="space-y-2">
{steps.map((step, index) => {
{steps.map((step) => {
if (step.type === 'procedure_end') {
// Render end step as a simple footer
return (
<div
key={step.id}
@@ -65,20 +75,57 @@ export function StepList() {
)
}
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)
}
return (
<div
key={step.id}
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
const stepNumber = index + 1
if (isExpanded) {
return (
<div key={step.id}>
{step.section_header && (
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
{step.section_header}
</div>
)}
<StepEditor
step={step}
stepNumber={stepNumber}
@@ -92,11 +139,6 @@ export function StepList() {
return (
<div key={step.id}>
{step.section_header && (
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
{step.section_header}
</div>
)}
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',

View File

@@ -171,6 +171,18 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
)
break
case 'url':
input = (
<input
type="url"
value={value}
onChange={(e) => setValue(field.variable_name, e.target.value)}
placeholder={field.placeholder || 'https://...'}
className={baseInputClass}
/>
)
break
default: // text, ip_address, email
input = (
<input

View File

@@ -0,0 +1,207 @@
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import type { DecisionRecord } from '@/types'
import { cn } from '@/lib/utils'
interface SessionTimelineProps {
decisions: DecisionRecord[]
treeType?: string // 'procedural' or 'troubleshooting' (default)
startedAt: string
completedAt: string | null
showCopyButtons?: boolean // default true
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleString()
}
function formatDuration(durationSeconds: number | null | undefined) {
if (durationSeconds == null || durationSeconds < 0) return null
if (durationSeconds < 60) return `${durationSeconds}s`
const hours = Math.floor(durationSeconds / 3600)
const minutes = Math.floor((durationSeconds % 3600) / 60)
const seconds = durationSeconds % 60
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
}
export function SessionTimeline({
decisions,
treeType,
startedAt,
completedAt,
showCopyButtons = true,
}: SessionTimelineProps) {
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
const handleCopyStep = async (decision: DecisionRecord, index: number) => {
const lines: string[] = []
if (decision.question) lines.push(`Question: ${decision.question}`)
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
try {
await navigator.clipboard.writeText(lines.join('\n'))
setCopiedStepIndex(index)
setTimeout(() => setCopiedStepIndex(null), 2000)
} catch {
// Clipboard access denied
}
}
if (treeType === 'procedural') {
return (
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
<div className="space-y-2">
{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>
{showCopyButtons && (
<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>
)
})}
{completedAt && (
<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(completedAt)}
</span>
</div>
)}
</div>
</div>
)
}
// Default: troubleshooting decision timeline
return (
<div className="mb-8">
<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(startedAt)}
</span>
</div>
{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>
{showCopyButtons && (
<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>
))}
{completedAt && (
<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(completedAt)}
</span>
</div>
)}
</div>
</div>
)
}
export default SessionTimeline

View File

@@ -0,0 +1,431 @@
import { useState, useEffect, useCallback } from 'react'
import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react'
import type { SessionShare, SessionShareVisibility } from '@/types'
import { sessionsApi } from '@/api/sessions'
import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface ShareSessionModalProps {
sessionId: string
sessionLabel: string // e.g. ticket number or "Session Details"
isOpen: boolean
onClose: () => void
}
type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom'
function getRelativeTime(dateString: string): string {
const now = Date.now()
const date = new Date(dateString).getTime()
const diffMs = now - date
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) return 'just now'
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
}
function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } {
if (!expiresAt) return { text: 'No expiration', isExpired: false }
const now = Date.now()
const expiry = new Date(expiresAt).getTime()
if (expiry <= now) return { text: 'Expired', isExpired: true }
const diffMs = expiry - now
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
return { text: 'Expires soon', isExpired: false }
}
function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined {
if (preset === 'never') return undefined
if (preset === 'custom') {
if (!customDatetime) return undefined
return new Date(customDatetime).toISOString()
}
const now = new Date()
switch (preset) {
case '1day':
now.setDate(now.getDate() + 1)
break
case '7days':
now.setDate(now.getDate() + 7)
break
case '30days':
now.setDate(now.getDate() + 30)
break
}
return now.toISOString()
}
export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) {
const [shares, setShares] = useState<SessionShare[]>([])
const [isLoadingShares, setIsLoadingShares] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [copiedShareId, setCopiedShareId] = useState<string | null>(null)
// Form state
const [visibility, setVisibility] = useState<SessionShareVisibility>('account')
const [shareName, setShareName] = useState('')
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('never')
const [customDatetime, setCustomDatetime] = useState('')
const [visibilityError, setVisibilityError] = useState<string | null>(null)
const loadShares = useCallback(async () => {
setIsLoadingShares(true)
try {
const allShares = await sessionsApi.listMyShares()
const sessionShares = filterSharesForSession(allShares, sessionId)
// Sort newest first
sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
setShares(sessionShares)
} catch (err) {
console.error('Failed to load shares:', err)
} finally {
setIsLoadingShares(false)
}
}, [sessionId])
useEffect(() => {
if (isOpen) {
loadShares()
// Reset form state
setVisibility('account')
setShareName('')
setExpirationPreset('never')
setCustomDatetime('')
setVisibilityError(null)
setCopiedShareId(null)
}
}, [isOpen, sessionId, loadShares])
const handleGenerateLink = async () => {
setIsGenerating(true)
setVisibilityError(null)
try {
const expires_at = computeExpiresAt(expirationPreset, customDatetime)
const newShare = await sessionsApi.createShare(sessionId, {
visibility,
share_name: shareName.trim() || undefined,
expires_at,
})
setShares([newShare, ...shares])
toast.success('Share link generated')
// Reset form
setShareName('')
setExpirationPreset('never')
setCustomDatetime('')
} catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { detail?: string } } }
if (
error.response?.status === 403 &&
error.response?.data?.detail?.toLowerCase().includes('public session sharing')
) {
setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing')
} else {
console.error('Failed to generate share link:', err)
toast.error('Failed to generate share link')
}
} finally {
setIsGenerating(false)
}
}
const handleCopyUrl = async (share: SessionShare) => {
try {
const url = buildSessionShareUrl(share)
await navigator.clipboard.writeText(url)
setCopiedShareId(share.id)
toast.success('Link copied to clipboard')
setTimeout(() => setCopiedShareId(null), 2000)
} catch (err) {
console.error('Failed to copy link:', err)
toast.error('Failed to copy link')
}
}
const handleRevoke = async (shareId: string) => {
try {
await sessionsApi.revokeShare(shareId)
setShares(shares.filter((s) => s.id !== shareId))
toast.success('Share link revoked')
} catch (err) {
console.error('Failed to revoke share:', err)
toast.error('Failed to revoke share')
}
}
if (!isOpen) return null
const presetButtons: { value: ExpirationPreset; label: string }[] = [
{ value: 'never', label: 'Never' },
{ value: '1day', label: '1 day' },
{ value: '7days', label: '7 days' },
{ value: '30days', label: '30 days' },
{ value: 'custom', label: 'Custom' },
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-white">Share Session</h2>
<p className="text-sm text-white/40">{sessionLabel}</p>
</div>
<button
onClick={onClose}
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-4 space-y-6">
{/* Create Share Form */}
<div className="space-y-4">
{/* Visibility */}
<div>
<label className="mb-2 block text-sm font-medium text-white">
Visibility
</label>
<div className="space-y-2">
<button
onClick={() => { setVisibility('account'); setVisibilityError(null) }}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
visibility === 'account'
? 'border-white/20 bg-white/10 text-white'
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
)}
>
<Users className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-medium">Account Only</div>
<div className="text-xs text-white/40">Visible to your team</div>
</div>
{visibility === 'account' && (
<div className="h-2 w-2 rounded-full bg-white" />
)}
</button>
<button
onClick={() => { setVisibility('public'); setVisibilityError(null) }}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
visibility === 'public'
? 'border-white/20 bg-white/10 text-white'
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
)}
>
<Globe className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-medium">Public</div>
<div className="text-xs text-white/40">Anyone with the link</div>
</div>
{visibility === 'public' && (
<div className="h-2 w-2 rounded-full bg-white" />
)}
</button>
</div>
{visibilityError && (
<p className="mt-2 text-sm text-red-400">{visibilityError}</p>
)}
</div>
{/* Share Name */}
<div>
<label className="mb-2 block text-sm font-medium text-white">
Share Name <span className="text-white/40">(optional)</span>
</label>
<input
type="text"
value={shareName}
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
placeholder="e.g. Training link, Customer escalation"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
maxLength={100}
/>
</div>
{/* Expiration */}
<div>
<label className="mb-2 block text-sm font-medium text-white">
Expiration
</label>
<div className="flex flex-wrap gap-2">
{presetButtons.map((preset) => (
<button
key={preset.value}
onClick={() => setExpirationPreset(preset.value)}
className={cn(
'rounded-md border px-3 py-1.5 text-sm transition-colors',
expirationPreset === preset.value
? 'border-white/20 bg-white/10 text-white'
: 'border-white/10 text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
)}
>
{preset.label}
</button>
))}
</div>
{expirationPreset === 'custom' && (
<input
type="datetime-local"
value={customDatetime}
onChange={(e) => setCustomDatetime(e.target.value)}
className={cn(
'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'[color-scheme:dark]'
)}
/>
)}
</div>
{/* Generate Button */}
<button
onClick={handleGenerateLink}
disabled={isGenerating || (expirationPreset === 'custom' && !customDatetime)}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Link2 className="h-4 w-4" />
{isGenerating ? 'Generating...' : 'Generate Link'}
</button>
</div>
{/* Existing Shares */}
{shares.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-medium text-white">
Active Shares ({shares.length})
</h3>
<div className="space-y-3">
{shares.map((share) => {
const expiration = getExpirationLabel(share.expires_at)
const isCopied = copiedShareId === share.id
return (
<div
key={share.id}
className="glass-card rounded-xl p-4 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
share.visibility === 'public'
? 'bg-white/10 text-white/70'
: 'bg-white/10 text-white/70'
)}>
{share.visibility === 'public' ? (
<Globe className="h-3 w-3" />
) : (
<Users className="h-3 w-3" />
)}
{share.visibility === 'public' ? 'Public' : 'Account'}
</span>
<span className="truncate text-sm font-medium text-white">
{share.share_name || 'Untitled share'}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
<span>{getRelativeTime(share.created_at)}</span>
<span>
{share.view_count > 0
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
: 'Not viewed yet'}
</span>
<span className={cn(
'flex items-center gap-1',
expiration.isExpired && 'text-red-400'
)}>
<Clock className="h-3 w-3" />
{expiration.text}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleCopyUrl(share)}
title="Copy share URL"
className={cn(
'rounded-md border border-white/10 p-1.5 text-sm transition-colors',
isCopied
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
: 'text-white/50 hover:bg-white/10 hover:text-white'
)}
>
{isCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
<button
onClick={() => handleRevoke(share.id)}
title="Revoke share"
className="rounded-md border border-white/10 p-1.5 text-white/50 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Loading state */}
{isLoadingShares && shares.length === 0 && (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
<button
onClick={onClose}
className={cn(
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
'hover:bg-white/10 hover:text-white'
)}
>
Close
</button>
</div>
</div>
</div>
)
}
export default ShareSessionModal

View File

@@ -0,0 +1,89 @@
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface SharedSessionTreePreviewProps {
treeStructure: TreeStructure
pathTaken: string[]
}
const nodeTypeColors: Record<string, string> = {
root: 'bg-white',
decision: 'bg-blue-400',
action: 'bg-yellow-400',
solution: 'bg-emerald-400',
information: 'bg-white/50',
}
function getNodeTitle(node: Record<string, unknown>): string {
return (
(node.question as string) ||
(node.title as string) ||
(node.node_type as string) ||
'Untitled'
)
}
function TreeNode({
node,
depth,
pathTaken,
}: {
node: Record<string, unknown>
depth: number
pathTaken: string[]
}) {
const nodeId = (node.id as string) || ''
const nodeType = (node.node_type as string) || 'decision'
const isInPath = pathTaken.includes(nodeId)
const children = (node.children as Record<string, unknown>[]) || []
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
return (
<>
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-sm',
isInPath
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
: 'text-white/30'
)}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
<span
className={cn('h-2 w-2 shrink-0 rounded-full', colorClass)}
/>
<span className="truncate">{getNodeTitle(node)}</span>
</div>
{children.map((child, index) => (
<TreeNode
key={(child.id as string) || index}
node={child}
depth={depth + 1}
pathTaken={pathTaken}
/>
))}
</>
)
}
export function SharedSessionTreePreview({
treeStructure,
pathTaken,
}: SharedSessionTreePreviewProps) {
if (!treeStructure) {
return null
}
return (
<div className="glass-card rounded-2xl">
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
</div>
<div className="max-h-[600px] overflow-y-auto py-2">
<TreeNode node={treeStructure as unknown as Record<string, unknown>} depth={0} pathTaken={pathTaken} />
</div>
</div>
)
}
export default SharedSessionTreePreview

View File

@@ -0,0 +1,31 @@
/**
* Shared routing helpers for tree/session navigation.
* Centralizes the logic for determining the correct navigation path
* based on tree type (troubleshooting vs procedural).
*/
/**
* Get the navigation path for starting or resuming a tree/session.
*/
export function getTreeNavigatePath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
return `/flows/${treeId}/navigate`
}
return `/trees/${treeId}/navigate`
}
/**
* Get the editor path for a tree.
*/
export function getTreeEditorPath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
return `/flows/${treeId}/edit`
}
return `/trees/${treeId}/edit`
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import type { SessionShare } from '@/types'
import { buildSessionShareUrl, filterSharesForSession, getLatestActiveShareForSession } from './sessionShare'
function makeMockShare(overrides: Partial<SessionShare> = {}): SessionShare {
return {
id: 'share-1',
session_id: 'session-1',
account_id: 'account-1',
share_token: 'abc123',
share_name: null,
visibility: 'public',
created_by: 'user-1',
created_at: '2026-02-14T10:00:00Z',
updated_at: '2026-02-14T10:00:00Z',
expires_at: null,
view_count: 0,
last_viewed_at: null,
is_active: true,
share_url: null,
...overrides,
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('buildSessionShareUrl', () => {
it('returns share_url when present', () => {
const share = makeMockShare({ share_url: 'https://resolutionflow.com/share/abc123' })
expect(buildSessionShareUrl(share)).toBe('https://resolutionflow.com/share/abc123')
})
it('constructs URL from token when share_url is null', () => {
vi.stubGlobal('location', { origin: 'http://localhost:5173' })
const share = makeMockShare({ share_token: 'tok-xyz', share_url: null })
expect(buildSessionShareUrl(share)).toBe('http://localhost:5173/share/tok-xyz')
})
})
describe('filterSharesForSession', () => {
it('filters to matching session_id and active shares', () => {
const shares = [
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
makeMockShare({ id: 's2', session_id: 'sess-B', is_active: true }),
makeMockShare({ id: 's3', session_id: 'sess-A', is_active: true }),
]
const result = filterSharesForSession(shares, 'sess-A')
expect(result).toHaveLength(2)
expect(result.map(s => s.id)).toEqual(['s1', 's3'])
})
it('excludes inactive shares', () => {
const shares = [
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
makeMockShare({ id: 's2', session_id: 'sess-A', is_active: false }),
]
const result = filterSharesForSession(shares, 'sess-A')
expect(result).toHaveLength(1)
expect(result[0].id).toBe('s1')
})
})
describe('getLatestActiveShareForSession', () => {
it('returns the most recently created share', () => {
const shares = [
makeMockShare({ id: 'old', session_id: 'sess-A', created_at: '2026-02-10T10:00:00Z', is_active: true }),
makeMockShare({ id: 'newest', session_id: 'sess-A', created_at: '2026-02-14T12:00:00Z', is_active: true }),
makeMockShare({ id: 'mid', session_id: 'sess-A', created_at: '2026-02-12T10:00:00Z', is_active: true }),
]
const result = getLatestActiveShareForSession(shares, 'sess-A')
expect(result).not.toBeNull()
expect(result!.id).toBe('newest')
})
it('returns null when no shares match', () => {
const shares = [
makeMockShare({ session_id: 'sess-B', is_active: true }),
makeMockShare({ session_id: 'sess-A', is_active: false }),
]
const result = getLatestActiveShareForSession(shares, 'sess-A')
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,27 @@
import type { SessionShare } from '@/types'
/**
* Build the full share URL from a SessionShare object.
* Uses share.share_url if present, otherwise constructs from token.
*/
export function buildSessionShareUrl(share: SessionShare): string {
if (share.share_url) return share.share_url
return `${window.location.origin}/share/${share.share_token}`
}
/**
* Filter shares to only those belonging to a specific session.
*/
export function filterSharesForSession(shares: SessionShare[], sessionId: string): SessionShare[] {
return shares.filter(s => s.session_id === sessionId && s.is_active)
}
/**
* Get the most recent active share for a given session.
* Returns null if no active shares exist.
*/
export function getLatestActiveShareForSession(shares: SessionShare[], sessionId: string): SessionShare | null {
const sessionShares = filterSharesForSession(shares, sessionId)
if (sessionShares.length === 0) return null
return sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
}

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>

View File

@@ -8,6 +8,9 @@ import {
RegisterPage,
} from '@/pages'
// Public pages
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
// Standalone auth pages
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
@@ -23,6 +26,7 @@ const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -69,6 +73,15 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (
<Suspense fallback={<PageLoader />}>
<SharedSessionPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/change-password',
element: (
@@ -177,6 +190,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'shares',
element: (
<Suspense fallback={<PageLoader />}>
<MySharesPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',

View File

@@ -1,10 +1,29 @@
import { create } from 'zustand'
import { temporal } from 'zundo'
import { immer } from 'zustand/middleware/immer'
import type { Tree, IntakeFormField, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
const generateId = () => crypto.randomUUID()
const FIELD_TYPE_PREFIX: Record<IntakeFieldType, string> = {
text: 'text',
textarea: 'textarea',
number: 'number',
ip_address: 'ip',
email: 'email',
url: 'url',
select: 'select',
multi_select: 'multiselect',
checkbox: 'checkbox',
password: 'password',
}
function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string {
const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field'
const count = existingFields.filter((f) => f.field_type === fieldType).length
return `${prefix}_${count + 1}`
}
function createDefaultStep(index: number): ProceduralStep {
return {
id: generateId(),
@@ -24,9 +43,17 @@ function createEndStep(): ProceduralStep {
}
}
function createDefaultField(index: number): IntakeFormField {
function createSectionHeader(title: string): ProceduralStep {
return {
variable_name: `field_${index + 1}`,
id: generateId(),
type: 'section_header',
title,
}
}
function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField {
return {
variable_name: generateVariableName('text', existingFields),
label: `Field ${index + 1}`,
field_type: 'text',
required: true,
@@ -70,6 +97,7 @@ interface ProceduralEditorState {
// Actions - Steps
addStep: (afterIndex?: number) => void
addSectionHeader: (afterIndex?: number) => void
removeStep: (stepId: string) => void
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
moveStep: (fromIndex: number, toIndex: number) => void
@@ -78,8 +106,8 @@ interface ProceduralEditorState {
// Actions - Intake Form
addField: () => void
removeField: (variableName: string) => void
updateField: (variableName: string, updates: Partial<IntakeFormField>) => void
removeField: (index: number) => void
updateField: (index: number, updates: Partial<IntakeFormField>) => void
moveField: (fromIndex: number, toIndex: number) => void
// Actions - Save
@@ -200,15 +228,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
})
},
addSectionHeader: (afterIndex) => {
set((state) => {
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
const insertAt = afterIndex !== undefined
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
: (endIndex >= 0 ? endIndex : state.steps.length)
const newHeader = createSectionHeader('New Section')
state.steps.splice(insertAt, 0, newHeader)
state.expandedStepId = newHeader.id
state.isDirty = true
})
},
removeStep: (stepId) => {
set((state) => {
const index = state.steps.findIndex((s) => s.id === stepId)
if (index === -1) return
// Don't remove the end step
if (state.steps[index].type === 'procedure_end') return
// Don't remove if it's the only procedure_step
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return
// Don't remove if it's the only procedure_step (section headers can always be removed)
if (state.steps[index].type === 'procedure_step') {
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return
}
state.steps.splice(index, 1)
if (state.selectedStepId === stepId) state.selectedStepId = null
@@ -249,28 +293,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
// Intake Form
addField: () => {
set((state) => {
const newField = createDefaultField(state.intakeForm.length)
const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
state.intakeForm.push(newField)
state.isDirty = true
})
},
removeField: (variableName) => {
removeField: (index) => {
set((state) => {
const index = state.intakeForm.findIndex((f) => f.variable_name === variableName)
if (index !== -1) {
state.intakeForm.splice(index, 1)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
}
if (index < 0 || index >= state.intakeForm.length) return
state.intakeForm.splice(index, 1)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
})
},
updateField: (variableName, updates) => {
updateField: (index, updates) => {
set((state) => {
const field = state.intakeForm.find((f) => f.variable_name === variableName)
const field = state.intakeForm[index]
if (field) {
// If field_type changed, auto-generate a new variable name
if (updates.field_type && updates.field_type !== field.field_type) {
const otherFields = state.intakeForm.filter((_, i) => i !== index)
updates.variable_name = generateVariableName(updates.field_type, otherFields)
}
Object.assign(field, updates)
state.isDirty = true
}

View File

@@ -39,6 +39,7 @@ export interface TreeSnapshot extends TreeStructure {
description?: string
category?: string
version?: number
tree_type?: string
}
export interface Session {
@@ -58,6 +59,7 @@ export interface Session {
exported: boolean
scratchpad: string
next_steps: string
session_variables: Record<string, string>
}
export interface SessionCreate {
@@ -125,3 +127,45 @@ export interface SaveAsTreeResponse {
tree_name: string
message: string
}
// Session Sharing
export type SessionShareVisibility = 'public' | 'account'
export interface SessionShareCreate {
visibility: SessionShareVisibility
share_name?: string
expires_at?: string // ISO datetime string
}
export interface SessionShare {
id: string
session_id: string
account_id: string
share_token: string
share_name: string | null
visibility: SessionShareVisibility
created_by: string
created_at: string
updated_at: string
expires_at: string | null
view_count: number
last_viewed_at: string | null
is_active: boolean
share_url: string | null
}
export interface SharedSessionView {
session_id: string
tree_name: string
tree_description: string | null
tree_structure: TreeStructure & Record<string, unknown>
path_taken: string[]
decisions: DecisionRecord[]
custom_steps: CustomStep[]
started_at: string
completed_at: string | null
ticket_number: string | null
client_name: string | null
share_name: string | null
visibility: SessionShareVisibility
}

View File

@@ -61,7 +61,7 @@ export interface TreeStructure {
export type TreeType = 'troubleshooting' | 'procedural'
export type IntakeFieldType =
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email'
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
| 'select' | 'multi_select' | 'checkbox' | 'password'
export type StepContentType = 'action' | 'informational' | 'verification' | 'warning'
@@ -105,7 +105,7 @@ export interface StepVerification {
export interface ProceduralStep {
id: string
type: 'procedure_step' | 'procedure_end'
type: 'procedure_step' | 'procedure_end' | 'section_header'
title: string
description?: string
content_type?: StepContentType