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:
Allison Durham
2025-06-16 12:10:41 -07:00
committed by GitHub
parent e599fa4773
commit 11a988a068
28 changed files with 2160 additions and 142 deletions

View File

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

View File

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

View 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

View 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!

View File

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

View File

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

View 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>
)
}

View File

@@ -0,0 +1,2 @@
// Export all components from a single entry point
export { ApprovalsPanel } from './ApprovalsPanel'

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

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

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

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

View File

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

View 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'

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

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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()

View 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'
}
}

View 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'

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

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

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

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

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

View 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(', ')
}