wip(handoff): start issue cleanup plan sections 1 and 2

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-05-01 02:04:19 -04:00
parent a21fe93454
commit 4d8b107121
23 changed files with 231 additions and 105 deletions

View File

@@ -16,7 +16,7 @@ function App() {
} else {
setLoading(false)
}
}, [])
}, [fetchUser, isAuthenticated, setLoading])
return <RouterProvider router={router} />
}

View File

@@ -39,7 +39,7 @@ export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true)
// eslint-disable-next-line react-hooks/set-state-in-effect
setError(false)
analyticsApi
.getFlowAnalytics(treeId, period)

View File

@@ -31,6 +31,62 @@ interface ActionResponse {
type TaskResponse = QuestionResponse | ActionResponse
interface DiagnosticHelp {
what: string
lookFor: string
usefulWhen: string
}
function getDiagnosticHelp(action: ActionResponse): DiagnosticHelp {
const command = (action.command || '').toLowerCase()
if (command.includes('test-netconnection') || command.includes('ping ')) {
return {
what: action.description || 'Checks whether the target is reachable over the network.',
lookFor: 'Successful replies, low packet loss, and whether the expected port shows as open.',
usefulWhen: 'Use it when you need to separate a service problem from a basic connectivity problem.',
}
}
if (command.includes('nslookup') || command.includes('resolve-dnsname')) {
return {
what: action.description || 'Checks how DNS resolves the hostname or record.',
lookFor: 'Wrong IPs, NXDOMAIN responses, timeout errors, or different answers from different resolvers.',
usefulWhen: 'Use it when names fail but direct IP access may still work.',
}
}
if (command.includes('ipconfig') || command.includes('get-netipconfiguration')) {
return {
what: action.description || 'Shows local IP, gateway, DNS, and adapter configuration.',
lookFor: 'APIPA addresses, missing gateways, wrong DNS servers, disconnected adapters, or stale leases.',
usefulWhen: 'Use it early when the symptom may be local network configuration.',
}
}
if (command.includes('get-eventlog') || command.includes('get-winevent') || command.includes('eventlog')) {
return {
what: action.description || 'Reads Windows event logs for recent errors or warnings.',
lookFor: 'Events matching the failure time, repeated error IDs, service crashes, or permission failures.',
usefulWhen: 'Use it when the UI only shows a generic error and you need system-level evidence.',
}
}
if (command.includes('get-service') || command.includes('restart-service')) {
return {
what: action.description || 'Checks service state on the affected machine.',
lookFor: 'Stopped services, restart loops, disabled startup types, or dependency failures.',
usefulWhen: 'Use it when a feature depends on a Windows service or background agent.',
}
}
return {
what: action.description || 'Runs the diagnostic check suggested by FlowPilot.',
lookFor: 'Errors, unexpected values, failed checks, or output that differs from a known-good machine.',
usefulWhen: 'Use it when you need evidence before choosing the next troubleshooting step.',
}
}
interface TaskLaneProps {
questions: QuestionItem[]
actions: ActionItem[]
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
const [showRunAll, setShowRunAll] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
// ── Resize state ──
const DEFAULT_WIDTH = 340
@@ -166,22 +223,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent best-effort save */ })
}).catch(() => { /* silent - best-effort save */ })
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps
}, [sessionId, tasks])
// Reset when new tasks come in from AI response — but preserve saved state
useEffect(() => {
if (sessionId) {
const saved = loadTaskState(sessionId)
if (saved && saved.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs task UI from persisted session state
setTasks(saved)
return
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
setTasks([
...questions.map((q): QuestionResponse => ({
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
@@ -190,7 +247,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
})),
])
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps
}, [questions, actions, sessionId])
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
@@ -490,10 +547,49 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
return (
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
{a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
)}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
{a.description && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
)}
</div>
<button
type="button"
onClick={() => setExpandedHelpKey(expandedHelpKey === `${idx}` ? null : `${idx}`)}
className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-elevated/50 hover:text-heading',
expandedHelpKey === `${idx}` && 'bg-accent-dim text-accent-text',
)}
title="Explain this check"
aria-label="Explain this diagnostic check"
aria-expanded={expandedHelpKey === `${idx}`}
>
<MessageCircleQuestion size={13} />
</button>
</div>
{expandedHelpKey === `${idx}` && (() => {
const help = getDiagnosticHelp(a)
return (
<div className="mt-2 rounded-lg border border-info/20 bg-info-dim/20 p-2.5 text-[0.6875rem] leading-relaxed">
<div className="space-y-1.5">
<p>
<span className="font-semibold text-heading">What it checks: </span>
<span className="text-muted-foreground">{help.what}</span>
</p>
<p>
<span className="font-semibold text-heading">What to look for: </span>
<span className="text-muted-foreground">{help.lookFor}</span>
</p>
<p>
<span className="font-semibold text-heading">When to use it: </span>
<span className="text-muted-foreground">{help.usefulWhen}</span>
</p>
</div>
</div>
)
})()}
{a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">

View File

@@ -296,7 +296,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
}
return result
}, [query, searchFlows, searchSessions, searchAISessions, user])
}, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
// Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
return (
<button
key={item.id}
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
className={cn(

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useCallback, useState, useEffect, useRef } from 'react'
import { FolderPlus, Check, Plus } from 'lucide-react'
import { foldersApi } from '@/api/folders'
import type { FolderListItem } from '@/types'
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isOpen) {
loadFoldersAndAssignments()
}
}, [isOpen, treeId])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const loadFoldersAndAssignments = async () => {
const loadFoldersAndAssignments = useCallback(async () => {
setIsLoading(true)
try {
const foldersData = await foldersApi.list()
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
} finally {
setIsLoading(false)
}
}
}, [treeId])
useEffect(() => {
if (isOpen) {
loadFoldersAndAssignments()
}
}, [isOpen, loadFoldersAndAssignments])
// Close on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const toggleFolder = async (folderId: string) => {
try {

View File

@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
}
// Get path string for sorting
function getPath(allFolders: FolderListItem[], folderId: string): string {
const f = allFolders.find((x) => x.id === folderId)
if (!f) return ''
if (!f.parent_id) return f.name
return getPath(allFolders, f.parent_id) + '/' + f.name
}
export function FolderEditModal({
folder,
parentId: initialParentId,
@@ -110,14 +118,6 @@ export function FolderEditModal({
})
}, [folder, folders])
// Get path string for sorting
function getPath(allFolders: FolderListItem[], folderId: string): string {
const f = allFolders.find((x) => x.id === folderId)
if (!f) return ''
if (!f.parent_id) return f.name
return getPath(allFolders, f.parent_id) + '/' + f.name
}
useEffect(() => {
if (folder) {
setName(folder.name)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useCallback, useState, useEffect } from 'react'
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
import { treesApi } from '@/api/trees'
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
const [allowForking, setAllowForking] = useState(true)
const [visibility, setVisibility] = useState<TreeVisibility>('private')
useEffect(() => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, tree.id])
const loadShares = async () => {
const loadShares = useCallback(async () => {
try {
const sharesData = await treesApi.listShares(tree.id)
setShares(sharesData)
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
} catch (err) {
console.error('Failed to load shares:', err)
}
}
}, [tree.id])
useEffect(() => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, loadShares])
const handleGenerateLink = async () => {
setIsGenerating(true)

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useCallback, useState, useEffect, useRef } from 'react'
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
import type { BrandingInfo } from '@/api/branding'
@@ -23,11 +23,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
const loadBranding = useCallback(async () => {
setIsLoading(true)
try {
const data = await getBranding(teamId)
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
} finally {
setIsLoading(false)
}
}
}, [teamId])
useEffect(() => {
loadBranding()
}, [loadBranding])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]

View File

@@ -47,9 +47,9 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
if (node) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraft(cloneWithoutChildren(node))
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDirty(false)
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowDeleteConfirm(false)
}
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -261,7 +261,7 @@ export function TreeCanvas() {
})
setExpandedNodeId(null)
},
[pendingLinks, treeStructure, updateNode]
[addNode, pendingLinks, treeStructure, updateNode]
)
// ── Cancel new node ──

View File

@@ -18,7 +18,7 @@ export function useCachedQuota() {
if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAiEnabled(cachedResult.aiEnabled)
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsLoading(false)
return
}

View File

@@ -153,7 +153,7 @@ export function AccountSettingsPage() {
useEffect(() => {
loadData()
}, [])
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- initial account load; mutations call loadData explicitly
const loadData = async () => {
setIsLoading(true)

View File

@@ -267,6 +267,15 @@ export default function AssistantChatPage() {
// path: post-claim the chat surface had no messages and the senior
// landed on a blank pane).
const loadedChatIdsRef = useRef<Set<string>>(new Set())
const guardCurrentChat = useCallback((expectedChatId: string, source: string) => {
if (currentChatRef.current === expectedChatId) return true
console.warn('[AssistantChat] Discarded stale async result', {
source,
expectedChatId,
currentChatId: currentChatRef.current,
})
return false
}, [])
// Persist active chat ID to sessionStorage
useEffect(() => {
@@ -612,7 +621,7 @@ export default function AssistantChatPage() {
}
window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener)
}, [activeFix])
}, [activeFix, activeChatId])
const loadChats = async () => {
try {
@@ -684,7 +693,7 @@ export default function AssistantChatPage() {
try {
const list = await sessionFactsApi.list(chatId)
// Guard: discard stale fetch if the user switched chats mid-flight.
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshFacts')) return
setFacts(list)
// Auto-open the task lane when the session has facts so the engineer
// can see them — without this, a session with only facts (no open
@@ -699,7 +708,7 @@ export default function AssistantChatPage() {
// Best-effort — facts are accessory state. Surfacing a toast on every
// refetch failure would be noisy; the empty state explains the absence.
}
}, [])
}, [guardCurrentChat])
// Phase 3 — active suggested fix + resolution-note preview.
// Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback
@@ -707,7 +716,7 @@ export default function AssistantChatPage() {
const refreshActiveFix = useCallback(async (chatId: string) => {
try {
const fix = await sessionSuggestedFixesApi.getActive(chatId)
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshActiveFix')) return
setActiveFix((prev) => {
// If the active fix changed (AI emitted a new SUGGEST_FIX that
// superseded the prior), close the script panel so the engineer
@@ -719,7 +728,7 @@ export default function AssistantChatPage() {
// No-fix-yet (404) is normalized to null inside the client. Genuine
// failures stay silent — accessory state, not load-bearing.
}
}, [])
}, [guardCurrentChat])
// Kind-aware preview fetch: Resolve hits /resolution-note/preview,
// Escalate hits /escalation-package/preview. They're cached separately
@@ -733,7 +742,7 @@ export default function AssistantChatPage() {
const p = effectiveKind === 'resolve'
? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId)
: await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId)
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'refreshPreview')) return
setPreviewData(p)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
@@ -745,7 +754,7 @@ export default function AssistantChatPage() {
} finally {
setPreviewLoading(false)
}
}, [previewKind])
}, [guardCurrentChat, previewKind])
// Trigger preview refresh with a 500ms debounce. The backend cache short-
// circuits same-state calls, but the network round-trip is still avoidable
@@ -880,7 +889,7 @@ export default function AssistantChatPage() {
}
// No draft, no template — route to the Script Builder tab.
setChatTab('script_builder')
}, [activeFix])
}, [activeFix, activeChatId])
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
@@ -1108,13 +1117,13 @@ export default function AssistantChatPage() {
// Guard: if the user switched to a different chat while this API call was
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
// clobber the new session's task lane state.
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'selectChat')) return
setActiveSessionStatus(detail.status)
setActivePsaTicketId(detail.psa_ticket_id)
if (detail.psa_ticket_id) {
integrationsApi.getTicket(detail.psa_ticket_id)
.then(ticket => {
if (currentChatRef.current !== chatId) return
if (!guardCurrentChat(chatId, 'selectChat.ticket')) return
setLinkedTicket(ticket)
})
.catch(() => {})
@@ -1149,7 +1158,7 @@ export default function AssistantChatPage() {
} catch {
setMessages([])
}
}, [refreshSessionDerived])
}, [guardCurrentChat, refreshSessionDerived, resetSessionDerivedState])
const handleAIAnalysis = useCallback(async () => {
if (!urlSessionId || !magicHandoff) return
@@ -1162,7 +1171,7 @@ export default function AssistantChatPage() {
setMagicState('dismissed')
void loadChats()
await selectChat(urlSessionId)
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.afterSelect')) return
const assessment = magicHandoff.ai_assessment_data
const snapshot = magicHandoff.snapshot as Record<string, unknown>
@@ -1192,7 +1201,7 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: briefing }])
setLoading(true)
const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing })
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.chatResponse')) return
setMessages(prev => [
...prev,
{
@@ -1233,7 +1242,7 @@ export default function AssistantChatPage() {
setActiveOptionKey(null)
setLoading(false)
}
}, [urlSessionId, magicHandoff, setSearchParams, selectChat])
}, [guardCurrentChat, urlSessionId, magicHandoff, setSearchParams, selectChat])
const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
@@ -1306,7 +1315,7 @@ export default function AssistantChatPage() {
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
})
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleSend')) return
analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [
...prev,
@@ -1396,7 +1405,7 @@ export default function AssistantChatPage() {
try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
if (!guardCurrentChat(sentForChatId, 'handleTaskSubmit')) return
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
@@ -1491,7 +1500,7 @@ export default function AssistantChatPage() {
const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt })
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== session.session_id) return
if (!guardCurrentChat(session.session_id, 'handleResumeNew')) return
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },

View File

@@ -40,7 +40,7 @@ export function MyTreesPage() {
useEffect(() => {
loadMyTrees()
}, [user?.id])
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps -- reload only when the owner identity changes
const loadMyTrees = async () => {
if (!user?.id) return

View File

@@ -118,7 +118,7 @@ export function ProceduralEditorPage() {
}
return () => { reset() }
}, [id])
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- editor init is keyed to route id; store actions are stable
useEffect(() => {
useProceduralEditorStore.getState().validate()

View File

@@ -155,7 +155,7 @@ export function ProceduralNavigationPage() {
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [treeId])
}, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- session load is keyed to route tree id
// Check for PSA connection on mount
useEffect(() => {

View File

@@ -57,7 +57,7 @@ export function SessionDetailPage() {
if (id) {
loadSession()
}
}, [id])
}, [id]) // eslint-disable-line react-hooks/exhaustive-deps -- detail reload is keyed to route session id
// Auto-show rating modal for completed sessions with library steps
useEffect(() => {

View File

@@ -269,7 +269,7 @@ export default function SessionHistoryPage() {
<PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Page heading */}
<div className="mb-6">
<div className="mb-6" data-testid="session-history-heading">
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
<p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</div>
@@ -279,6 +279,7 @@ export default function SessionHistoryPage() {
{TABS.map((tab) => (
<button
key={tab.id}
data-testid={`session-history-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-4 py-2 text-sm transition-colors whitespace-nowrap',
@@ -614,6 +615,7 @@ export default function SessionHistoryPage() {
Close
</button>
<button
data-testid="flow-session-resume"
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-white hover:brightness-110 transition-all"
>

View File

@@ -234,7 +234,7 @@ export function TreeEditorPage() {
return () => {
reset()
}
}, [id, isEditMode, canCreateTrees])
}, [id, isEditMode, canCreateTrees]) // eslint-disable-line react-hooks/exhaustive-deps -- initialization is keyed to route/editability state
// Handle unsaved changes warning
useEffect(() => {
@@ -391,7 +391,7 @@ export function TreeEditorPage() {
} finally {
setSaving(false)
}
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving])
const handlePublish = useCallback(async () => {
if (isSaving) return
@@ -472,7 +472,7 @@ export function TreeEditorPage() {
} finally {
setSaving(false)
}
}, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
}, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate, setSaving])
// Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => {

View File

@@ -292,7 +292,7 @@ export function TreeNavigationPage() {
if (treeId) {
loadTreeAndSession()
}
}, [treeId])
}, [treeId]) // eslint-disable-line react-hooks/exhaustive-deps -- route tree id is the load boundary
// Check for PSA connection on mount
useEffect(() => {