wip(handoff): start issue cleanup plan sections 1 and 2
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -16,7 +16,7 @@ function App() {
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [fetchUser, isAuthenticated, setLoading])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -261,7 +261,7 @@ export function TreeCanvas() {
|
||||
})
|
||||
setExpandedNodeId(null)
|
||||
},
|
||||
[pendingLinks, treeStructure, updateNode]
|
||||
[addNode, pendingLinks, treeStructure, updateNode]
|
||||
)
|
||||
|
||||
// ── Cancel new node ──
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user