4f80ec1459
- Upgrade eslint and @eslint/js to v10 - Replace eslint-plugin-react-x + eslint-plugin-react-dom + eslint-plugin-react-hooks with unified @eslint-react/eslint-plugin - Raise dev Node.js requirement to ^22.13.0 || >=24 (runtime packages unaffected) - Add .npmrc with engine-strict=true - Move all @eslint-react rule overrides to eslint.config.js, eliminating plugin-specific inline eslint-disable comments - Fix real issues caught by new rules: useless assignments, leaked setTimeout, ref naming, useState setter naming
228 lines
5.9 KiB
TypeScript
228 lines
5.9 KiB
TypeScript
import { History, Send, Settings, Square } from 'lucide-react'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { ConfigPanel } from '@/components/ConfigPanel'
|
|
import { HistoryDetail } from '@/components/HistoryDetail'
|
|
import { HistoryList } from '@/components/HistoryList'
|
|
import { ActivityCard, EventCard } from '@/components/cards'
|
|
import { EmptyState, Logo, MotionOverlay, StatusDot } from '@/components/misc'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from '@/components/ui/input-group'
|
|
import { saveSession } from '@/lib/db'
|
|
|
|
import { useAgent } from '../../agent/useAgent'
|
|
|
|
type View =
|
|
| { name: 'chat' }
|
|
| { name: 'config' }
|
|
| { name: 'history' }
|
|
| { name: 'history-detail'; sessionId: string }
|
|
|
|
export default function App() {
|
|
const [view, setView] = useState<View>({ name: 'chat' })
|
|
const [inputValue, setInputValue] = useState('')
|
|
const historyRef = useRef<HTMLDivElement>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent()
|
|
|
|
// Persist session when task finishes
|
|
const prevStatusRef = useRef(status)
|
|
useEffect(() => {
|
|
const prev = prevStatusRef.current
|
|
prevStatusRef.current = status
|
|
|
|
if (
|
|
prev === 'running' &&
|
|
(status === 'completed' || status === 'error') &&
|
|
history.length > 0 &&
|
|
currentTask
|
|
) {
|
|
saveSession({ task: currentTask, history, status }).catch((err) =>
|
|
console.error('[SidePanel] Failed to save session:', err)
|
|
)
|
|
}
|
|
}, [status, history, currentTask])
|
|
|
|
// Auto-scroll to bottom on new events
|
|
useEffect(() => {
|
|
if (historyRef.current) {
|
|
historyRef.current.scrollTop = historyRef.current.scrollHeight
|
|
}
|
|
}, [history, activity])
|
|
|
|
const runTask = useCallback(
|
|
(task: string) => {
|
|
const normalizedTask = task.trim()
|
|
if (!normalizedTask || status === 'running') return
|
|
|
|
setInputValue('')
|
|
setView({ name: 'chat' })
|
|
|
|
execute(normalizedTask).catch((error) => {
|
|
console.error('[SidePanel] Failed to execute task:', error)
|
|
})
|
|
},
|
|
[execute, status]
|
|
)
|
|
|
|
const handleSubmit = useCallback(
|
|
(e?: React.SyntheticEvent) => {
|
|
e?.preventDefault()
|
|
runTask(inputValue)
|
|
},
|
|
[inputValue, runTask]
|
|
)
|
|
|
|
const handleStop = useCallback(() => {
|
|
console.log('[SidePanel] Stopping task...')
|
|
stop()
|
|
}, [stop])
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
e.preventDefault()
|
|
handleSubmit()
|
|
}
|
|
}
|
|
|
|
// --- View routing ---
|
|
|
|
if (view.name === 'config') {
|
|
return (
|
|
<ConfigPanel
|
|
config={config}
|
|
onSave={async (newConfig) => {
|
|
await configure(newConfig)
|
|
setView({ name: 'chat' })
|
|
}}
|
|
onClose={() => setView({ name: 'chat' })}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (view.name === 'history') {
|
|
return (
|
|
<HistoryList
|
|
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
|
onBack={() => setView({ name: 'chat' })}
|
|
onRerun={runTask}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (view.name === 'history-detail') {
|
|
return (
|
|
<HistoryDetail
|
|
sessionId={view.sessionId}
|
|
onBack={() => setView({ name: 'history' })}
|
|
onRerun={runTask}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// --- Chat view ---
|
|
|
|
const isRunning = status === 'running'
|
|
const showEmptyState = !currentTask && history.length === 0 && !isRunning
|
|
|
|
return (
|
|
<div className="relative flex flex-col h-screen bg-background">
|
|
<MotionOverlay active={isRunning} />
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between border-b px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Logo className="size-5" />
|
|
<span className="text-sm font-medium">Page Agent Ext</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<StatusDot status={status} />
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setView({ name: 'history' })}
|
|
className="cursor-pointer"
|
|
>
|
|
<History className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setView({ name: 'config' })}
|
|
className="cursor-pointer"
|
|
>
|
|
<Settings className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="flex-1 overflow-hidden flex flex-col">
|
|
{/* Current task */}
|
|
{currentTask && (
|
|
<div className="border-b px-3 py-2 bg-muted/30">
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">Task</div>
|
|
<div className="text-xs font-medium truncate" title={currentTask}>
|
|
{currentTask}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* History */}
|
|
<div ref={historyRef} className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
{showEmptyState && <EmptyState />}
|
|
|
|
{history.map((event, index) => (
|
|
<EventCard key={index} event={event} />
|
|
))}
|
|
|
|
{/* Activity indicator at bottom */}
|
|
{activity && <ActivityCard activity={activity} />}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Input */}
|
|
<footer className="border-t p-3">
|
|
<InputGroup className="relative rounded-lg">
|
|
<InputGroupTextarea
|
|
ref={textareaRef}
|
|
placeholder="Describe your task... (Enter to send)"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={isRunning}
|
|
className="text-xs pr-12 min-h-10"
|
|
/>
|
|
<InputGroupAddon align="inline-end" className="absolute bottom-0 right-0">
|
|
{isRunning ? (
|
|
<InputGroupButton
|
|
size="icon-sm"
|
|
variant="destructive"
|
|
onClick={handleStop}
|
|
className="size-7"
|
|
>
|
|
<Square className="size-3" />
|
|
</InputGroupButton>
|
|
) : (
|
|
<InputGroupButton
|
|
size="icon-sm"
|
|
variant="default"
|
|
onClick={() => handleSubmit()}
|
|
disabled={!inputValue.trim()}
|
|
className="size-7 cursor-pointer"
|
|
>
|
|
<Send className="size-3" />
|
|
</InputGroupButton>
|
|
)}
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|