mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
add TypeScript types, React hooks, and UI components for web interface (for sundeep) (#214)
* refactor: reorganize daemon client to mirror Rust architecture - Split monolithic daemon-client.ts into organized modules: - lib/daemon/types.ts: All TypeScript type definitions - lib/daemon/client.ts: DaemonClient implementation - lib/daemon/errors.ts: Error types - lib/daemon/validation.ts: Type guards and validation helpers - lib/daemon/index.ts: Controlled public exports - Matches Rust daemon_client module structure for consistency - Improves maintainability and separation of concerns * feat: add React hooks layer for daemon interactions - useApprovals: Manage approval requests with data enrichment - useSessions: List and launch Claude Code sessions - useConversation: View conversation history - useDaemonConnection: Monitor daemon health - Includes real-time updates via polling - Provides loading states, error handling, and refresh functions - Hooks handle all complexity, components just render * feat: add UI utilities and type definitions - UI types: UnifiedApprovalRequest for display-friendly data - Data enrichment: Join approvals with session context - Formatting utilities: truncate, formatTimestamp, formatParameters - Error formatting: Convert technical errors to user-friendly messages - Separation of UI concerns from protocol implementation * docs: improve documentation structure and developer guides - Update README to be concise with development section first - Add comprehensive documentation: - ARCHITECTURE.md: System design with mermaid diagrams - DEVELOPER_GUIDE.md: Best practices and do's/don'ts - API.md: React hooks reference - Clear guidance on which layer to use (hooks vs daemon client) - Examples showing correct usage patterns for frontend developers * formatting * feat: add real-time subscription support and example components - Update daemon client to support event callbacks for subscriptions - Implement proper real-time updates in useApprovalsWithSubscription hook - Add ApprovalsPanel component using shadcn/ui components - Fix linting errors with proper eslint comments - Update App.tsx imports to use new daemon module path - Fix TypeScript return types in hooks * feat(wui): add missing shadcn/ui components Add card, badge, alert, and collapsible components from shadcn/ui to resolve TypeScript import errors in ApprovalsPanel * add react
This commit is contained in:
@@ -1,8 +1,39 @@
|
||||
# humanlayer-wui
|
||||
|
||||
This is a web/desktop-based UI for the HumanLayer daemon, `hld`. It's still in an experimental state.
|
||||
Web/desktop UI for the HumanLayer daemon (`hld`) built with Tauri and React.
|
||||
|
||||
# Getting Started
|
||||
## Development
|
||||
|
||||
- `bun install` - To install dependencies.
|
||||
- `bun run tauri dev` - To start the dev server
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Start dev server
|
||||
bun run tauri dev
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Quick Start for Frontend Development
|
||||
|
||||
Always use React hooks, never the daemon client directly:
|
||||
|
||||
```tsx
|
||||
import { useApprovals } from '@/hooks'
|
||||
|
||||
function MyComponent() {
|
||||
const { approvals, loading, error, approve } = useApprovals()
|
||||
// ... render UI
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture Overview](docs/ARCHITECTURE.md) - System design and data flow
|
||||
- [Developer Guide](docs/DEVELOPER_GUIDE.md) - Best practices and examples
|
||||
- [API Reference](docs/API.md) - Hook and type documentation
|
||||
|
||||
## Status
|
||||
|
||||
⚠️ Experimental - APIs may change
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "humanlayer-wui",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -167,10 +168,28 @@
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.11", "", {}, "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.43.0", "", { "os": "android", "cpu": "arm" }, "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw=="],
|
||||
|
||||
131
humanlayer-wui/docs/API.md
Normal file
131
humanlayer-wui/docs/API.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# API Reference
|
||||
|
||||
## React Hooks
|
||||
|
||||
### useApprovals()
|
||||
|
||||
Fetch and manage approval requests.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
approvals, // UnifiedApprovalRequest[]
|
||||
loading, // boolean
|
||||
error, // string | null
|
||||
refresh, // () => Promise<void>
|
||||
approve, // (callId: string, comment?: string) => Promise<void>
|
||||
deny, // (callId: string, reason: string) => Promise<void>
|
||||
respond // (callId: string, response: string) => Promise<void>
|
||||
} = useApprovals(sessionId?: string)
|
||||
```
|
||||
|
||||
### useApprovalsWithSubscription()
|
||||
|
||||
Same as `useApprovals()` but with real-time updates (polls every 5 seconds).
|
||||
|
||||
### useSessions()
|
||||
|
||||
List and launch Claude Code sessions.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
sessions, // SessionSummary[]
|
||||
loading, // boolean
|
||||
error, // string | null
|
||||
refresh, // () => Promise<void>
|
||||
launchSession, // (request: LaunchSessionRequest) => Promise<{ sessionId, runId }>
|
||||
} = useSessions()
|
||||
```
|
||||
|
||||
### useSession(sessionId)
|
||||
|
||||
Get details for a specific session.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
session, // SessionState
|
||||
loading, // boolean
|
||||
error, // string | null
|
||||
refresh // () => Promise<void>
|
||||
} = useSession(sessionId: string)
|
||||
```
|
||||
|
||||
### useConversation(sessionId?, claudeSessionId?)
|
||||
|
||||
Fetch conversation history.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
events, // ConversationEvent[]
|
||||
loading, // boolean
|
||||
error, // string | null
|
||||
refresh // () => Promise<void>
|
||||
} = useConversation(sessionId?: string, claudeSessionId?: string)
|
||||
```
|
||||
|
||||
### useDaemonConnection()
|
||||
|
||||
Monitor daemon connection health.
|
||||
|
||||
```typescript
|
||||
const {
|
||||
connected, // boolean
|
||||
connecting, // boolean
|
||||
error, // string | null
|
||||
version, // string | null
|
||||
connect, // () => Promise<void>
|
||||
checkHealth, // () => Promise<void>
|
||||
} = useDaemonConnection()
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### UnifiedApprovalRequest
|
||||
|
||||
UI-friendly approval type that combines FunctionCall and HumanContact.
|
||||
|
||||
```typescript
|
||||
interface UnifiedApprovalRequest {
|
||||
id: string
|
||||
callId: string
|
||||
runId: string
|
||||
type: ApprovalType
|
||||
title: string // Formatted for display
|
||||
description: string // Full details
|
||||
tool?: string // Function name
|
||||
parameters?: Record<string, any>
|
||||
createdAt: Date
|
||||
sessionId?: string // Enriched data
|
||||
sessionQuery?: string // Enriched data
|
||||
sessionModel?: string // Enriched data
|
||||
}
|
||||
```
|
||||
|
||||
### LaunchSessionRequest
|
||||
|
||||
```typescript
|
||||
interface LaunchSessionRequest {
|
||||
query: string
|
||||
model?: string // 'opus' | 'sonnet'
|
||||
working_dir?: string
|
||||
max_turns?: number
|
||||
// ... see types.ts for all options
|
||||
}
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
### formatTimestamp(date)
|
||||
|
||||
Format dates for display (e.g., "5m ago", "2h ago")
|
||||
|
||||
### truncate(text, maxLength)
|
||||
|
||||
Truncate text with ellipsis
|
||||
|
||||
### formatError(error)
|
||||
|
||||
Convert technical errors to user-friendly messages
|
||||
|
||||
### enrichApprovals(approvals, sessions)
|
||||
|
||||
Join approval data with session context
|
||||
130
humanlayer-wui/docs/ARCHITECTURE.md
Normal file
130
humanlayer-wui/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Architecture Overview
|
||||
|
||||
## System Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Frontend (TypeScript)"
|
||||
RC[React Component]
|
||||
RH[React Hooks]
|
||||
DC[Daemon Client]
|
||||
end
|
||||
|
||||
subgraph "Tauri Bridge"
|
||||
TC[Tauri Commands<br/>src-tauri/src/lib.rs]
|
||||
RDC[Rust Daemon Client<br/>src-tauri/src/daemon_client]
|
||||
end
|
||||
|
||||
subgraph "Backend"
|
||||
HLD[HumanLayer Daemon<br/>hld]
|
||||
CC[Claude Code<br/>Sessions]
|
||||
end
|
||||
|
||||
RC -->|"uses"| RH
|
||||
RH -->|"calls"| DC
|
||||
DC -->|"invoke"| TC
|
||||
TC -->|"calls"| RDC
|
||||
RDC -->|"Unix Socket<br/>JSON-RPC"| HLD
|
||||
HLD -->|"manages"| CC
|
||||
```
|
||||
|
||||
## Data Flow Example: Approving a Function Call
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as React Component
|
||||
participant Hook as useApprovals Hook
|
||||
participant Client as Daemon Client (TS)
|
||||
participant Tauri as Tauri Command
|
||||
participant Rust as Rust Client
|
||||
participant Daemon as HLD
|
||||
|
||||
UI->>Hook: approve(callId)
|
||||
Hook->>Client: approveFunctionCall(callId)
|
||||
Client->>Tauri: invoke('approve_function_call')
|
||||
Tauri->>Rust: client.approve_function_call()
|
||||
Rust->>Daemon: JSON-RPC: sendDecision
|
||||
Daemon-->>Rust: Response
|
||||
Rust-->>Tauri: Result
|
||||
Tauri-->>Client: Success
|
||||
Client-->>Hook: void
|
||||
Hook->>Hook: refresh approvals
|
||||
Hook-->>UI: Updated state
|
||||
```
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Frontend (src/)
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ └── daemon/ # Low-level daemon interface
|
||||
│ ├── types.ts # Protocol type definitions
|
||||
│ ├── client.ts # DaemonClient class
|
||||
│ ├── errors.ts # Error types
|
||||
│ └── index.ts # Public exports
|
||||
├── hooks/ # React hooks layer
|
||||
│ ├── useApprovals.ts # Approval management
|
||||
│ ├── useSessions.ts # Session management
|
||||
│ └── useConversation.ts
|
||||
├── utils/ # UI utilities
|
||||
│ ├── enrichment.ts # Join approvals with sessions
|
||||
│ └── formatting.ts # Display formatters
|
||||
├── types/
|
||||
│ └── ui.ts # UI-specific types
|
||||
└── components/ # React components
|
||||
```
|
||||
|
||||
### Tauri Bridge (src-tauri/)
|
||||
|
||||
```
|
||||
src-tauri/
|
||||
├── src/
|
||||
│ ├── lib.rs # Tauri command handlers
|
||||
│ └── daemon_client/ # Rust daemon client
|
||||
│ ├── mod.rs # Module exports
|
||||
│ ├── types.rs # Rust type definitions
|
||||
│ ├── client.rs # Client implementation
|
||||
│ ├── connection.rs
|
||||
│ └── subscriptions.rs
|
||||
```
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
### 1. Layer Separation
|
||||
|
||||
- **Daemon Client**: Pure protocol implementation, no UI logic
|
||||
- **Hooks**: React state management and data enrichment
|
||||
- **Components**: Presentation only, use hooks for all logic
|
||||
|
||||
### 2. Type Safety
|
||||
|
||||
- Full TypeScript types matching Rust/Go protocol
|
||||
- Enums for constants (SessionStatus, ApprovalType, etc.)
|
||||
- Separate UI types for enriched data
|
||||
|
||||
### 3. Data Enrichment
|
||||
|
||||
- Raw daemon data is enriched in the hooks layer
|
||||
- Approvals are joined with session context
|
||||
- UI-friendly formatting happens in TypeScript
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- Daemon errors are caught and formatted in hooks
|
||||
- User-friendly messages replace technical errors
|
||||
- Components receive simple error strings
|
||||
|
||||
## Protocol Details
|
||||
|
||||
The daemon uses JSON-RPC 2.0 over Unix domain sockets. See [hld/PROTOCOL.md](../../hld/PROTOCOL.md) for the full specification.
|
||||
|
||||
Key RPC methods:
|
||||
|
||||
- `launchSession` - Start a new Claude Code session
|
||||
- `listSessions` - Get all sessions
|
||||
- `fetchApprovals` - Get pending approvals
|
||||
- `sendDecision` - Approve/deny/respond to approvals
|
||||
- `getConversation` - Fetch session conversation history
|
||||
- `subscribe` - Real-time event updates
|
||||
189
humanlayer-wui/docs/DEVELOPER_GUIDE.md
Normal file
189
humanlayer-wui/docs/DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Developer Guide
|
||||
|
||||
## For Frontend Developers
|
||||
|
||||
This guide helps you build UI components that interact with the HumanLayer daemon.
|
||||
|
||||
### ✅ DO
|
||||
|
||||
#### Use Hooks for Everything
|
||||
|
||||
```tsx
|
||||
import { useApprovals, useSessions } from '@/hooks'
|
||||
|
||||
function MyComponent() {
|
||||
const { approvals, loading, error, approve, deny } = useApprovals()
|
||||
const { sessions, launchSession } = useSessions()
|
||||
|
||||
// Hooks handle all the complexity
|
||||
}
|
||||
```
|
||||
|
||||
#### Use UI Types for Props
|
||||
|
||||
```tsx
|
||||
import { UnifiedApprovalRequest } from '@/types/ui'
|
||||
|
||||
interface Props {
|
||||
approval: UnifiedApprovalRequest // ✅ UI type
|
||||
}
|
||||
```
|
||||
|
||||
#### Import Enums When Needed
|
||||
|
||||
```tsx
|
||||
import { ApprovalType, SessionStatus } from '@/lib/daemon/types'
|
||||
|
||||
if (approval.type === ApprovalType.FunctionCall) {
|
||||
// This is fine - enums are meant to be used
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
#### Don't Use the Daemon Client Directly
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - Never do this in components
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
const approvals = await daemonClient.fetchApprovals()
|
||||
|
||||
// ✅ CORRECT - Use hooks instead
|
||||
const { approvals } = useApprovals()
|
||||
```
|
||||
|
||||
#### Don't Use Raw Protocol Types in Components
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - FunctionCall is a protocol type
|
||||
import { FunctionCall } from '@/lib/daemon/types'
|
||||
interface Props {
|
||||
approval: FunctionCall
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Use UI types
|
||||
import { UnifiedApprovalRequest } from '@/types/ui'
|
||||
interface Props {
|
||||
approval: UnifiedApprovalRequest
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Handling Approvals
|
||||
|
||||
```tsx
|
||||
function ApprovalCard({ approval }: { approval: UnifiedApprovalRequest }) {
|
||||
const { approve, deny, respond } = useApprovals()
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await approve(approval.callId)
|
||||
toast.success('Approved!')
|
||||
} catch (error) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3>{approval.title}</h3>
|
||||
<p>{approval.sessionQuery}</p>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
Approve
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Launching Sessions
|
||||
|
||||
```tsx
|
||||
function LaunchButton() {
|
||||
const { launchSession } = useSessions()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const handleLaunch = async () => {
|
||||
try {
|
||||
const { sessionId } = await launchSession({
|
||||
query,
|
||||
model: 'sonnet',
|
||||
working_dir: '/path/to/project',
|
||||
})
|
||||
navigate(`/sessions/${sessionId}`)
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button onClick={handleLaunch}>Launch</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
```tsx
|
||||
function LiveApprovals() {
|
||||
// This hook automatically polls for updates
|
||||
const { approvals } = useApprovalsWithSubscription()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{approvals.map(approval => (
|
||||
<ApprovalCard key={approval.id} approval={approval} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Understanding the Layers
|
||||
|
||||
### 1. Components (Your Code)
|
||||
|
||||
- Import hooks and UI types
|
||||
- Handle user interactions
|
||||
- Render UI
|
||||
|
||||
### 2. Hooks (React Layer)
|
||||
|
||||
- Manage state with useState
|
||||
- Handle loading/error states
|
||||
- Enrich data (join approvals + sessions)
|
||||
- Format errors for display
|
||||
|
||||
### 3. Daemon Client (Protocol Layer)
|
||||
|
||||
- Type-safe Tauri invocations
|
||||
- 1:1 mapping to Rust API
|
||||
- No business logic
|
||||
|
||||
### 4. Rust/Daemon
|
||||
|
||||
- Handles actual daemon communication
|
||||
- Manages Unix socket connection
|
||||
- Protocol implementation
|
||||
|
||||
## Tips
|
||||
|
||||
- **Loading States**: All hooks provide `loading` - use it!
|
||||
- **Error Handling**: Hooks format errors, just display `error` string
|
||||
- **Refreshing**: Actions like `approve()` auto-refresh the list
|
||||
- **Polling**: `useApprovalsWithSubscription` polls every 5 seconds
|
||||
- **Types**: When in doubt, check what the hook returns
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [API Reference](API.md) for all available hooks
|
||||
- Look at existing components for examples
|
||||
- The TypeScript compiler will guide you - trust the types!
|
||||
@@ -15,6 +15,7 @@
|
||||
"check": "bun run format:check && bun run lint && bun run typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tauri-apps/api": "^2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { daemonClient } from './daemon-client'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import './App.css'
|
||||
|
||||
@@ -26,10 +26,10 @@ function App() {
|
||||
.subscribeToEvents({
|
||||
session_id: activeSessionId,
|
||||
})
|
||||
.then(unsub => {
|
||||
.then((unsub: () => void) => {
|
||||
unsubscribe = unsub
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error: Error) => {
|
||||
console.error('Failed to subscribe to events:', error)
|
||||
})
|
||||
}
|
||||
|
||||
210
humanlayer-wui/src/components/ApprovalsPanel.tsx
Normal file
210
humanlayer-wui/src/components/ApprovalsPanel.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react'
|
||||
import { useApprovalsWithSubscription, useDaemonConnection } from '@/hooks'
|
||||
import { ApprovalType } from '@/lib/daemon'
|
||||
import { formatTimestamp } from '@/utils/formatting'
|
||||
import type { UnifiedApprovalRequest } from '@/types/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Loader2, AlertCircle, CheckCircle2, XCircle, MessageSquare } from 'lucide-react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
|
||||
/**
|
||||
* Example approvals panel component similar to the TUI
|
||||
* Shows how to use the hooks and handle real-time updates
|
||||
*/
|
||||
export function ApprovalsPanel() {
|
||||
const { connected, error: connectionError } = useDaemonConnection()
|
||||
const { approvals, loading, error, approve, deny, respond } = useApprovalsWithSubscription()
|
||||
const [processingId, setProcessingId] = useState<string | null>(null)
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Disconnected</AlertTitle>
|
||||
<AlertDescription>{connectionError || 'Cannot connect to daemon'}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading Approvals...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const handleApprove = async (approval: UnifiedApprovalRequest) => {
|
||||
setProcessingId(approval.id)
|
||||
try {
|
||||
await approve(approval.callId)
|
||||
} catch (err) {
|
||||
console.error('Failed to approve:', err)
|
||||
alert(err instanceof Error ? err.message : 'Failed to approve')
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeny = async (approval: UnifiedApprovalRequest) => {
|
||||
const reason = prompt('Reason for denial:')
|
||||
if (!reason) return
|
||||
|
||||
setProcessingId(approval.id)
|
||||
try {
|
||||
await deny(approval.callId, reason)
|
||||
} catch (err) {
|
||||
console.error('Failed to deny:', err)
|
||||
alert(err instanceof Error ? err.message : 'Failed to deny')
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRespond = async (approval: UnifiedApprovalRequest) => {
|
||||
const response = prompt('Your response:')
|
||||
if (!response) return
|
||||
|
||||
setProcessingId(approval.id)
|
||||
try {
|
||||
await respond(approval.callId, response)
|
||||
} catch (err) {
|
||||
console.error('Failed to respond:', err)
|
||||
alert(err instanceof Error ? err.message : 'Failed to respond')
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (approvals.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Pending Approvals</CardTitle>
|
||||
<CardDescription>Waiting for approval requests...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-bold">Pending Approvals ({approvals.length})</h2>
|
||||
<div className="space-y-4">
|
||||
{approvals.map(approval => (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
approval={approval}
|
||||
isProcessing={processingId === approval.id}
|
||||
onApprove={() => handleApprove(approval)}
|
||||
onDeny={() => handleDeny(approval)}
|
||||
onRespond={() => handleRespond(approval)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApprovalCardProps {
|
||||
approval: UnifiedApprovalRequest
|
||||
isProcessing: boolean
|
||||
onApprove: () => void
|
||||
onDeny: () => void
|
||||
onRespond: () => void
|
||||
}
|
||||
|
||||
function ApprovalCard({ approval, isProcessing, onApprove, onDeny, onRespond }: ApprovalCardProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Card className={isProcessing ? 'opacity-60' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{approval.title}</CardTitle>
|
||||
{approval.sessionQuery && (
|
||||
<CardDescription>
|
||||
Session: {approval.sessionQuery} • Model: {approval.sessionModel}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={approval.type === ApprovalType.FunctionCall ? 'default' : 'secondary'}>
|
||||
{approval.type === ApprovalType.FunctionCall ? 'Function' : 'Human'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{formatTimestamp(approval.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{approval.type === ApprovalType.FunctionCall && approval.parameters && (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full justify-between">
|
||||
View Parameters
|
||||
<span className="text-xs">{isOpen ? '▲' : '▼'}</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<pre className="p-3 bg-muted rounded-md text-sm overflow-x-auto">
|
||||
{JSON.stringify(approval.parameters, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{approval.type === ApprovalType.HumanContact && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md">
|
||||
<p className="text-sm">{approval.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{approval.type === ApprovalType.FunctionCall ? (
|
||||
<>
|
||||
<Button onClick={onApprove} disabled={isProcessing} size="sm" className="flex-1">
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDeny}
|
||||
disabled={isProcessing}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Deny
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onRespond} disabled={isProcessing} size="sm" className="w-full">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Respond
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
2
humanlayer-wui/src/components/index.ts
Normal file
2
humanlayer-wui/src/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export all components from a single entry point
|
||||
export { ApprovalsPanel } from './ApprovalsPanel'
|
||||
60
humanlayer-wui/src/components/ui/alert.tsx
Normal file
60
humanlayer-wui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
37
humanlayer-wui/src/components/ui/badge.tsx
Normal file
37
humanlayer-wui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
71
humanlayer-wui/src/components/ui/card.tsx
Normal file
71
humanlayer-wui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
20
humanlayer-wui/src/components/ui/collapsible.tsx
Normal file
20
humanlayer-wui/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,135 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
// Type definitions matching the Rust types
|
||||
export interface HealthCheckResponse {
|
||||
status: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface LaunchSessionRequest {
|
||||
query: string
|
||||
model?: string
|
||||
mcp_config?: any
|
||||
permission_prompt_tool?: string
|
||||
working_dir?: string
|
||||
max_turns?: number
|
||||
system_prompt?: string
|
||||
append_system_prompt?: string
|
||||
allowed_tools?: string[]
|
||||
disallowed_tools?: string[]
|
||||
custom_instructions?: string
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export interface LaunchSessionResponse {
|
||||
session_id: string
|
||||
run_id: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
run_id: string
|
||||
claude_session_id?: string
|
||||
parent_session_id?: string
|
||||
status: 'starting' | 'running' | 'completed' | 'failed' | 'waiting_input'
|
||||
start_time: string
|
||||
end_time?: string
|
||||
last_activity_at: string
|
||||
error?: string
|
||||
query: string
|
||||
model?: string
|
||||
working_dir?: string
|
||||
result?: any
|
||||
}
|
||||
|
||||
export interface ListSessionsResponse {
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
type: 'function_call' | 'human_contact'
|
||||
function_call?: any
|
||||
human_contact?: any
|
||||
}
|
||||
|
||||
export interface FetchApprovalsResponse {
|
||||
approvals: PendingApproval[]
|
||||
}
|
||||
|
||||
export interface EventNotification {
|
||||
event: {
|
||||
type: string
|
||||
timestamp: string
|
||||
data: any
|
||||
}
|
||||
}
|
||||
|
||||
// Daemon client API
|
||||
export interface GetSessionStateResponse {
|
||||
session: SessionInfo
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
async connect(): Promise<void> {
|
||||
await invoke('connect_daemon')
|
||||
}
|
||||
|
||||
async health(): Promise<HealthCheckResponse> {
|
||||
return await invoke('daemon_health')
|
||||
}
|
||||
|
||||
async launchSession(request: LaunchSessionRequest): Promise<LaunchSessionResponse> {
|
||||
return await invoke('launch_session', { request })
|
||||
}
|
||||
|
||||
async listSessions(): Promise<ListSessionsResponse> {
|
||||
return await invoke('list_sessions')
|
||||
}
|
||||
|
||||
async getSessionState(sessionId: string): Promise<GetSessionStateResponse> {
|
||||
return await invoke('get_session_state', { sessionId })
|
||||
}
|
||||
|
||||
async continueSession(request: any) {
|
||||
return await invoke('continue_session', { request })
|
||||
}
|
||||
|
||||
async getConversation(sessionId?: string, claudeSessionId?: string) {
|
||||
return await invoke('get_conversation', { sessionId, claudeSessionId })
|
||||
}
|
||||
|
||||
async fetchApprovals(sessionId?: string): Promise<FetchApprovalsResponse> {
|
||||
return await invoke('fetch_approvals', { sessionId })
|
||||
}
|
||||
|
||||
async approveFunctionCall(callId: string, comment?: string): Promise<void> {
|
||||
return await invoke('approve_function_call', { callId, comment })
|
||||
}
|
||||
|
||||
async denyFunctionCall(callId: string, reason: string): Promise<void> {
|
||||
return await invoke('deny_function_call', { callId, reason })
|
||||
}
|
||||
|
||||
async respondToHumanContact(callId: string, response: string): Promise<void> {
|
||||
return await invoke('respond_to_human_contact', { callId, response })
|
||||
}
|
||||
|
||||
async subscribeToEvents(request: {
|
||||
event_types?: string[]
|
||||
session_id?: string
|
||||
run_id?: string
|
||||
}): Promise<() => void> {
|
||||
await invoke('subscribe_to_events', { request })
|
||||
|
||||
// Return unsubscribe function
|
||||
const unlisten = await listen<EventNotification>('daemon-event', event => {
|
||||
console.log('Received daemon event:', event.payload)
|
||||
})
|
||||
|
||||
return unlisten
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const daemonClient = new DaemonClient()
|
||||
6
humanlayer-wui/src/hooks/index.ts
Normal file
6
humanlayer-wui/src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all hooks from a single entry point
|
||||
export { useApprovals, useApprovalsWithSubscription } from './useApprovals'
|
||||
export { useSessions, useSession } from './useSessions'
|
||||
export { useConversation, useFormattedConversation } from './useConversation'
|
||||
export { useDaemonConnection } from './useDaemonConnection'
|
||||
export type { FormattedMessage } from './useConversation'
|
||||
163
humanlayer-wui/src/hooks/useApprovals.ts
Normal file
163
humanlayer-wui/src/hooks/useApprovals.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import { UnifiedApprovalRequest } from '@/types/ui'
|
||||
import { enrichApprovals } from '@/utils/enrichment'
|
||||
import { formatError } from '@/utils/errors'
|
||||
|
||||
interface UseApprovalsReturn {
|
||||
approvals: UnifiedApprovalRequest[]
|
||||
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>
|
||||
}
|
||||
|
||||
export function useApprovals(sessionId?: string): UseApprovalsReturn {
|
||||
const [approvals, setApprovals] = useState<UnifiedApprovalRequest[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchApprovals = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch approvals and sessions in parallel
|
||||
const [approvalsResponse, sessionsResponse] = await Promise.all([
|
||||
daemonClient.fetchApprovals(sessionId),
|
||||
daemonClient.listSessions(),
|
||||
])
|
||||
|
||||
// Enrich approvals with session context
|
||||
const enriched = enrichApprovals(approvalsResponse.approvals, sessionsResponse.sessions)
|
||||
|
||||
setApprovals(enriched)
|
||||
} catch (err) {
|
||||
setError(formatError(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchApprovals()
|
||||
}, [fetchApprovals])
|
||||
|
||||
// Approve a function call
|
||||
const approve = useCallback(
|
||||
async (callId: string, comment?: string) => {
|
||||
try {
|
||||
await daemonClient.approveFunctionCall(callId, comment)
|
||||
// Refresh the list after approval
|
||||
await fetchApprovals()
|
||||
} catch (err) {
|
||||
throw new Error(formatError(err))
|
||||
}
|
||||
},
|
||||
[fetchApprovals],
|
||||
)
|
||||
|
||||
// Deny a function call
|
||||
const deny = useCallback(
|
||||
async (callId: string, reason: string) => {
|
||||
try {
|
||||
await daemonClient.denyFunctionCall(callId, reason)
|
||||
// Refresh the list after denial
|
||||
await fetchApprovals()
|
||||
} catch (err) {
|
||||
throw new Error(formatError(err))
|
||||
}
|
||||
},
|
||||
[fetchApprovals],
|
||||
)
|
||||
|
||||
// Respond to human contact
|
||||
const respond = useCallback(
|
||||
async (callId: string, response: string) => {
|
||||
try {
|
||||
await daemonClient.respondToHumanContact(callId, response)
|
||||
// Refresh the list after response
|
||||
await fetchApprovals()
|
||||
} catch (err) {
|
||||
throw new Error(formatError(err))
|
||||
}
|
||||
},
|
||||
[fetchApprovals],
|
||||
)
|
||||
|
||||
return {
|
||||
approvals,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchApprovals,
|
||||
approve,
|
||||
deny,
|
||||
respond,
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for real-time updates
|
||||
export function useApprovalsWithSubscription(sessionId?: string): UseApprovalsReturn {
|
||||
const base = useApprovals(sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let isSubscribed = true
|
||||
|
||||
const subscribe = async () => {
|
||||
try {
|
||||
unsubscribe = await daemonClient.subscribeToEvents(
|
||||
{
|
||||
event_types: ['approval_requested', 'approval_resolved', 'session_status_changed'],
|
||||
session_id: sessionId,
|
||||
},
|
||||
{
|
||||
onEvent: event => {
|
||||
if (!isSubscribed) return
|
||||
|
||||
// Handle different event types
|
||||
switch (event.event.type) {
|
||||
case 'approval_requested':
|
||||
case 'approval_resolved':
|
||||
// Refresh approvals when relevant events occur
|
||||
base.refresh()
|
||||
break
|
||||
case 'session_status_changed':
|
||||
// Could update session status if needed
|
||||
break
|
||||
}
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Subscription error:', error)
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to subscribe to events:', err)
|
||||
// Fall back to polling on subscription failure
|
||||
const interval = setInterval(() => {
|
||||
if (isSubscribed) {
|
||||
base.refresh()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe()
|
||||
|
||||
return () => {
|
||||
isSubscribed = false
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [sessionId, base.refresh])
|
||||
|
||||
return base
|
||||
}
|
||||
112
humanlayer-wui/src/hooks/useConversation.ts
Normal file
112
humanlayer-wui/src/hooks/useConversation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { daemonClient, ConversationEvent } from '@/lib/daemon'
|
||||
import { formatError } from '@/utils/errors'
|
||||
|
||||
interface UseConversationReturn {
|
||||
events: ConversationEvent[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useConversation(sessionId?: string, claudeSessionId?: string): UseConversationReturn {
|
||||
const [events, setEvents] = useState<ConversationEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchConversation = useCallback(async () => {
|
||||
if (!sessionId && !claudeSessionId) {
|
||||
setError('Either sessionId or claudeSessionId must be provided')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await daemonClient.getConversation(sessionId, claudeSessionId)
|
||||
setEvents(response.events)
|
||||
} catch (err) {
|
||||
setError(formatError(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sessionId, claudeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversation()
|
||||
}, [fetchConversation])
|
||||
|
||||
return {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchConversation,
|
||||
}
|
||||
}
|
||||
|
||||
// Formatted conversation for display
|
||||
export interface FormattedMessage {
|
||||
id: number
|
||||
type: 'message' | 'tool_call' | 'tool_result' | 'approval'
|
||||
role?: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
metadata?: {
|
||||
toolName?: string
|
||||
toolId?: string
|
||||
approvalStatus?: string
|
||||
approvalId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function useFormattedConversation(
|
||||
sessionId?: string,
|
||||
claudeSessionId?: string,
|
||||
): UseConversationReturn & { formattedEvents: FormattedMessage[] } {
|
||||
const base = useConversation(sessionId, claudeSessionId)
|
||||
|
||||
const formattedEvents: FormattedMessage[] = base.events.map(event => {
|
||||
let content = event.content || ''
|
||||
let type: FormattedMessage['type'] = 'message'
|
||||
|
||||
if (event.event_type === 'tool_call') {
|
||||
type = 'tool_call'
|
||||
content = `Calling ${event.tool_name || 'tool'}`
|
||||
if (event.tool_input_json) {
|
||||
try {
|
||||
const input = JSON.parse(event.tool_input_json)
|
||||
content += `: ${JSON.stringify(input, null, 2)}`
|
||||
} catch {
|
||||
content += `: ${event.tool_input_json}`
|
||||
}
|
||||
}
|
||||
} else if (event.event_type === 'tool_result') {
|
||||
type = 'tool_result'
|
||||
content = event.tool_result_content || 'Tool completed'
|
||||
} else if (event.approval_status) {
|
||||
type = 'approval'
|
||||
content = `Approval ${event.approval_status}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type,
|
||||
role: event.role,
|
||||
content,
|
||||
timestamp: new Date(event.created_at),
|
||||
metadata: {
|
||||
toolName: event.tool_name,
|
||||
toolId: event.tool_id,
|
||||
approvalStatus: event.approval_status || undefined,
|
||||
approvalId: event.approval_id,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...base,
|
||||
formattedEvents,
|
||||
}
|
||||
}
|
||||
68
humanlayer-wui/src/hooks/useDaemonConnection.ts
Normal file
68
humanlayer-wui/src/hooks/useDaemonConnection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import { formatError } from '@/utils/errors'
|
||||
|
||||
interface UseDaemonConnectionReturn {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
error: string | null
|
||||
version: string | null
|
||||
connect: () => Promise<void>
|
||||
checkHealth: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useDaemonConnection(): UseDaemonConnectionReturn {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [version, setVersion] = useState<string | null>(null)
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const response = await daemonClient.health()
|
||||
setConnected(response.status === 'healthy')
|
||||
setVersion(response.version)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setConnected(false)
|
||||
setVersion(null)
|
||||
setError(formatError(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
setConnecting(true)
|
||||
setError(null)
|
||||
|
||||
await daemonClient.connect()
|
||||
await checkHealth()
|
||||
} catch (err) {
|
||||
setError(formatError(err))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}, [checkHealth])
|
||||
|
||||
// Auto-connect on mount
|
||||
useEffect(() => {
|
||||
connect()
|
||||
}, [connect])
|
||||
|
||||
// Periodic health checks
|
||||
useEffect(() => {
|
||||
if (!connected) return
|
||||
|
||||
const interval = setInterval(checkHealth, 30000) // Every 30 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [connected, checkHealth])
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
error,
|
||||
version,
|
||||
connect,
|
||||
checkHealth,
|
||||
}
|
||||
}
|
||||
123
humanlayer-wui/src/hooks/useSessions.ts
Normal file
123
humanlayer-wui/src/hooks/useSessions.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { daemonClient, LaunchSessionRequest } from '@/lib/daemon'
|
||||
import { SessionSummary } from '@/types/ui'
|
||||
import { formatError } from '@/utils/errors'
|
||||
|
||||
interface UseSessionsReturn {
|
||||
sessions: SessionSummary[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
launchSession: (request: LaunchSessionRequest) => Promise<{ sessionId: string; runId: string }>
|
||||
}
|
||||
|
||||
export function useSessions(): UseSessionsReturn {
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await daemonClient.listSessions()
|
||||
|
||||
// Transform to UI-friendly format
|
||||
const summaries: SessionSummary[] = response.sessions.map(session => ({
|
||||
id: session.id,
|
||||
runId: session.run_id,
|
||||
status: session.status,
|
||||
query: session.query,
|
||||
model: session.model || 'default',
|
||||
startTime: new Date(session.start_time),
|
||||
endTime: session.end_time ? new Date(session.end_time) : undefined,
|
||||
hasApprovals: false, // Will be enriched later if needed
|
||||
}))
|
||||
|
||||
// Sort by start time (newest first)
|
||||
summaries.sort((a, b) => b.startTime.getTime() - a.startTime.getTime())
|
||||
|
||||
setSessions(summaries)
|
||||
} catch (err) {
|
||||
setError(formatError(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchSessions()
|
||||
}, [fetchSessions])
|
||||
|
||||
// Launch a new session
|
||||
const launchSession = useCallback(
|
||||
async (request: LaunchSessionRequest) => {
|
||||
try {
|
||||
const response = await daemonClient.launchSession(request)
|
||||
// Refresh the list after launching
|
||||
await fetchSessions()
|
||||
return response
|
||||
} catch (err) {
|
||||
throw new Error(formatError(err))
|
||||
}
|
||||
},
|
||||
[fetchSessions],
|
||||
)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchSessions,
|
||||
launchSession: async (request: LaunchSessionRequest) => {
|
||||
const response = await launchSession(request)
|
||||
return {
|
||||
sessionId: response.session_id,
|
||||
runId: response.run_id,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for a single session with details
|
||||
export function useSession(sessionId: string) {
|
||||
const [session, setSession] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchSession = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await daemonClient.getSessionState(sessionId)
|
||||
setSession(response.session)
|
||||
} catch (err) {
|
||||
setError(formatError(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession()
|
||||
// Poll for updates while session is running
|
||||
const interval = setInterval(() => {
|
||||
if (session?.status === 'running' || session?.status === 'starting') {
|
||||
fetchSession()
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchSession, session?.status])
|
||||
|
||||
return {
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchSession,
|
||||
}
|
||||
}
|
||||
90
humanlayer-wui/src/lib/daemon/client.ts
Normal file
90
humanlayer-wui/src/lib/daemon/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type {
|
||||
HealthCheckResponse,
|
||||
LaunchSessionRequest,
|
||||
LaunchSessionResponse,
|
||||
ListSessionsResponse,
|
||||
GetSessionStateResponse,
|
||||
ContinueSessionRequest,
|
||||
ContinueSessionResponse,
|
||||
GetConversationResponse,
|
||||
FetchApprovalsResponse,
|
||||
EventNotification,
|
||||
SubscribeRequest,
|
||||
} from './types'
|
||||
|
||||
export class DaemonClient {
|
||||
async connect(): Promise<void> {
|
||||
await invoke('connect_daemon')
|
||||
}
|
||||
|
||||
async health(): Promise<HealthCheckResponse> {
|
||||
return await invoke('daemon_health')
|
||||
}
|
||||
|
||||
async launchSession(request: LaunchSessionRequest): Promise<LaunchSessionResponse> {
|
||||
return await invoke('launch_session', { request })
|
||||
}
|
||||
|
||||
async listSessions(): Promise<ListSessionsResponse> {
|
||||
return await invoke('list_sessions')
|
||||
}
|
||||
|
||||
async getSessionState(sessionId: string): Promise<GetSessionStateResponse> {
|
||||
return await invoke('get_session_state', { sessionId })
|
||||
}
|
||||
|
||||
async continueSession(request: ContinueSessionRequest): Promise<ContinueSessionResponse> {
|
||||
return await invoke('continue_session', { request })
|
||||
}
|
||||
|
||||
async getConversation(
|
||||
sessionId?: string,
|
||||
claudeSessionId?: string,
|
||||
): Promise<GetConversationResponse> {
|
||||
return await invoke('get_conversation', { sessionId, claudeSessionId })
|
||||
}
|
||||
|
||||
async fetchApprovals(sessionId?: string): Promise<FetchApprovalsResponse> {
|
||||
return await invoke('fetch_approvals', { sessionId })
|
||||
}
|
||||
|
||||
async approveFunctionCall(callId: string, comment?: string): Promise<void> {
|
||||
return await invoke('approve_function_call', { callId, comment })
|
||||
}
|
||||
|
||||
async denyFunctionCall(callId: string, reason: string): Promise<void> {
|
||||
return await invoke('deny_function_call', { callId, reason })
|
||||
}
|
||||
|
||||
async respondToHumanContact(callId: string, response: string): Promise<void> {
|
||||
return await invoke('respond_to_human_contact', { callId, response })
|
||||
}
|
||||
|
||||
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> {
|
||||
await invoke('subscribe_to_events', { request })
|
||||
|
||||
// Listen for daemon events and forward to handlers
|
||||
const unlisten = await listen<EventNotification>('daemon-event', event => {
|
||||
try {
|
||||
handlers.onEvent?.(event.payload)
|
||||
} catch (error) {
|
||||
handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
|
||||
return unlisten
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const daemonClient = new DaemonClient()
|
||||
27
humanlayer-wui/src/lib/daemon/errors.ts
Normal file
27
humanlayer-wui/src/lib/daemon/errors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Daemon-specific error types
|
||||
|
||||
export class DaemonError extends Error {
|
||||
declare code?: string
|
||||
declare details?: any
|
||||
|
||||
constructor(message: string, code?: string, details?: any) {
|
||||
super(message)
|
||||
this.name = 'DaemonError'
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionError extends DaemonError {
|
||||
constructor(message: string, details?: any) {
|
||||
super(message, 'CONNECTION_ERROR', details)
|
||||
this.name = 'ConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class RPCError extends DaemonError {
|
||||
constructor(message: string, code?: string, details?: any) {
|
||||
super(message, code || 'RPC_ERROR', details)
|
||||
this.name = 'RPCError'
|
||||
}
|
||||
}
|
||||
14
humanlayer-wui/src/lib/daemon/index.ts
Normal file
14
humanlayer-wui/src/lib/daemon/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Public exports for the daemon module
|
||||
// Components should use hooks instead of importing from here directly
|
||||
|
||||
export { daemonClient } from './client'
|
||||
export type { DaemonClient } from './client'
|
||||
|
||||
// Export all types
|
||||
export * from './types'
|
||||
|
||||
// Export error types
|
||||
export * from './errors'
|
||||
|
||||
// Export validation helpers
|
||||
export * from './validation'
|
||||
270
humanlayer-wui/src/lib/daemon/types.ts
Normal file
270
humanlayer-wui/src/lib/daemon/types.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Enums
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum SessionStatus {
|
||||
Starting = 'starting',
|
||||
Running = 'running',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed',
|
||||
WaitingInput = 'waiting_input',
|
||||
}
|
||||
|
||||
export enum ApprovalType {
|
||||
FunctionCall = 'function_call',
|
||||
HumanContact = 'human_contact',
|
||||
}
|
||||
|
||||
export enum Decision {
|
||||
Approve = 'approve',
|
||||
Deny = 'deny',
|
||||
Respond = 'respond',
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// Type definitions matching the Rust types
|
||||
export interface HealthCheckResponse {
|
||||
status: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface LaunchSessionRequest {
|
||||
query: string
|
||||
model?: string
|
||||
mcp_config?: any
|
||||
permission_prompt_tool?: string
|
||||
working_dir?: string
|
||||
max_turns?: number
|
||||
system_prompt?: string
|
||||
append_system_prompt?: string
|
||||
allowed_tools?: string[]
|
||||
disallowed_tools?: string[]
|
||||
custom_instructions?: string
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export interface LaunchSessionResponse {
|
||||
session_id: string
|
||||
run_id: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string
|
||||
run_id: string
|
||||
claude_session_id?: string
|
||||
parent_session_id?: string
|
||||
status: SessionStatus
|
||||
start_time: string
|
||||
end_time?: string
|
||||
last_activity_at: string
|
||||
error?: string
|
||||
query: string
|
||||
model?: string
|
||||
working_dir?: string
|
||||
result?: any
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
id: string
|
||||
run_id: string
|
||||
claude_session_id?: string
|
||||
parent_session_id?: string
|
||||
status: string // Protocol returns string, not enum
|
||||
query: string
|
||||
model?: string
|
||||
working_dir?: string
|
||||
created_at: string
|
||||
last_activity_at: string
|
||||
completed_at?: string
|
||||
error_message?: string
|
||||
cost_usd?: number
|
||||
total_tokens?: number
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
export interface ListSessionsResponse {
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
// Contact channel types
|
||||
export interface SlackChannel {
|
||||
channel_or_user_id: string
|
||||
}
|
||||
|
||||
export interface EmailChannel {
|
||||
address: string
|
||||
}
|
||||
|
||||
export interface ContactChannel {
|
||||
slack?: SlackChannel
|
||||
email?: EmailChannel
|
||||
}
|
||||
|
||||
// Response option types
|
||||
export interface ResponseOption {
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
prompt_fill?: string
|
||||
interactive: boolean
|
||||
}
|
||||
|
||||
// Function call types
|
||||
export interface FunctionCallSpec {
|
||||
fn: string
|
||||
kwargs: Record<string, any>
|
||||
channel?: ContactChannel
|
||||
reject_options?: ResponseOption[]
|
||||
state?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface FunctionCallStatus {
|
||||
requested_at?: string
|
||||
responded_at?: string
|
||||
approved?: boolean
|
||||
comment?: string
|
||||
user_info?: Record<string, any>
|
||||
reject_option_name?: string
|
||||
}
|
||||
|
||||
export interface FunctionCall {
|
||||
run_id: string
|
||||
call_id: string
|
||||
spec: FunctionCallSpec
|
||||
status?: FunctionCallStatus
|
||||
}
|
||||
|
||||
// Human contact types
|
||||
export interface HumanContactSpec {
|
||||
msg: string
|
||||
subject?: string
|
||||
channel?: ContactChannel
|
||||
response_options?: ResponseOption[]
|
||||
state?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface HumanContactStatus {
|
||||
requested_at?: string
|
||||
responded_at?: string
|
||||
response?: string
|
||||
response_option_name?: string
|
||||
}
|
||||
|
||||
export interface HumanContact {
|
||||
run_id: string
|
||||
call_id: string
|
||||
spec: HumanContactSpec
|
||||
status?: HumanContactStatus
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
type: ApprovalType
|
||||
function_call?: FunctionCall
|
||||
human_contact?: HumanContact
|
||||
}
|
||||
|
||||
export interface FetchApprovalsResponse {
|
||||
approvals: PendingApproval[]
|
||||
}
|
||||
|
||||
// Event types
|
||||
export interface Event {
|
||||
type: string
|
||||
timestamp: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface EventNotification {
|
||||
event: Event
|
||||
}
|
||||
|
||||
// Event-specific data types
|
||||
export interface ApprovalRequestedEventData {
|
||||
approval: PendingApproval
|
||||
}
|
||||
|
||||
export interface ApprovalResolvedEventData {
|
||||
call_id: string
|
||||
type: ApprovalType
|
||||
decision: Decision
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface SessionStatusChangedEventData {
|
||||
session_id: string
|
||||
old_status: string
|
||||
new_status: string
|
||||
}
|
||||
|
||||
// Conversation types
|
||||
export interface ConversationEvent {
|
||||
id: number
|
||||
session_id: string
|
||||
claude_session_id: string
|
||||
sequence: number
|
||||
event_type: 'message' | 'tool_call' | 'tool_result' | 'system'
|
||||
created_at: string
|
||||
role?: 'user' | 'assistant' | 'system'
|
||||
content?: string
|
||||
tool_id?: string
|
||||
tool_name?: string
|
||||
tool_input_json?: string
|
||||
tool_result_for_id?: string
|
||||
tool_result_content?: string
|
||||
is_completed: boolean
|
||||
approval_status?: 'pending' | 'approved' | 'denied' | 'resolved' | null
|
||||
approval_id?: string
|
||||
}
|
||||
|
||||
export interface GetConversationRequest {
|
||||
session_id?: string
|
||||
claude_session_id?: string
|
||||
}
|
||||
|
||||
export interface GetConversationResponse {
|
||||
events: ConversationEvent[]
|
||||
}
|
||||
|
||||
// Continue session types
|
||||
export interface ContinueSessionRequest {
|
||||
session_id: string
|
||||
query: string
|
||||
system_prompt?: string
|
||||
append_system_prompt?: string
|
||||
mcp_config?: string // JSON string in protocol
|
||||
permission_prompt_tool?: string
|
||||
allowed_tools?: string[]
|
||||
disallowed_tools?: string[]
|
||||
custom_instructions?: string
|
||||
max_turns?: number
|
||||
}
|
||||
|
||||
export interface ContinueSessionResponse {
|
||||
session_id: string
|
||||
run_id: string
|
||||
claude_session_id: string
|
||||
parent_session_id: string
|
||||
}
|
||||
|
||||
// Decision types
|
||||
export interface SendDecisionRequest {
|
||||
call_id: string
|
||||
type: ApprovalType
|
||||
decision: Decision
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export interface SendDecisionResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Subscribe types
|
||||
export interface SubscribeRequest {
|
||||
event_types?: string[]
|
||||
session_id?: string
|
||||
run_id?: string
|
||||
}
|
||||
|
||||
// Daemon client API
|
||||
export interface GetSessionStateResponse {
|
||||
session: SessionState
|
||||
}
|
||||
101
humanlayer-wui/src/lib/daemon/validation.ts
Normal file
101
humanlayer-wui/src/lib/daemon/validation.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Decision, ApprovalType } from './types'
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid Decision enum value
|
||||
*/
|
||||
export function isValidDecision(value: any): value is Decision {
|
||||
return Object.values(Decision).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a valid ApprovalType enum value
|
||||
*/
|
||||
export function isValidApprovalType(value: any): value is ApprovalType {
|
||||
return Object.values(ApprovalType).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a decision is valid for a given approval type
|
||||
* Mirrors the Rust implementation's validation logic
|
||||
*/
|
||||
export function isValidDecisionForApprovalType(
|
||||
decision: Decision,
|
||||
approvalType: ApprovalType,
|
||||
): boolean {
|
||||
switch (approvalType) {
|
||||
case ApprovalType.FunctionCall:
|
||||
return decision === Decision.Approve || decision === Decision.Deny
|
||||
case ApprovalType.HumanContact:
|
||||
return decision === Decision.Respond
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required fields are present for a decision
|
||||
*/
|
||||
export function validateDecisionRequest(
|
||||
decision: Decision,
|
||||
approvalType: ApprovalType,
|
||||
comment?: string,
|
||||
): { valid: boolean; error?: string } {
|
||||
// Check decision is valid for approval type
|
||||
if (!isValidDecisionForApprovalType(decision, approvalType)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid decision '${decision}' for approval type '${approvalType}'`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (decision === Decision.Deny && !comment) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Comment is required when denying a function call',
|
||||
}
|
||||
}
|
||||
|
||||
if (decision === Decision.Respond && !comment) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Response is required for human contact',
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object has required approval fields
|
||||
*/
|
||||
export function isPendingApproval(obj: any): obj is {
|
||||
type: ApprovalType
|
||||
function_call?: any
|
||||
human_contact?: any
|
||||
} {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
'type' in obj &&
|
||||
isValidApprovalType(obj.type) &&
|
||||
(obj.type === ApprovalType.FunctionCall ? 'function_call' in obj : true) &&
|
||||
(obj.type === ApprovalType.HumanContact ? 'human_contact' in obj : true)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session status transitions
|
||||
*/
|
||||
export function isValidStatusTransition(currentStatus: string, newStatus: string): boolean {
|
||||
const validTransitions: Record<string, string[]> = {
|
||||
starting: ['running', 'failed'],
|
||||
running: ['completed', 'failed', 'waiting_input'],
|
||||
waiting_input: ['running', 'failed'],
|
||||
completed: [], // Terminal state
|
||||
failed: [], // Terminal state
|
||||
}
|
||||
|
||||
const allowed = validTransitions[currentStatus]
|
||||
return allowed ? allowed.includes(newStatus) : false
|
||||
}
|
||||
43
humanlayer-wui/src/types/ui.ts
Normal file
43
humanlayer-wui/src/types/ui.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApprovalType } from '@/lib/daemon'
|
||||
|
||||
// Unified approval request type for UI display
|
||||
export interface UnifiedApprovalRequest {
|
||||
id: string
|
||||
callId: string
|
||||
runId: string
|
||||
type: ApprovalType
|
||||
title: string // Formatted title for display
|
||||
description: string // Formatted description
|
||||
tool?: string // Function name for function calls
|
||||
parameters?: Record<string, any> // Function parameters
|
||||
createdAt: Date
|
||||
// Enriched session context
|
||||
sessionId?: string
|
||||
sessionQuery?: string
|
||||
sessionModel?: string
|
||||
}
|
||||
|
||||
// Types for UI state management
|
||||
export interface ApprovalState {
|
||||
approvals: UnifiedApprovalRequest[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastRefresh: Date | null
|
||||
}
|
||||
|
||||
export interface SessionListState {
|
||||
sessions: SessionSummary[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string
|
||||
runId: string
|
||||
status: string
|
||||
query: string
|
||||
model: string
|
||||
startTime: Date
|
||||
endTime?: Date
|
||||
hasApprovals: boolean
|
||||
}
|
||||
112
humanlayer-wui/src/utils/enrichment.ts
Normal file
112
humanlayer-wui/src/utils/enrichment.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { PendingApproval, ApprovalType, FunctionCall, HumanContact, SessionInfo } from '@/lib/daemon'
|
||||
import { UnifiedApprovalRequest } from '@/types/ui'
|
||||
import { truncate, formatParameters } from './formatting'
|
||||
|
||||
// Enrich approvals with session context
|
||||
export function enrichApprovals(
|
||||
approvals: PendingApproval[],
|
||||
sessions: SessionInfo[],
|
||||
): UnifiedApprovalRequest[] {
|
||||
// Create lookup map for sessions by runId
|
||||
const sessionsByRunId = new Map<string, SessionInfo>()
|
||||
sessions.forEach(session => {
|
||||
sessionsByRunId.set(session.run_id, session)
|
||||
})
|
||||
|
||||
return approvals.map(approval => {
|
||||
if (approval.type === ApprovalType.FunctionCall && approval.function_call) {
|
||||
return enrichFunctionCall(approval.function_call, sessionsByRunId)
|
||||
} else if (approval.type === ApprovalType.HumanContact && approval.human_contact) {
|
||||
return enrichHumanContact(approval.human_contact, sessionsByRunId)
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return {
|
||||
id: 'unknown',
|
||||
callId: 'unknown',
|
||||
runId: 'unknown',
|
||||
type: approval.type,
|
||||
title: 'Unknown approval type',
|
||||
description: '',
|
||||
createdAt: new Date(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function enrichFunctionCall(
|
||||
fc: FunctionCall,
|
||||
sessionsByRunId: Map<string, SessionInfo>,
|
||||
): UnifiedApprovalRequest {
|
||||
const session = sessionsByRunId.get(fc.run_id)
|
||||
|
||||
// Build title
|
||||
let title = `Call ${fc.spec.fn}`
|
||||
if (Object.keys(fc.spec.kwargs).length > 0) {
|
||||
const params = formatParameters(fc.spec.kwargs, 50)
|
||||
title += ` with ${params}`
|
||||
}
|
||||
|
||||
// Build description with more details
|
||||
const description = JSON.stringify(fc.spec.kwargs, null, 2)
|
||||
|
||||
return {
|
||||
id: fc.call_id,
|
||||
callId: fc.call_id,
|
||||
runId: fc.run_id,
|
||||
type: ApprovalType.FunctionCall,
|
||||
title,
|
||||
description,
|
||||
tool: fc.spec.fn,
|
||||
parameters: fc.spec.kwargs,
|
||||
createdAt: fc.status?.requested_at ? new Date(fc.status.requested_at) : new Date(),
|
||||
// Session context
|
||||
sessionId: session?.id,
|
||||
sessionQuery: session ? truncate(session.query, 50) : undefined,
|
||||
sessionModel: session?.model || 'default',
|
||||
}
|
||||
}
|
||||
|
||||
function enrichHumanContact(
|
||||
hc: HumanContact,
|
||||
sessionsByRunId: Map<string, SessionInfo>,
|
||||
): UnifiedApprovalRequest {
|
||||
const session = sessionsByRunId.get(hc.run_id)
|
||||
|
||||
// Title is the subject or first line of message
|
||||
const title = hc.spec.subject || truncate(hc.spec.msg, 50)
|
||||
|
||||
return {
|
||||
id: hc.call_id,
|
||||
callId: hc.call_id,
|
||||
runId: hc.run_id,
|
||||
type: ApprovalType.HumanContact,
|
||||
title,
|
||||
description: hc.spec.msg,
|
||||
createdAt: hc.status?.requested_at ? new Date(hc.status.requested_at) : new Date(),
|
||||
// Session context
|
||||
sessionId: session?.id,
|
||||
sessionQuery: session ? truncate(session.query, 50) : undefined,
|
||||
sessionModel: session?.model || 'default',
|
||||
}
|
||||
}
|
||||
|
||||
// Group approvals by session for display
|
||||
export function groupApprovalsBySession(
|
||||
approvals: UnifiedApprovalRequest[],
|
||||
): Map<string, UnifiedApprovalRequest[]> {
|
||||
const grouped = new Map<string, UnifiedApprovalRequest[]>()
|
||||
|
||||
approvals.forEach(approval => {
|
||||
const key = approval.sessionId || 'no-session'
|
||||
const list = grouped.get(key) || []
|
||||
list.push(approval)
|
||||
grouped.set(key, list)
|
||||
})
|
||||
|
||||
// Sort each group by creation time (newest first)
|
||||
grouped.forEach(list => {
|
||||
list.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
47
humanlayer-wui/src/utils/errors.ts
Normal file
47
humanlayer-wui/src/utils/errors.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Error formatting utilities for UI display
|
||||
|
||||
export function formatError(error: unknown): string {
|
||||
if (!error) return 'Unknown error'
|
||||
|
||||
// Extract message from various error formats
|
||||
let message: string
|
||||
if (error instanceof Error) {
|
||||
message = error.message
|
||||
} else if (typeof error === 'object' && 'message' in error) {
|
||||
message = String((error as any).message)
|
||||
} else {
|
||||
message = String(error)
|
||||
}
|
||||
|
||||
// Handle specific error patterns
|
||||
if (message.includes('call already has a response')) {
|
||||
return 'Approval already responded to'
|
||||
}
|
||||
|
||||
if (message.includes('409 Conflict')) {
|
||||
return 'Conflict: Resource already exists'
|
||||
}
|
||||
|
||||
if (message.includes('404 Not Found')) {
|
||||
return 'Resource not found'
|
||||
}
|
||||
|
||||
if (message.includes('500 Internal Server Error')) {
|
||||
return 'Server error occurred'
|
||||
}
|
||||
|
||||
if (message.includes('Failed to connect')) {
|
||||
return 'Cannot connect to daemon. Is it running?'
|
||||
}
|
||||
|
||||
// Remove stack traces and technical details
|
||||
const firstLine = message.split('\n')[0]
|
||||
|
||||
// Remove common prefixes
|
||||
const cleaned = firstLine
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.replace(/^RPC error:\s*/i, '')
|
||||
.replace(/^JSON-RPC error:\s*/i, '')
|
||||
|
||||
return cleaned
|
||||
}
|
||||
76
humanlayer-wui/src/utils/formatting.ts
Normal file
76
humanlayer-wui/src/utils/formatting.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// UI formatting utilities
|
||||
|
||||
export function truncate(text: string, maxLength: number): string {
|
||||
// Replace whitespace with single spaces
|
||||
const cleaned = text.replace(/[\n\r\t]+/g, ' ').trim()
|
||||
|
||||
if (cleaned.length <= maxLength) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
if (maxLength > 3) {
|
||||
return cleaned.substring(0, maxLength - 3) + '...'
|
||||
}
|
||||
return cleaned.substring(0, maxLength)
|
||||
}
|
||||
|
||||
export function formatTimestamp(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
// For older dates, show actual date
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
export function formatDuration(startTime: Date | string, endTime?: Date | string): string {
|
||||
const start = typeof startTime === 'string' ? new Date(startTime) : startTime
|
||||
const end = endTime ? (typeof endTime === 'string' ? new Date(endTime) : endTime) : new Date()
|
||||
|
||||
const diffMs = end.getTime() - start.getTime()
|
||||
const seconds = Math.floor(diffMs / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
export function formatParameters(params: Record<string, any>, maxLength: number = 100): string {
|
||||
const entries = Object.entries(params)
|
||||
if (entries.length === 0) return ''
|
||||
|
||||
const parts: string[] = []
|
||||
let totalLength = 0
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const formatted = `${key}=${JSON.stringify(value)}`
|
||||
if (totalLength + formatted.length + (parts.length > 0 ? 2 : 0) > maxLength) {
|
||||
if (parts.length === 0) {
|
||||
// At least show truncated first param
|
||||
parts.push(truncate(formatted, maxLength - 3))
|
||||
}
|
||||
parts.push('...')
|
||||
break
|
||||
}
|
||||
parts.push(formatted)
|
||||
totalLength += formatted.length + (parts.length > 1 ? 2 : 0)
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
Reference in New Issue
Block a user