wip(handoff): start issue cleanup plan sections 1 and 2
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user