feat: session sharing frontend (#76)

* feat: add session sharing types, API client, and utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add SessionTimeline and ActionMenu reusable components

SessionTimeline extracts timeline/checklist rendering from SessionDetailPage
into a reusable component for both authenticated and public session views.
ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ShareSessionModal and integrate into SessionDetailPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Share Progress popover to TreeNavigationPage

Replace the single "Copy for Ticket" button with a "Share Progress"
popover that offers three actions: Copy Progress Summary (existing PSA
export flow), Copy Share Link (auto-creates account-only share if
needed), and Manage Share Links (opens ShareSessionModal).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add public SharedSessionPage with tree preview

Add the public-facing shared session page at /share/:shareToken that
renders shared sessions without authentication. Includes error handling
for 401 (redirect to login), 403 (access denied), 404 (not found),
and 410 (expired). The page features a minimal header, session metadata,
SessionTimeline component, and a new SharedSessionTreePreview component
that renders the decision tree structure with the path taken highlighted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add My Shares management page with nav link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review issues in session sharing

- Add useCallback for loadShares in ShareSessionModal (React hook deps)
- Use TreeStructure type instead of Record<string, unknown> for type safety
- Fix login redirect format to match LoginPage's expected state shape

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add focused tests for session sharing utilities and API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve tree_structure type compatibility for shared session views

- Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility
- Add explicit cast in SharedSessionTreePreview for recursive node rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add session sharing learnings to CLAUDE.md

Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect
state format), note about npm run build strictness, and public route
pattern to Common Tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: procedural editor UX improvements

Add URL intake field type, fix variable name editing collapsing fields
(index-based keys/updates), auto-generate variable names by field type,
add section header as first-class step type, and simplify step editor
with "More Options" collapsible for advanced fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow section_header step type in validation, improve tag input

- Add 'section_header' to VALID_STEP_TYPES in backend validation so
  procedural flows with section headers can be published
- Replace procedural editor's inline tag input with TagInput component
  (supports autocomplete, Tab, comma, semicolon, and paste splitting)
- Add semicolon delimiter support to TagInput component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add type-aware routing for procedural flows

Centralizes tree navigation routing via getTreeNavigatePath helper.
Fixes all pages to route procedural sessions to /flows/:id/navigate
instead of /trees/:id/navigate. Adds safety redirect in troubleshooting
navigator and resume support in procedural navigator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unused index prop from IntakeFieldEditor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #76.
This commit is contained in:
chihlasm
2026-02-14 23:08:17 -05:00
committed by GitHub
parent 6b4304ab92
commit 57f429f33b
32 changed files with 2199 additions and 426 deletions

View File

@@ -1,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
}