Merge branch 'main' into pr/Adonis0123/313
This commit is contained in:
@@ -1,12 +1,20 @@
|
|||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { type SessionRecord, getSession } from '@/lib/db'
|
import { type SessionRecord, deleteSession, getSession } from '@/lib/db'
|
||||||
|
|
||||||
import { EventCard } from './cards'
|
import { EventCard } from './cards'
|
||||||
|
|
||||||
export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
|
export function HistoryDetail({
|
||||||
|
sessionId,
|
||||||
|
onBack,
|
||||||
|
onRerun,
|
||||||
|
}: {
|
||||||
|
sessionId: string
|
||||||
|
onBack: () => void
|
||||||
|
onRerun: (task: string) => void
|
||||||
|
}) {
|
||||||
const [session, setSession] = useState<SessionRecord | null>(null)
|
const [session, setSession] = useState<SessionRecord | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,6 +45,27 @@ export function HistoryDetail({ sessionId, onBack }: { sessionId: string; onBack
|
|||||||
<div className="text-xs font-medium" title={session.task}>
|
<div className="text-xs font-medium" title={session.task}>
|
||||||
{session.task}
|
{session.task}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRerun(session.task)}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Run again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteSession(sessionId)
|
||||||
|
onBack()
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Events (read-only) */}
|
{/* Events (read-only) */}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ArrowDownToLine, ArrowLeft, CheckCircle, Trash2, XCircle } from 'lucide-react'
|
import { ArrowDownToLine, ArrowLeft, CheckCircle, RotateCcw, Trash2, XCircle } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { downloadHistoryExport } from '@/lib/history-export'
|
|
||||||
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
import { type SessionRecord, clearSessions, deleteSession, listSessions } from '@/lib/db'
|
||||||
|
import { downloadHistoryExport } from '@/lib/history-export'
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const seconds = Math.floor((Date.now() - ts) / 1000)
|
const seconds = Math.floor((Date.now() - ts) / 1000)
|
||||||
@@ -19,9 +19,11 @@ function timeAgo(ts: number): string {
|
|||||||
export function HistoryList({
|
export function HistoryList({
|
||||||
onSelect,
|
onSelect,
|
||||||
onBack,
|
onBack,
|
||||||
|
onRerun,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
onRerun: (task: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
const [sessions, setSessions] = useState<SessionRecord[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -47,6 +49,11 @@ export function HistoryList({
|
|||||||
downloadHistoryExport(session.task, session.createdAt, session.history)
|
downloadHistoryExport(session.task, session.createdAt, session.history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRerun = (e: React.MouseEvent, task: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRerun(task)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-background">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -91,7 +98,12 @@ export function HistoryList({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => onSelect(session.id)}
|
onClick={() => onSelect(session.id)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(session.id)}
|
onKeyDown={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect(session.id)
|
||||||
|
}}
|
||||||
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
className="w-full text-left px-3 py-2.5 border-b hover:bg-muted/50 transition-colors cursor-pointer flex items-start gap-2 group"
|
||||||
>
|
>
|
||||||
{/* Status icon */}
|
{/* Status icon */}
|
||||||
@@ -104,30 +116,40 @@ export function HistoryList({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs font-medium truncate">{session.task}</p>
|
<p className="text-xs font-medium truncate">{session.task}</p>
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex items-center mt-0.5">
|
||||||
{timeAgo(session.createdAt)} · {session.history.length} steps
|
<p className="text-[10px] text-muted-foreground">
|
||||||
</p>
|
{timeAgo(session.createdAt)} · {session.history.length} steps
|
||||||
</div>
|
</p>
|
||||||
|
<div className="flex items-center gap-0.5 ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={(e) => handleRerun(e, session.task)}
|
||||||
onClick={(e) => handleExport(e, session)}
|
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
title="Run task again"
|
||||||
title="Export history JSON"
|
aria-label={`Run history task again: ${session.task}`}
|
||||||
aria-label={`Export history for ${session.task}`}
|
>
|
||||||
>
|
<RotateCcw className="size-3" />
|
||||||
<ArrowDownToLine className="size-3" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={(e) => handleExport(e, session)}
|
||||||
onClick={(e) => handleDelete(e, session.id)}
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:text-destructive cursor-pointer"
|
title="Export history JSON"
|
||||||
title="Delete history"
|
aria-label={`Export history for ${session.task}`}
|
||||||
aria-label={`Delete history for ${session.task}`}
|
>
|
||||||
>
|
<ArrowDownToLine className="size-3" />
|
||||||
<Trash2 className="size-3" />
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDelete(e, session.id)}
|
||||||
|
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||||
|
title="Delete history"
|
||||||
|
aria-label={`Delete history for ${session.task}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -56,19 +56,27 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [history, activity])
|
}, [history, activity])
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const runTask = useCallback(
|
||||||
(e?: React.SyntheticEvent) => {
|
(task: string) => {
|
||||||
e?.preventDefault()
|
const normalizedTask = task.trim()
|
||||||
if (!inputValue.trim() || status === 'running') return
|
if (!normalizedTask || status === 'running') return
|
||||||
|
|
||||||
const taskToExecute = inputValue.trim()
|
|
||||||
setInputValue('')
|
setInputValue('')
|
||||||
|
setView({ name: 'chat' })
|
||||||
|
|
||||||
execute(taskToExecute).catch((error) => {
|
execute(normalizedTask).catch((error) => {
|
||||||
console.error('[SidePanel] Failed to execute task:', error)
|
console.error('[SidePanel] Failed to execute task:', error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[inputValue, status, execute]
|
[execute, status]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e?: React.SyntheticEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
runTask(inputValue)
|
||||||
|
},
|
||||||
|
[inputValue, runTask]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
@@ -103,12 +111,19 @@ export default function App() {
|
|||||||
<HistoryList
|
<HistoryList
|
||||||
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
onSelect={(id) => setView({ name: 'history-detail', sessionId: id })}
|
||||||
onBack={() => setView({ name: 'chat' })}
|
onBack={() => setView({ name: 'chat' })}
|
||||||
|
onRerun={runTask}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (view.name === 'history-detail') {
|
if (view.name === 'history-detail') {
|
||||||
return <HistoryDetail sessionId={view.sessionId} onBack={() => setView({ name: 'history' })} />
|
return (
|
||||||
|
<HistoryDetail
|
||||||
|
sessionId={view.sessionId}
|
||||||
|
onBack={() => setView({ name: 'history' })}
|
||||||
|
onRerun={runTask}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Chat view ---
|
// --- Chat view ---
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ function blurLastClickedElement() {
|
|||||||
lastClickedElement.dispatchEvent(
|
lastClickedElement.dispatchEvent(
|
||||||
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
new MouseEvent('mouseout', { bubbles: true, cancelable: true })
|
||||||
)
|
)
|
||||||
|
lastClickedElement.dispatchEvent(
|
||||||
|
new MouseEvent('mouseleave', { bubbles: false, cancelable: true })
|
||||||
|
)
|
||||||
lastClickedElement = null
|
lastClickedElement = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,9 +122,12 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
|||||||
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
// - Monaco/CodeMirror: Require direct JS instance access. No universal way to obtain.
|
||||||
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
|
// - Draft.js: Not responsive to synthetic/execCommand/Range/DataTransfer. Unmaintained.
|
||||||
//
|
//
|
||||||
|
// Strategy: Try Plan A (synthetic events) first, then verify and fall back
|
||||||
|
// to Plan B (execCommand) if the text wasn't actually inserted.
|
||||||
|
//
|
||||||
// Plan A: Dispatch synthetic events
|
// Plan A: Dispatch synthetic events
|
||||||
// Works: LinkedIn, React contenteditable, Quill.
|
// Works: React contenteditable, Quill.
|
||||||
// Fails: Slate.js
|
// Fails: Slate.js, some contenteditable editors that ignore synthetic events.
|
||||||
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
// Sequence: beforeinput -> mutation -> input -> change -> blur
|
||||||
|
|
||||||
// Dispatch beforeinput + mutation + input for clearing
|
// Dispatch beforeinput + mutation + input for clearing
|
||||||
@@ -164,18 +170,34 @@ export async function inputTextElement(element: HTMLElement, text: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify Plan A worked by checking if the text was actually inserted
|
||||||
|
const planASucceeded = element.innerText.trim() === text.trim()
|
||||||
|
|
||||||
|
if (!planASucceeded) {
|
||||||
|
// Plan B: execCommand fallback (deprecated but widely supported)
|
||||||
|
// Works: Quill, Slate.js, react contenteditable components.
|
||||||
|
// This approach integrates with the browser's undo stack and is handled
|
||||||
|
// natively by most rich-text editors.
|
||||||
|
element.focus()
|
||||||
|
|
||||||
|
// Select all existing content and delete it
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(element)
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
|
document.execCommand('delete', false)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
|
document.execCommand('insertText', false, text)
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch change event (for good measure)
|
// Dispatch change event (for good measure)
|
||||||
element.dispatchEvent(new Event('change', { bubbles: true }))
|
element.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
|
||||||
// Trigger blur for validation
|
// Trigger blur for validation
|
||||||
element.blur()
|
element.blur()
|
||||||
|
|
||||||
// Plan B: execCommand (deprecated but works better for some editors)
|
|
||||||
// Works: LinkedIn, Quill, Slate.js, react contenteditable components
|
|
||||||
//
|
|
||||||
// document.execCommand('selectAll')
|
|
||||||
// document.execCommand('delete')
|
|
||||||
// document.execCommand('insertText', false, text)
|
|
||||||
} else if (element instanceof HTMLTextAreaElement) {
|
} else if (element instanceof HTMLTextAreaElement) {
|
||||||
nativeTextAreaValueSetter.call(element, text)
|
nativeTextAreaValueSetter.call(element, text)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user