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:
Sundeep Malladi
2025-06-17 13:54:12 -05:00
committed by GitHub
parent cffb88f62e
commit b28f14a80f
10 changed files with 277 additions and 43 deletions

View File

@@ -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:

View File

@@ -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",

View File

@@ -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" />

View File

@@ -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>
)

View File

@@ -50,7 +50,6 @@ export function ThemeProvider({
const value = {
theme,
setTheme: (theme: Theme) => {
console.log('setting theme', theme)
localStorage.setItem(storageKey, theme)
setTheme(theme)
},

View File

@@ -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>
}

View File

@@ -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 }>
}

View File

@@ -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> {

View File

@@ -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
View File

@@ -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" },