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

@@ -2,23 +2,24 @@
# HANDOFF.md # HANDOFF.md
**Last updated:** 2026-05-01 (session 8cleaned stale TODOs, wrote issue cleanup plan) **Last updated:** 2026-05-01 (session 9started issue cleanup plan sections 1 and 2)
**Active task:** None. Pick next from `.ai/TODO.md` or roadmap. **Active task:** None. Pick next from `.ai/TODO.md` or roadmap.
**Just-updated:** stale local TODOs were removed and an issue cleanup plan was added. **Just-updated:** issue cleanup plan sections 1 and 2 were started and documented.
## Where this session ended ## Where this session ended
Cleanup follow-up completed: Issue cleanup plan follow-up completed:
- `.ai/TODO.md`: removed resolved pytest-xdist "Up next" item, removed resolved claim-role-gate item, updated frontend lint count to 24 warnings. - Section 1: frontend lint is clean. Stale lint disables from the warning set were removed or replaced with justified comments, hook dependency warnings were resolved, e2e selectors were added for session history and the FlowPilot command-palette entry, and `AssistantChatPage` now logs unexpected `currentChatRef` stale async discards.
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with tracker hygiene and implementation order. - Section 2: `TaskLane` action cards now have diagnostic help affordances for common commands (connectivity, DNS, IP config, event logs, services, and generic checks). #128 was documented as "keep existing responsive side-panel/bottom-drawer behavior unless pilot feedback proves a preference is needed."
- Tried to close Gitea #127 via API, but this environment has no Gitea token; API returned `401 token is required`. - Updated `docs/plans/2026-05-01-issue-cleanup-plan.md` with section 1/2 status and validation.
- Validation passed: `docker exec -w /app resolutionflow_frontend npm run lint`, `docker exec -w /app resolutionflow_frontend npx tsc -b`, and `docker exec -w /app resolutionflow_frontend npm run build` (existing Vite large-chunk warning only).
## Resume point — DO THIS NEXT ## Resume point — DO THIS NEXT
If tracker auth is available, close #127 and close/archive stale PR #124; rewrite #66 to template packs / one-click install only. Then pick from the plan: low-risk maintenance first, then #130/#128 pilot UX friction. If tracker auth is available, close #127 and close/archive stale PR #124; rewrite #66 to template packs / one-click install only. Then continue the plan at section 3: #58 structured "step is wrong" quality signals. After that, section 4 is #60 recurring issue detection and section 5 is #129 hierarchical guide navigation.
## Environment notes (carry-forward) ## Environment notes (carry-forward)

View File

@@ -12,6 +12,16 @@
--- ---
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan ## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task. - Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.

View File

@@ -19,21 +19,28 @@ These are safe tracker updates before any feature work:
### 1. Low-Risk Maintenance ### 1. Low-Risk Maintenance
- Clean frontend lint warnings. - Status: started 2026-05-01.
- Audit/remove stale eslint-disable comments. - Frontend lint is clean after removing stale disable comments and tightening hook dependencies.
- Add missing `data-testid` selectors for e2e-critical controls. - Added `data-testid` selectors for e2e-critical session history and FlowPilot command-palette controls.
- Add observability around unexpected `currentChatRef` guard mismatches. - Added `AssistantChatPage` observability for unexpected `currentChatRef` guard mismatches so stale async discards are visible in the console.
Why first: these reduce future regression cost and are small, well-bounded changes. Why first: these reduce future regression cost and are small, well-bounded changes.
### 2. Pilot UX Friction ### 2. Pilot UX Friction
- #130: Add diagnostic command help affordances in `TaskLane` / action cards. - Status: started 2026-05-01.
- #128: Decide whether task panel placement should be configurable or whether the existing responsive drawer is enough. - #130: Added diagnostic command help affordances in `TaskLane` action cards. Each active diagnostic card can explain what it checks, what to look for, and when to use it.
- #128: Keep the existing responsive drawer behavior for now. `TaskLane` already uses a side panel on wide screens and a bottom drawer below the desktop breakpoint; do not add a top/side preference unless pilot feedback shows the current responsive layout is blocking workflow.
- EscalationQueue mobile design stays deferred until a customer asks for it. - EscalationQueue mobile design stays deferred until a customer asks for it.
Why second: this improves the current FlowPilot wedge without changing core data models. Why second: this improves the current FlowPilot wedge without changing core data models.
Validation run:
- `docker exec -w /app resolutionflow_frontend npm run lint`
- `docker exec -w /app resolutionflow_frontend npx tsc -b`
- `docker exec -w /app resolutionflow_frontend npm run build`
### 3. Workflow Quality Signals ### 3. Workflow Quality Signals
- #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness. - #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness.

View File

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

View File

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

View File

@@ -31,6 +31,62 @@ interface ActionResponse {
type TaskResponse = QuestionResponse | 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 { interface TaskLaneProps {
questions: QuestionItem[] questions: QuestionItem[]
actions: ActionItem[] actions: ActionItem[]
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
const [showRunAll, setShowRunAll] = useState(false) const [showRunAll, setShowRunAll] = useState(false)
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [copiedKey, setCopiedKey] = useState<string | null>(null) const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
// ── Resize state ── // ── Resize state ──
const DEFAULT_WIDTH = 340 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 })), 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 })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasksRef.current as unknown as Array<Record<string, unknown>>, responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent best-effort save */ }) }).catch(() => { /* silent - best-effort save */ })
}, 2000) }, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } 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 // Reset when new tasks come in from AI response — but preserve saved state
useEffect(() => { useEffect(() => {
if (sessionId) { if (sessionId) {
const saved = loadTaskState(sessionId) const saved = loadTaskState(sessionId)
if (saved && saved.length > 0) { 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) setTasks(saved)
return return
} }
} }
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
setTasks([ setTasks([
...questions.map((q): QuestionResponse => ({ ...questions.map((q): QuestionResponse => ({
type: 'question', text: q.text, context: q.context, state: 'pending', value: '', 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: '', 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>) => { const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t)) 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 ( 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 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> <div className="flex items-start justify-between gap-2">
{a.description && ( <div className="min-w-0 flex-1">
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div> <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 && ( {a.command && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5"> <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 return result
}, [query, searchFlows, searchSessions, searchAISessions, user]) }, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
// Flatten all items for keyboard navigation // Flatten all items for keyboard navigation
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items) const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
return ( return (
<button <button
key={item.id} key={item.id}
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)} onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
className={cn( 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 { FolderPlus, Check, Plus } from 'lucide-react'
import { foldersApi } from '@/api/folders' import { foldersApi } from '@/api/folders'
import type { FolderListItem } from '@/types' import type { FolderListItem } from '@/types'
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => { const loadFoldersAndAssignments = useCallback(async () => {
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 () => {
setIsLoading(true) setIsLoading(true)
try { try {
const foldersData = await foldersApi.list() const foldersData = await foldersApi.list()
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
} finally { } finally {
setIsLoading(false) 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) => { const toggleFolder = async (folderId: string) => {
try { try {

View File

@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '') 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({ export function FolderEditModal({
folder, folder,
parentId: initialParentId, parentId: initialParentId,
@@ -110,14 +118,6 @@ export function FolderEditModal({
}) })
}, [folder, folders]) }, [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(() => { useEffect(() => {
if (folder) { if (folder) {
setName(folder.name) 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 { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types' import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
import { treesApi } from '@/api/trees' import { treesApi } from '@/api/trees'
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
const [allowForking, setAllowForking] = useState(true) const [allowForking, setAllowForking] = useState(true)
const [visibility, setVisibility] = useState<TreeVisibility>('private') const [visibility, setVisibility] = useState<TreeVisibility>('private')
useEffect(() => { const loadShares = useCallback(async () => {
if (isOpen) {
loadShares()
// Reset state
setCopied(false)
setAllowForking(true)
}
}, [isOpen, tree.id])
const loadShares = async () => {
try { try {
const sharesData = await treesApi.listShares(tree.id) const sharesData = await treesApi.listShares(tree.id)
setShares(sharesData) setShares(sharesData)
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
} catch (err) { } catch (err) {
console.error('Failed to load shares:', 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 () => { const handleGenerateLink = async () => {
setIsGenerating(true) 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 { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
import { getBranding, updateBranding, deleteLogo } from '@/api/branding' import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
import type { BrandingInfo } 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 [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { const loadBranding = useCallback(async () => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
setIsLoading(true) setIsLoading(true)
try { try {
const data = await getBranding(teamId) const data = await getBranding(teamId)
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }, [teamId])
useEffect(() => {
loadBranding()
}, [loadBranding])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ export function ProceduralNavigationPage() {
return () => { return () => {
if (timerRef.current) clearInterval(timerRef.current) 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 // Check for PSA connection on mount
useEffect(() => { useEffect(() => {

View File

@@ -57,7 +57,7 @@ export function SessionDetailPage() {
if (id) { if (id) {
loadSession() 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 // Auto-show rating modal for completed sessions with library steps
useEffect(() => { useEffect(() => {

View File

@@ -269,7 +269,7 @@ export default function SessionHistoryPage() {
<PageMeta title="Sessions" /> <PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Page heading */} {/* 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> <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> <p className="mt-1 text-sm text-muted-foreground">View and manage your sessions</p>
</div> </div>
@@ -279,6 +279,7 @@ export default function SessionHistoryPage() {
{TABS.map((tab) => ( {TABS.map((tab) => (
<button <button
key={tab.id} key={tab.id}
data-testid={`session-history-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={cn( className={cn(
'px-4 py-2 text-sm transition-colors whitespace-nowrap', 'px-4 py-2 text-sm transition-colors whitespace-nowrap',
@@ -614,6 +615,7 @@ export default function SessionHistoryPage() {
Close Close
</button> </button>
<button <button
data-testid="flow-session-resume"
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })} 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" 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 () => { return () => {
reset() 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 // Handle unsaved changes warning
useEffect(() => { useEffect(() => {
@@ -391,7 +391,7 @@ export function TreeEditorPage() {
} finally { } finally {
setSaving(false) setSaving(false)
} }
}, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate]) }, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving])
const handlePublish = useCallback(async () => { const handlePublish = useCallback(async () => {
if (isSaving) return if (isSaving) return
@@ -472,7 +472,7 @@ export function TreeEditorPage() {
} finally { } finally {
setSaving(false) 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) // Keep handleSave for backward compatibility (Ctrl+S shortcut)
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {

View File

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