mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Meta View for Session Detail (#220)
* Flesh out additional tool events (compact) * spacing adjusts * meta view handling * tweaks to precommit * make setup changes
This commit is contained in:
@@ -17,10 +17,11 @@ repos:
|
||||
exclude: ^examples/
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: "v3.0.3"
|
||||
hooks:
|
||||
- id: prettier
|
||||
# Prettier disabled for now, no longer supported and breaking
|
||||
# - repo: https://github.com/pre-commit/mirrors-prettier
|
||||
# rev: "v3.0.3"
|
||||
# hooks:
|
||||
# - id: prettier
|
||||
# - repo: https://github.com/tcort/markdown-link-check
|
||||
# rev: "v3.12.2"
|
||||
# hooks:
|
||||
|
||||
4
hlyr/package-lock.json
generated
4
hlyr/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "humanlayer",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "humanlayer",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanlayer/sdk": "^0.7.7",
|
||||
|
||||
@@ -12,8 +12,6 @@ export function ModeToggle() {
|
||||
setTheme(isDark ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
console.log('theme', theme)
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" className="cursor-pointer" onClick={handleClick}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent } from '../ui/card'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useFormattedConversation, useConversation } from '@/hooks/useConversation'
|
||||
import { Skeleton } from '../ui/skeleton'
|
||||
import { Suspense, useEffect, useRef } from 'react'
|
||||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, MessageCircleDashed, Wrench } from 'lucide-react'
|
||||
|
||||
/* I, Sundeep, don't know how I feel about what's going on here. */
|
||||
@@ -19,6 +19,7 @@ interface SessionDetailProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/* This will almost certainly become something else over time, but for the moment while we get a feel for the data, this is okay */
|
||||
function eventToDisplayObject(event: ConversationEvent) {
|
||||
let subject = <span>Unknown Subject</span>
|
||||
let body = null
|
||||
@@ -52,6 +53,67 @@ function eventToDisplayObject(event: ConversationEvent) {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Task') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">{toolInput.description}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'TodoWrite') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
const todos = toolInput.todos
|
||||
const completedCount = todos.filter((todo: any) => todo.status === 'completed').length
|
||||
const pendingCount = todos.filter((todo: any) => todo.status === 'pending').length
|
||||
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">Update TODOs </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{completedCount} completed, {pendingCount} pending
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Edit') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">Edit to {toolInput.file_path}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'MultiEdit') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{toolInput.edits.length} edit{toolInput.edits.length === 1 ? '' : 's'} to{' '}
|
||||
{toolInput.file_path}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Grep') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">{toolInput.pattern}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
console.log('tool call raw event', event)
|
||||
}
|
||||
|
||||
if (event.event_type === ConversationEventType.Message) {
|
||||
@@ -87,15 +149,129 @@ function eventToDisplayObject(event: ConversationEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function ConversationContent({ sessionId }: { sessionId: string }) {
|
||||
function EventMetaInfo({ event }: { event: ConversationEvent }) {
|
||||
return (
|
||||
<div className="bg-muted/20 rounded p-4 mt-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Event ID:</span>
|
||||
<span className="ml-2 font-mono">{event.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Sequence:</span>
|
||||
<span className="ml-2 font-mono">{event.sequence}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Type:</span>
|
||||
<span className="ml-2 font-mono">{event.event_type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Role:</span>
|
||||
<span className="ml-2 font-mono">{event.role || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Created:</span>
|
||||
<span className="ml-2 font-mono text-xs">{new Date(event.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Completed:</span>
|
||||
<span className="ml-2">{event.is_completed ? '✓' : '⏳'}</span>
|
||||
</div>
|
||||
{event.tool_name && (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Tool:</span>
|
||||
<span className="ml-2 font-mono">{event.tool_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Tool ID:</span>
|
||||
<span className="ml-2 font-mono text-xs">{event.tool_id}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{event.approval_status && (
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Approval:</span>
|
||||
<span className="ml-2 font-mono">{event.approval_status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{event.tool_input_json && (
|
||||
<div className="mt-3">
|
||||
<span className="font-medium text-muted-foreground">Tool Input:</span>
|
||||
<pre className="mt-1 text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(JSON.parse(event.tool_input_json), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.tool_result_content && (
|
||||
<div className="mt-3">
|
||||
<span className="font-medium text-muted-foreground">Tool Result:</span>
|
||||
<pre className="mt-1 text-xs bg-background rounded p-2 overflow-x-auto max-h-32 overflow-y-auto">
|
||||
{event.tool_result_content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationContent({
|
||||
sessionId,
|
||||
focusedEventId,
|
||||
setFocusedEventId,
|
||||
expandedEventId,
|
||||
setExpandedEventId,
|
||||
isWideView,
|
||||
}: {
|
||||
sessionId: string
|
||||
focusedEventId: number | null
|
||||
setFocusedEventId: (id: number | null) => void
|
||||
expandedEventId: number | null
|
||||
setExpandedEventId: (id: number | null) => void
|
||||
isWideView: boolean
|
||||
}) {
|
||||
const { formattedEvents, loading, error } = useFormattedConversation(sessionId)
|
||||
const { events } = useConversation(sessionId)
|
||||
console.log('raw events', events)
|
||||
const displayObjects = events.map(eventToDisplayObject)
|
||||
const nonEmptyDisplayObjects = displayObjects.filter(displayObject => displayObject !== null)
|
||||
|
||||
console.log('formattedEvents', formattedEvents)
|
||||
console.log('raw events', events)
|
||||
console.log('displayObjects', displayObjects)
|
||||
// Navigation handlers
|
||||
const focusNextEvent = () => {
|
||||
if (nonEmptyDisplayObjects.length === 0) return
|
||||
|
||||
const currentIndex = focusedEventId
|
||||
? nonEmptyDisplayObjects.findIndex(obj => obj.id === focusedEventId)
|
||||
: -1
|
||||
|
||||
if (currentIndex === -1 || currentIndex === nonEmptyDisplayObjects.length - 1) {
|
||||
setFocusedEventId(nonEmptyDisplayObjects[0].id)
|
||||
} else {
|
||||
setFocusedEventId(nonEmptyDisplayObjects[currentIndex + 1].id)
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviousEvent = () => {
|
||||
if (nonEmptyDisplayObjects.length === 0) return
|
||||
|
||||
const currentIndex = focusedEventId
|
||||
? nonEmptyDisplayObjects.findIndex(obj => obj.id === focusedEventId)
|
||||
: -1
|
||||
|
||||
if (currentIndex === -1 || currentIndex === 0) {
|
||||
setFocusedEventId(nonEmptyDisplayObjects[nonEmptyDisplayObjects.length - 1].id)
|
||||
} else {
|
||||
setFocusedEventId(nonEmptyDisplayObjects[currentIndex - 1].id)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useHotkeys('j', focusNextEvent)
|
||||
useHotkeys('k', focusPreviousEvent)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -139,23 +315,36 @@ function ConversationContent({ sessionId }: { sessionId: string }) {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="max-h-[calc(100vh-375px)] overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
{nonEmptyDisplayObjects.map((displayObject, index) => (
|
||||
<div
|
||||
key={displayObject.id}
|
||||
className={`pb-4 ${index !== nonEmptyDisplayObjects.length - 1 ? 'border-b' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{displayObject.iconComponent && (
|
||||
<span className="text-sm text-accent">{displayObject.iconComponent}</span>
|
||||
)}
|
||||
<div key={displayObject.id}>
|
||||
<div
|
||||
onMouseEnter={() => setFocusedEventId(displayObject.id)}
|
||||
onMouseLeave={() => setFocusedEventId(null)}
|
||||
onClick={() =>
|
||||
setExpandedEventId(expandedEventId === displayObject.id ? null : displayObject.id)
|
||||
}
|
||||
className={`py-3 px-2 cursor-pointer ${
|
||||
index !== nonEmptyDisplayObjects.length - 1 ? 'border-b' : ''
|
||||
} ${focusedEventId === displayObject.id ? '!bg-accent/20 -mx-2 px-4 rounded' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{displayObject.iconComponent && (
|
||||
<span className="text-sm text-accent">{displayObject.iconComponent}</span>
|
||||
)}
|
||||
|
||||
<span className="whitespace-pre-wrap text-accent">{displayObject.subject}</span>
|
||||
{/* <span className="font-medium">{displayObject.role}</span> */}
|
||||
{/* <span className="text-sm text-muted-foreground">{displayObject.timestamp.toLocaleTimeString()}</span> */}
|
||||
<span className="whitespace-pre-wrap text-accent">{displayObject.subject}</span>
|
||||
{/* <span className="font-medium">{displayObject.role}</span> */}
|
||||
{/* <span className="text-sm text-muted-foreground">{displayObject.timestamp.toLocaleTimeString()}</span> */}
|
||||
</div>
|
||||
{displayObject.body && (
|
||||
<p className="whitespace-pre-wrap text-foreground">{displayObject.body}</p>
|
||||
)}
|
||||
</div>
|
||||
{displayObject.body && (
|
||||
<p className="whitespace-pre-wrap text-foreground">{displayObject.body}</p>
|
||||
|
||||
{/* Expanded content for slim view */}
|
||||
{!isWideView && expandedEventId === displayObject.id && (
|
||||
<EventMetaInfo event={events.find(e => e.id === displayObject.id)!} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -165,8 +354,41 @@ function ConversationContent({ sessionId }: { sessionId: string }) {
|
||||
}
|
||||
|
||||
function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
useHotkeys('escape', onClose)
|
||||
console.log('session', session)
|
||||
const [focusedEventId, setFocusedEventId] = useState<number | null>(null)
|
||||
const [expandedEventId, setExpandedEventId] = useState<number | null>(null)
|
||||
const [isWideView, setIsWideView] = useState(false)
|
||||
|
||||
// Get events for sidebar access
|
||||
const { events } = useConversation(session.id)
|
||||
|
||||
// Screen width detection for responsive layout
|
||||
useEffect(() => {
|
||||
const checkScreenWidth = () => {
|
||||
setIsWideView(window.innerWidth >= 1024) // lg breakpoint
|
||||
}
|
||||
|
||||
checkScreenWidth()
|
||||
window.addEventListener('resize', checkScreenWidth)
|
||||
return () => window.removeEventListener('resize', checkScreenWidth)
|
||||
}, [])
|
||||
|
||||
// Enter key to expand/collapse focused event
|
||||
useHotkeys('enter', () => {
|
||||
if (focusedEventId) {
|
||||
setExpandedEventId(expandedEventId === focusedEventId ? null : focusedEventId)
|
||||
}
|
||||
})
|
||||
|
||||
// Clear focus/expansion on escape, then close if nothing focused
|
||||
useHotkeys('escape', () => {
|
||||
if (expandedEventId) {
|
||||
setExpandedEventId(null)
|
||||
} else if (focusedEventId) {
|
||||
setFocusedEventId(null)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
@@ -176,8 +398,8 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
{session.status} / {session.id} / {session.model}
|
||||
</small>
|
||||
</hgroup>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<div className={`flex gap-4 ${isWideView ? 'flex-row' : 'flex-col'}`}>
|
||||
<Card className={isWideView ? 'flex-1' : 'w-full'}>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -188,10 +410,26 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConversationContent sessionId={session.id} />
|
||||
<ConversationContent
|
||||
sessionId={session.id}
|
||||
focusedEventId={focusedEventId}
|
||||
setFocusedEventId={setFocusedEventId}
|
||||
expandedEventId={expandedEventId}
|
||||
setExpandedEventId={setExpandedEventId}
|
||||
isWideView={isWideView}
|
||||
/>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar for wide view */}
|
||||
{isWideView && focusedEventId && (
|
||||
<Card className="w-[40%]">
|
||||
<CardContent>
|
||||
<EventMetaInfo event={events.find(e => e.id === focusedEventId)!} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -50,7 +50,6 @@ export function ThemeProvider({
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
console.log('setting theme', theme)
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
|
||||
@@ -9,11 +9,11 @@ interface UseApprovalsReturn {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
approve: (callId: string, comment?: string) => Promise<void>
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
deny: (callId: string, reason: string) => Promise<void>
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
respond: (callId: string, response: string) => Promise<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ interface UseSessionsReturn {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
launchSession: (request: LaunchSessionRequest) => Promise<{ sessionId: string; runId: string }>
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,8 @@ export class DaemonClient {
|
||||
async subscribeToEvents(
|
||||
request: SubscribeRequest,
|
||||
handlers: {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
onEvent?: (event: EventNotification) => void
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
onError?: (error: Error) => void
|
||||
} = {},
|
||||
): Promise<() => void> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Enums
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
export enum SessionStatus {
|
||||
Starting = 'starting',
|
||||
Running = 'running',
|
||||
@@ -38,7 +38,6 @@ export enum ApprovalStatus {
|
||||
Denied = 'denied',
|
||||
Resolved = 'resolved',
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// Type definitions matching the Rust types
|
||||
export interface HealthCheckResponse {
|
||||
|
||||
4
uv.lock
generated
4
uv.lock
generated
@@ -280,7 +280,7 @@ name = "click"
|
||||
version = "8.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
|
||||
wheels = [
|
||||
@@ -690,7 +690,7 @@ version = "1.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "colorama", marker = "platform_system == 'Windows'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "ghp-import" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
|
||||
Reference in New Issue
Block a user