wip(handoff): start issue cleanup plan sections 1 and 2
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -2,23 +2,24 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-01 (session 8 — cleaned stale TODOs, wrote issue cleanup plan)
|
||||
**Last updated:** 2026-05-01 (session 9 — started issue cleanup plan sections 1 and 2)
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with tracker hygiene and implementation order.
|
||||
- Tried to close Gitea #127 via API, but this environment has no Gitea token; API returned `401 token is required`.
|
||||
- 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.
|
||||
- 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."
|
||||
- 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
|
||||
|
||||
@@ -19,21 +19,28 @@ These are safe tracker updates before any feature work:
|
||||
|
||||
### 1. Low-Risk Maintenance
|
||||
|
||||
- Clean frontend lint warnings.
|
||||
- Audit/remove stale eslint-disable comments.
|
||||
- Add missing `data-testid` selectors for e2e-critical controls.
|
||||
- Add observability around unexpected `currentChatRef` guard mismatches.
|
||||
- Status: started 2026-05-01.
|
||||
- Frontend lint is clean after removing stale disable comments and tightening hook dependencies.
|
||||
- Added `data-testid` selectors for e2e-critical session history and FlowPilot command-palette controls.
|
||||
- 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.
|
||||
|
||||
### 2. Pilot UX Friction
|
||||
|
||||
- #130: Add diagnostic command help affordances in `TaskLane` / action cards.
|
||||
- #128: Decide whether task panel placement should be configurable or whether the existing responsive drawer is enough.
|
||||
- Status: started 2026-05-01.
|
||||
- #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.
|
||||
|
||||
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
|
||||
|
||||
- #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness.
|
||||
|
||||
@@ -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