mirror of
https://github.com/humanlayer/humanlayer.git
synced 2025-08-20 19:01:22 +03:00
Add first user message injection to session detail message stream
- Create synthetic ConversationEvent from session.query to show first user message - Add User icon for user role messages in eventToDisplayObject function - Inject first user message at beginning of conversation events list - Resolve merge conflicts in SessionTable.tsx to combine search highlighting with collapsible queries - Fix TypeScript and linting issues with proper imports and types - Ensure consistent message display with timestamps and proper formatting The first user message (session.query) now appears in the conversation stream instead of only being shown as a truncated header, providing better context for understanding the conversation flow. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -178,6 +178,3 @@ cython_debug/
|
||||
.Trashes
|
||||
|
||||
.idea/
|
||||
|
||||
# HumanLayer thoughts directory
|
||||
/thoughts/
|
||||
|
||||
152
CLAUDE.md
152
CLAUDE.md
@@ -2,112 +2,84 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 🚨 MANDATORY PERSONA SELECTION
|
||||
## Repository Overview
|
||||
|
||||
**CRITICAL: You MUST adopt one of the specialized personas before proceeding with any work.**
|
||||
This is a monorepo containing two distinct but interconnected project groups:
|
||||
|
||||
**BEFORE DOING ANYTHING ELSE**, you must read and adopt one of these personas:
|
||||
**Project 1: HumanLayer SDK & Platform** - The core product providing human-in-the-loop capabilities for AI agents
|
||||
**Project 2: Local Tools Suite** - Tools that leverage HumanLayer SDK to provide rich approval experiences
|
||||
|
||||
1. **Developer Agent** - Read `.multiclaude/personas/agent-developer.md` - For coding, debugging, and implementation tasks
|
||||
2. **Code Reviewer Agent** - Read `.multiclaude/personas/agent-code-reviewer.md` - For reviewing code changes and quality assurance
|
||||
3. **Rebaser Agent** - Read `.multiclaude/personas/agent-rebaser.md` - For cleaning git history and rebasing changes
|
||||
4. **Merger Agent** - Read `.multiclaude/personas/agent-merger.md` - For merging code across branches
|
||||
5. **Multiplan Manager Agent** - Read `.multiclaude/personas/agent-multiplan-manager.md` - For orchestrating parallel work and creating plans
|
||||
## Project 1: HumanLayer SDK & Platform
|
||||
|
||||
**DO NOT PROCEED WITHOUT SELECTING A PERSONA.** Each persona has specific rules, workflows, and tools that you MUST follow exactly.
|
||||
### Components
|
||||
- `humanlayer/` - Python SDK with decorators for approval flows and human interaction
|
||||
- `humanlayer-ts/` - TypeScript SDK for Node.js and browser environments
|
||||
- `humanlayer-go/` - Minimal Go client for building tools
|
||||
- `humanlayer-ts-vercel-ai-sdk/` - Specialized integration for Vercel AI SDK
|
||||
- `examples/` - Integration examples for LangChain, CrewAI, OpenAI, and other frameworks
|
||||
- `docs/` - Mintlify documentation site
|
||||
|
||||
### How to Choose Your Persona
|
||||
### Core Concepts
|
||||
- **Approval Decorators**: `@hl.require_approval()` wraps functions requiring human oversight
|
||||
- **Human as Tool**: `hl.human_as_tool()` enables AI agents to consult humans
|
||||
- **Contact Channels**: Slack, Email, CLI, and web interfaces for human interaction
|
||||
- **Multi-language Support**: Feature parity across Python, TypeScript, and Go SDKs
|
||||
|
||||
- **Asked to write code, fix bugs, or implement features?** → Use Developer Agent
|
||||
- **Asked to review code changes?** → Use Code Reviewer Agent
|
||||
- **Asked to clean git history or rebase changes?** → Use Rebaser Agent
|
||||
- **Asked to merge branches or consolidate work?** → Use Merger Agent
|
||||
- **Asked to coordinate multiple tasks, build plans, or manage parallel work?** → Use Multiplan Manager Agent
|
||||
## Project 2: Local Tools Suite
|
||||
|
||||
### Core Principles (All Personas)
|
||||
### Components
|
||||
- `hld/` - Go daemon that coordinates approvals and manages Claude Code sessions
|
||||
- `hlyr/` - TypeScript CLI with MCP (Model Context Protocol) server for Claude integration
|
||||
- `humanlayer-tui/` - Terminal UI (Go + Bubble Tea) for managing approvals
|
||||
- `humanlayer-wui/` - Desktop/Web UI (Tauri + React) for graphical approval management
|
||||
- `claudecode-go/` - Go SDK for programmatically launching Claude Code sessions
|
||||
|
||||
1. **READ FIRST**: Always read at least 1500 lines to understand context fully
|
||||
2. **DELETE MORE THAN YOU ADD**: Complexity compounds into disasters
|
||||
3. **FOLLOW EXISTING PATTERNS**: Don't invent new approaches
|
||||
4. **BUILD AND TEST**: Run your build and test commands after changes
|
||||
5. **COMMIT FREQUENTLY**: Every 5-10 minutes for meaningful progress
|
||||
|
||||
## 🚨 THE 1500-LINE MINIMUM READ RULE - THIS IS NOT OPTIONAL
|
||||
|
||||
### PLEASE READ AT LEAST 1500 LINES AT A TIME DONT DO PARTIAL READS
|
||||
|
||||
because you miss a lot of delicate logic which then causes you to add more bad code and compound the problem. Every LLM that reads 100 lines thinks they understand, then they ADD DUPLICATE FUNCTIONS THAT ALREADY EXIST DEEPER IN THE FILE.
|
||||
|
||||
**ONCE YOU'VE READ THE FULL FILE, YOU ALREADY UNDERSTAND EVERYTHING.** You don't need to re-read it. You have the complete context. Just write your changes directly. Trust what you learned from the full read.
|
||||
|
||||
## 📋 YOUR 20-POINT TODO LIST - YOU NEED THIS STRUCTURE
|
||||
|
||||
**LISTEN: Without a 20+ item TODO list, you'll lose track and repeat work. Other LLMs think they can remember everything - they can't. You're smarter than that.**
|
||||
|
||||
```markdown
|
||||
## Current TODO List (you MUST maintain 20+ items)
|
||||
|
||||
1. [ ] Read [filename] FULLY (1500+ lines) - you'll understand the whole flow
|
||||
2. [ ] Remove at least 10% of redundant code - it's there, you'll see it
|
||||
3. [ ] Run make check - this MUST pass before moving on
|
||||
4. [ ] Run make test - don't skip this
|
||||
5. [ ] Check specific functionality works as expected
|
||||
... (keep going to 20+ or you'll lose context like lesser models do)
|
||||
### Architecture Flow
|
||||
```
|
||||
Claude Code → MCP Protocol → hlyr → JSON-RPC → hld → HumanLayer Cloud API
|
||||
↑ ↑
|
||||
TUI ─┘ └─ WUI
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
## Development Commands
|
||||
|
||||
- `humanlayer/` - Python package source
|
||||
- `humanlayer-ts/` - TypeScript package source
|
||||
- `hlyr/` - CLI tool with integrated MCP server functionality
|
||||
- `examples/` - Framework integrations (LangChain, CrewAI, OpenAI, etc.)
|
||||
- `docs/` - Documentation site
|
||||
### Quick Actions
|
||||
- `make check-test` - Run all checks and tests
|
||||
- `make check` - Run linting and type checking
|
||||
- `make test` - Run all test suites
|
||||
|
||||
## Examples
|
||||
### Python Development
|
||||
- Uses `uv` exclusively - never use pip directly
|
||||
- Tests are co-located with source as `*_test.py` files
|
||||
- Commands: `uv sync`, `make check-py`, `make test-py`
|
||||
|
||||
The `examples/` directory contains examples of using humanlayer with major AI frameworks:
|
||||
### TypeScript Development
|
||||
- Package managers vary - check `package.json` for npm or bun
|
||||
- Build/test commands differ - check `package.json` scripts section
|
||||
- Some use Jest, others Vitest, check `package.json` devDependencies
|
||||
|
||||
- **LangChain**: Tool wrapping and agent integration
|
||||
- **CrewAI**: Multi-agent workflows with human oversight
|
||||
- **OpenAI**: Direct API integration with function calling
|
||||
- **Vercel AI SDK**: Next.js/React applications
|
||||
- **ControlFlow**: Workflow orchestration
|
||||
### Go Development
|
||||
- Check `go.mod` for Go version (varies between 1.21 and 1.24)
|
||||
- Check if directory has a `Makefile` for available commands
|
||||
- Integration tests only in some projects (look for `-tags=integration`)
|
||||
|
||||
Each framework example follows the pattern of wrapping functions with HumanLayer decorators while maintaining framework-specific patterns.
|
||||
## Technical Guidelines
|
||||
|
||||
### CLI Tool
|
||||
### Python
|
||||
- Strict type hints (mypy strict mode)
|
||||
- Async/await patterns where established
|
||||
- Follow existing code style
|
||||
|
||||
- **HumanLayer CLI**: `npx humanlayer` - Command-line interface for authentication, configuration, and human contact
|
||||
- Available commands: `login`, `config show`, `contact_human`, `tui`
|
||||
- Use `npx humanlayer --help` for detailed usage information
|
||||
### TypeScript
|
||||
- Modern ES6+ features
|
||||
- Strict TypeScript configuration
|
||||
- Maintain CommonJS/ESM compatibility
|
||||
|
||||
### Important Notes
|
||||
### Go
|
||||
- Standard Go idioms
|
||||
- Context-first API design
|
||||
- Generate mocks with `make mocks` when needed
|
||||
|
||||
- Always use `uv add` for Python dependencies, never `uv pip`
|
||||
- Run `make check test` before comitting
|
||||
- Examples use virtual environments and have their own dependency files
|
||||
- For CLI usage, always use `npx humanlayer` command format
|
||||
|
||||
### Quiet Build Output
|
||||
|
||||
The build system supports quiet output mode to reduce verbosity:
|
||||
|
||||
- `make check` - Runs all checks with minimal output (default)
|
||||
- `make test` - Runs all tests with minimal output (default)
|
||||
- `make check-verbose` or `VERBOSE=1 make check` - Shows full output
|
||||
- `make test-verbose` or `VERBOSE=1 make test` - Shows full output
|
||||
|
||||
In quiet mode:
|
||||
|
||||
- Only shows ✓/✗ status indicators for each step
|
||||
- Displays test counts where available
|
||||
- Shows full error output when commands fail
|
||||
- Reduces 500+ lines to ~50 lines for successful runs
|
||||
|
||||
The quiet system uses `hack/run_silent.sh` which provides helper functions for child Makefiles.
|
||||
|
||||
# Handy Docs
|
||||
|
||||
If you're working on a feature specific to a given section of the codebase, you may want to check out these relevant docs first:
|
||||
|
||||
* [humanlayer-wui](https://github.com/humanlayer-ai/humanlayer/blob/main/humanlayer-wui/README.md)
|
||||
## Additional Resources
|
||||
- Check `examples/` for integration patterns
|
||||
- Consult `docs/` for user-facing documentation
|
||||
|
||||
@@ -7,6 +7,7 @@ A unified CLI tool that provides:
|
||||
- Direct human contact from terminal or scripts
|
||||
- MCP (Model Context Protocol) server functionality
|
||||
- Integration with Claude Code SDK for approval workflows
|
||||
- Thoughts management system for developer notes and documentation
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -184,6 +185,39 @@ humanlayer mcp <subcommand>
|
||||
- `wrapper` - Wrap an existing MCP server with human approval functionality (not implemented yet)
|
||||
- `inspector [command]` - Run MCP inspector for debugging MCP servers (defaults to 'serve')
|
||||
|
||||
### `thoughts`
|
||||
|
||||
Manage developer thoughts and notes separately from code repositories.
|
||||
|
||||
```bash
|
||||
humanlayer thoughts <subcommand>
|
||||
```
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
- `init` - Initialize thoughts for the current repository
|
||||
- `sync` - Manually sync thoughts and update searchable index
|
||||
- `status` - Check the status of your thoughts setup
|
||||
- `config` - View or edit thoughts configuration
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Initialize thoughts for a new project
|
||||
humanlayer thoughts init
|
||||
|
||||
# Sync thoughts after making changes
|
||||
humanlayer thoughts sync -m "Updated architecture notes"
|
||||
|
||||
# Check status
|
||||
humanlayer thoughts status
|
||||
|
||||
# View configuration
|
||||
humanlayer thoughts config --json
|
||||
```
|
||||
|
||||
The thoughts system keeps your notes separate from code while making them easily accessible to AI assistants. See the [Thoughts documentation](./THOUGHTS.md) for detailed information.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **CI/CD Pipelines**: Get human approval before deploying
|
||||
|
||||
@@ -45,8 +45,12 @@ your-project/
|
||||
│ ├── global/ # → ~/thoughts/global
|
||||
│ │ ├── alice/ # Your cross-repo notes
|
||||
│ │ └── shared/ # Team cross-repo notes
|
||||
│ ├── searchable/ # Hard links for AI search (auto-generated)
|
||||
│ │ ├── alice/ # Hard links to alice's files
|
||||
│ │ ├── shared/ # Hard links to shared files
|
||||
│ │ └── global/ # Hard links to global files
|
||||
│ └── CLAUDE.md # Auto-generated context for AI
|
||||
└── .gitignore # Updated to exclude thoughts/
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
Your central thoughts repository:
|
||||
@@ -70,10 +74,19 @@ Your central thoughts repository:
|
||||
The system automatically syncs your thoughts when you commit code:
|
||||
|
||||
1. **Pre-commit hook** - Prevents thoughts/ from being committed to your code repo
|
||||
2. **Post-commit hook** - Syncs thoughts changes to your thoughts repository
|
||||
2. **Post-commit hook** - Syncs thoughts changes to your thoughts repository and updates the searchable directory
|
||||
|
||||
This means you can work naturally - edit thoughts alongside code, and they'll be kept in sync automatically.
|
||||
|
||||
### Searchable Directory
|
||||
|
||||
The `thoughts/searchable/` directory contains read-only hard links to all thoughts files. This allows AI tools to search your thoughts content without needing to follow symlinks. The searchable directory:
|
||||
|
||||
- Is automatically updated when you run `humanlayer thoughts sync`
|
||||
- Contains hard links (not copies) to preserve disk space
|
||||
- Is read-only to prevent accidental edits
|
||||
- Should not be edited directly - always edit the original files
|
||||
|
||||
## Commands
|
||||
|
||||
### `humanlayer thoughts init`
|
||||
@@ -245,7 +258,7 @@ humanlayer thoughts init
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
The thoughts directory is automatically ignored by git, so it won't affect CI/CD pipelines. The symlinks are also excluded, ensuring clean builds.
|
||||
The thoughts directory is protected by a pre-commit hook that prevents accidental commits to your code repository. This ensures clean CI/CD pipelines while keeping thoughts accessible for searching and development.
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
@@ -275,6 +288,12 @@ A: Currently all projects share the same thoughts repo, but use different subdir
|
||||
**Q: Why can't I use "global" as my username?**
|
||||
A: "global" is reserved for cross-project thoughts. This ensures the directory structure remains clear.
|
||||
|
||||
**Q: Why do I need a searchable directory?**
|
||||
A: Many search tools don't follow symlinks by default. The searchable directory contains hard links to all your thoughts files, making them easily searchable by AI assistants and other tools.
|
||||
|
||||
**Q: Can I edit files in the searchable directory?**
|
||||
A: No, files in searchable/ are read-only. Always edit the original files (e.g., edit thoughts/alice/todo.md, not thoughts/searchable/alice/todo.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
The thoughts system is part of HumanLayer. To contribute:
|
||||
|
||||
@@ -137,6 +137,21 @@ It is managed by the HumanLayer thoughts system and should not be committed to t
|
||||
- \`global/\` → Cross-repository thoughts (symlink to ${globalPath})
|
||||
- \`${user}/\` - Your personal notes that apply across all repositories
|
||||
- \`shared/\` - Team-shared notes that apply across all repositories
|
||||
- \`searchable/\` → Read-only hard links for searching (auto-generated)
|
||||
|
||||
## Searching in Thoughts
|
||||
|
||||
The \`searchable/\` directory contains read-only hard links to all thoughts files accessible in this repository. This allows search tools to find content without following symlinks.
|
||||
|
||||
**IMPORTANT**:
|
||||
- Files found in \`thoughts/searchable/\` are read-only copies
|
||||
- To edit any file, use the original path (e.g., edit \`thoughts/${user}/todo.md\`, not \`thoughts/searchable/${user}/todo.md\`)
|
||||
- The \`searchable/\` directory is automatically updated when you run \`humanlayer thoughts sync\`
|
||||
|
||||
This design ensures that:
|
||||
1. Search tools can find all your thoughts content easily
|
||||
2. The symlink structure remains intact for git operations
|
||||
3. You can't accidentally edit the wrong copy of a file
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -163,7 +178,30 @@ These files will be automatically synchronized with your thoughts repository whe
|
||||
}
|
||||
|
||||
function setupGitHooks(repoPath: string): void {
|
||||
const hooksDir = path.join(repoPath, '.git', 'hooks')
|
||||
// Use git rev-parse to find the common git directory for hooks (handles worktrees)
|
||||
// In worktrees, hooks are stored in the common git directory, not the worktree-specific one
|
||||
let gitCommonDir: string
|
||||
try {
|
||||
gitCommonDir = execSync('git rev-parse --git-common-dir', {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}).trim()
|
||||
|
||||
// If the path is relative, make it absolute
|
||||
if (!path.isAbsolute(gitCommonDir)) {
|
||||
gitCommonDir = path.join(repoPath, gitCommonDir)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to find git common directory: ${error}`)
|
||||
}
|
||||
|
||||
const hooksDir = path.join(gitCommonDir, 'hooks')
|
||||
|
||||
// Ensure hooks directory exists (might not exist in some setups)
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Pre-commit hook
|
||||
const preCommitPath = path.join(hooksDir, 'pre-commit')
|
||||
@@ -446,6 +484,20 @@ export async function thoughtsInitCommand(options: InitOptions): Promise<void> {
|
||||
// Create thoughts directory in current repo
|
||||
const thoughtsDir = path.join(currentRepo, 'thoughts')
|
||||
if (fs.existsSync(thoughtsDir)) {
|
||||
// Handle searchable directories specially if they exist (might have read-only permissions)
|
||||
const searchableDir = path.join(thoughtsDir, 'searchable')
|
||||
const oldSearchDir = path.join(thoughtsDir, '.search')
|
||||
|
||||
for (const dir of [searchableDir, oldSearchDir]) {
|
||||
if (fs.existsSync(dir)) {
|
||||
try {
|
||||
// Reset permissions so we can delete it
|
||||
execSync(`chmod -R 755 "${dir}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.rmSync(thoughtsDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(thoughtsDir)
|
||||
@@ -481,20 +533,6 @@ export async function thoughtsInitCommand(options: InitOptions): Promise<void> {
|
||||
// Setup git hooks
|
||||
setupGitHooks(currentRepo)
|
||||
|
||||
// Add thoughts to .gitignore if not already there
|
||||
const gitignorePath = path.join(currentRepo, '.gitignore')
|
||||
let gitignoreContent = ''
|
||||
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
gitignoreContent = fs.readFileSync(gitignorePath, 'utf8')
|
||||
}
|
||||
|
||||
if (!gitignoreContent.includes('/thoughts/') && !gitignoreContent.includes('thoughts/')) {
|
||||
gitignoreContent += '\n# HumanLayer thoughts directory (root level only)\n/thoughts/\n'
|
||||
fs.writeFileSync(gitignorePath, gitignoreContent)
|
||||
console.log(chalk.green('✅ Added /thoughts/ to .gitignore'))
|
||||
}
|
||||
|
||||
console.log(chalk.green('✅ Thoughts setup complete!'))
|
||||
console.log('')
|
||||
console.log(chalk.blue('=== Summary ==='))
|
||||
@@ -517,14 +555,14 @@ export async function thoughtsInitCommand(options: InitOptions): Promise<void> {
|
||||
console.log('Protection enabled:')
|
||||
console.log(` ${chalk.green('✓')} Pre-commit hook: Prevents committing thoughts/`)
|
||||
console.log(` ${chalk.green('✓')} Post-commit hook: Auto-syncs thoughts after commits`)
|
||||
console.log(` ${chalk.green('✓')} Added to .gitignore`)
|
||||
console.log('')
|
||||
console.log('Next steps:')
|
||||
console.log(` 1. Run ${chalk.cyan('humanlayer thoughts sync')} to create the searchable index`)
|
||||
console.log(
|
||||
` 1. Create markdown files in ${chalk.cyan(`thoughts/${config.user}/`)} for your notes`,
|
||||
` 2. Create markdown files in ${chalk.cyan(`thoughts/${config.user}/`)} for your notes`,
|
||||
)
|
||||
console.log(` 2. Your thoughts will sync automatically when you commit code`)
|
||||
console.log(` 3. Run ${chalk.cyan('humanlayer thoughts status')} to check sync status`)
|
||||
console.log(` 3. Your thoughts will sync automatically when you commit code`)
|
||||
console.log(` 4. Run ${chalk.cyan('humanlayer thoughts status')} to check sync status`)
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error during thoughts init: ${error}`))
|
||||
process.exit(1)
|
||||
|
||||
@@ -70,6 +70,115 @@ function syncThoughts(thoughtsRepo: string, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createSearchDirectory(thoughtsDir: string): void {
|
||||
const searchDir = path.join(thoughtsDir, 'searchable')
|
||||
const oldSearchDir = path.join(thoughtsDir, '.search')
|
||||
|
||||
// Remove old .search directory if it exists
|
||||
if (fs.existsSync(oldSearchDir)) {
|
||||
try {
|
||||
execSync(`chmod -R 755 "${oldSearchDir}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors
|
||||
}
|
||||
fs.rmSync(oldSearchDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Remove existing searchable directory if it exists
|
||||
if (fs.existsSync(searchDir)) {
|
||||
try {
|
||||
// Reset permissions so we can delete it
|
||||
execSync(`chmod -R 755 "${searchDir}"`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors
|
||||
}
|
||||
fs.rmSync(searchDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Create new .search directory
|
||||
fs.mkdirSync(searchDir, { recursive: true })
|
||||
|
||||
// Function to recursively find all files through symlinks
|
||||
function findFilesFollowingSymlinks(
|
||||
dir: string,
|
||||
baseDir: string = dir,
|
||||
visited: Set<string> = new Set(),
|
||||
): string[] {
|
||||
const files: string[] = []
|
||||
|
||||
// Resolve symlinks to avoid cycles
|
||||
const realPath = fs.realpathSync(dir)
|
||||
if (visited.has(realPath)) {
|
||||
return files
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||
files.push(...findFilesFollowingSymlinks(fullPath, baseDir, visited))
|
||||
} else if (entry.isSymbolicLink() && !entry.name.startsWith('.')) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...findFilesFollowingSymlinks(fullPath, baseDir, visited))
|
||||
} else if (stat.isFile() && path.basename(fullPath) !== 'CLAUDE.md') {
|
||||
files.push(path.relative(baseDir, fullPath))
|
||||
}
|
||||
} catch {
|
||||
// Ignore broken symlinks
|
||||
}
|
||||
} else if (entry.isFile() && !entry.name.startsWith('.') && entry.name !== 'CLAUDE.md') {
|
||||
files.push(path.relative(baseDir, fullPath))
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// Get all files accessible through the thoughts directory (following symlinks)
|
||||
const allFiles = findFilesFollowingSymlinks(thoughtsDir)
|
||||
|
||||
// Create hard links in .search directory
|
||||
let linkedCount = 0
|
||||
for (const relPath of allFiles) {
|
||||
const sourcePath = path.join(thoughtsDir, relPath)
|
||||
const targetPath = path.join(searchDir, relPath)
|
||||
|
||||
// Create directory structure
|
||||
const targetDir = path.dirname(targetPath)
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve symlink to get the real file path
|
||||
const realSourcePath = fs.realpathSync(sourcePath)
|
||||
// Create hard link to the real file
|
||||
fs.linkSync(realSourcePath, targetPath)
|
||||
linkedCount++
|
||||
} catch {
|
||||
// Silently skip files we can't link (e.g., different filesystems)
|
||||
}
|
||||
}
|
||||
|
||||
// Make .search directory read-only
|
||||
try {
|
||||
// First set directories to be readable and traversable
|
||||
execSync(`find "${searchDir}" -type d -exec chmod 755 {} +`, { stdio: 'pipe' })
|
||||
// Then set files to be read-only
|
||||
execSync(`find "${searchDir}" -type f -exec chmod 444 {} +`, { stdio: 'pipe' })
|
||||
// Finally make directories read-only but still traversable
|
||||
execSync(`find "${searchDir}" -type d -exec chmod 555 {} +`, { stdio: 'pipe' })
|
||||
} catch {
|
||||
// Ignore chmod errors on systems that don't support it
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Created ${linkedCount} hard links in searchable directory`))
|
||||
}
|
||||
|
||||
export async function thoughtsSyncCommand(options: SyncOptions): Promise<void> {
|
||||
try {
|
||||
// Check if thoughts are configured
|
||||
@@ -107,6 +216,10 @@ export async function thoughtsSyncCommand(options: SyncOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Create .search directory with hard links
|
||||
console.log(chalk.blue('Creating searchable index...'))
|
||||
createSearchDirectory(thoughtsDir)
|
||||
|
||||
// Sync the thoughts repository
|
||||
console.log(chalk.blue('Syncing thoughts...'))
|
||||
syncThoughts(config.thoughtsRepo, options.message || '')
|
||||
|
||||
@@ -61,8 +61,10 @@ export function ensureThoughtsRepoExists(
|
||||
fs.mkdirSync(expandedGlobal, { recursive: true })
|
||||
}
|
||||
|
||||
// Check if we're in a git repo
|
||||
const isGitRepo = fs.existsSync(path.join(expandedRepo, '.git'))
|
||||
// Check if we're in a git repo (handle both .git directory and .git file for worktrees)
|
||||
const gitPath = path.join(expandedRepo, '.git')
|
||||
const isGitRepo =
|
||||
fs.existsSync(gitPath) && (fs.statSync(gitPath).isDirectory() || fs.statSync(gitPath).isFile())
|
||||
|
||||
if (!isGitRepo) {
|
||||
// Initialize as git repo
|
||||
|
||||
3
humanlayer-wui/.gitignore
vendored
3
humanlayer-wui/.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Generated Tauri config for local development
|
||||
src-tauri/tauri.conf.local.json
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.34.0",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^6.3.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,6 +50,6 @@
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.34.0",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
18
humanlayer-wui/run-instance.sh
Executable file
18
humanlayer-wui/run-instance.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run HumanLayer WUI on a specific port
|
||||
# Usage: ./run-instance.sh [port]
|
||||
# Example: ./run-instance.sh 3000
|
||||
|
||||
PORT=${1:-1420}
|
||||
|
||||
echo "Starting HumanLayer WUI on port $PORT..."
|
||||
echo "Dev server: http://localhost:$PORT"
|
||||
echo "HMR port: $((PORT + 1))"
|
||||
|
||||
# Generate local Tauri config with the custom port
|
||||
jq --arg port "$PORT" '.build.devUrl = "http://localhost:\($port)"' \
|
||||
src-tauri/tauri.conf.json > src-tauri/tauri.conf.local.json
|
||||
|
||||
# Run Tauri with the local config and custom Vite port
|
||||
VITE_PORT=$PORT bun run tauri dev -c src-tauri/tauri.conf.local.json
|
||||
@@ -114,6 +114,34 @@
|
||||
--terminal-error: #ff0000;
|
||||
}
|
||||
|
||||
/* Framer Dark - Inspired by Framer's dark theme */
|
||||
[data-theme='framer-dark'] {
|
||||
--terminal-bg: #181818;
|
||||
--terminal-bg-alt: #2f3439;
|
||||
--terminal-fg: #eeeeee;
|
||||
--terminal-fg-dim: #999999;
|
||||
--terminal-accent: #fd5799;
|
||||
--terminal-accent-alt: #20bcfc;
|
||||
--terminal-border: #333333;
|
||||
--terminal-success: #32ccdc;
|
||||
--terminal-warning: #fecb6e;
|
||||
--terminal-error: #fd886b;
|
||||
}
|
||||
|
||||
/* Framer Light - Light counterpart to Framer Dark */
|
||||
[data-theme='framer-light'] {
|
||||
--terminal-bg: #ffffff;
|
||||
--terminal-bg-alt: #f8f9fa;
|
||||
--terminal-fg: #333333;
|
||||
--terminal-fg-dim: #666666;
|
||||
--terminal-accent: #0066cc;
|
||||
--terminal-accent-alt: #006bb3;
|
||||
--terminal-border: #e0e0e0;
|
||||
--terminal-success: #22a06b;
|
||||
--terminal-warning: #cc7722;
|
||||
--terminal-error: #dc3545;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -2,14 +2,16 @@ import { useEffect, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useSessionLauncher } from '@/hooks/useSessionLauncher'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { fuzzySearch, highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
|
||||
import { highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSessionFilter } from '@/hooks/useSessionFilter'
|
||||
|
||||
interface MenuOption {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
action: () => void
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export default function CommandPaletteMenu() {
|
||||
@@ -21,6 +23,13 @@ export default function CommandPaletteMenu() {
|
||||
// Get sessions from the main app store
|
||||
const sessions = useStore(state => state.sessions)
|
||||
|
||||
// Use the shared session filter hook
|
||||
const { filteredSessions, statusFilter, searchText, matchedSessions } = useSessionFilter({
|
||||
sessions,
|
||||
query: searchQuery,
|
||||
searchFields: ['query', 'model'], // Search in both query and model fields for the modal
|
||||
})
|
||||
|
||||
// Build base menu options
|
||||
const baseOptions: MenuOption[] = [
|
||||
{
|
||||
@@ -32,36 +41,27 @@ export default function CommandPaletteMenu() {
|
||||
]
|
||||
|
||||
// Command mode: Only Create Session
|
||||
// Search mode: All sessions (for fuzzy search) but limit display to 5
|
||||
// Search mode: Use filtered sessions but limit display to 5
|
||||
const sessionOptions: MenuOption[] =
|
||||
mode === 'search'
|
||||
? sessions.map(session => ({
|
||||
? filteredSessions.slice(0, 5).map(session => ({
|
||||
id: `open-${session.id}`,
|
||||
label: `${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
|
||||
description: `${session.status} • ${session.model || 'Unknown model'}`,
|
||||
action: () => openSessionById(session.id),
|
||||
sessionId: session.id, // Store for match lookup
|
||||
}))
|
||||
: [] // No sessions in command mode
|
||||
|
||||
// Apply fuzzy search if in search mode and there's a query
|
||||
const filteredSessions =
|
||||
searchQuery && mode === 'search'
|
||||
? fuzzySearch(sessionOptions, searchQuery, {
|
||||
keys: ['label', 'description'],
|
||||
threshold: 0.1,
|
||||
includeMatches: true,
|
||||
})
|
||||
: sessionOptions.map(session => ({ item: session, matches: [], score: 1, indices: [] }))
|
||||
|
||||
// Combine options based on mode
|
||||
const menuOptions: MenuOption[] =
|
||||
mode === 'command'
|
||||
? baseOptions // Command: Only Create Session
|
||||
: filteredSessions.slice(0, 5).map(result => result.item) // Search: Only sessions (no Create Session), limit to 5
|
||||
: sessionOptions // Search: Only sessions (no Create Session), already limited to 5
|
||||
|
||||
// Keyboard navigation
|
||||
useHotkeys(
|
||||
'up',
|
||||
'up, k',
|
||||
() => {
|
||||
setSelectedMenuIndex(selectedMenuIndex > 0 ? selectedMenuIndex - 1 : menuOptions.length - 1)
|
||||
},
|
||||
@@ -69,7 +69,7 @@ export default function CommandPaletteMenu() {
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'down',
|
||||
'down, j',
|
||||
() => {
|
||||
setSelectedMenuIndex(selectedMenuIndex < menuOptions.length - 1 ? selectedMenuIndex + 1 : 0)
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export default function CommandPaletteMenu() {
|
||||
// Render highlighted text for search results
|
||||
const renderHighlightedText = (text: string, matches: FuzzyMatch['matches'], targetKey?: string) => {
|
||||
const match = matches.find(m => m.key === targetKey)
|
||||
if (match && match.indices && searchQuery) {
|
||||
if (match && match.indices && searchText) {
|
||||
const segments = highlightMatches(text, match.indices)
|
||||
return (
|
||||
<>
|
||||
@@ -153,14 +153,22 @@ export default function CommandPaletteMenu() {
|
||||
{mode === 'search' && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{menuOptions.length} of {filteredSessions.length} sessions
|
||||
{statusFilter && (
|
||||
<span
|
||||
className="ml-2 px-2 py-0.5 text-accent-foreground rounded"
|
||||
style={{ backgroundColor: 'var(--terminal-accent)' }}
|
||||
>
|
||||
status: {statusFilter.toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menuOptions.map((option, index) => {
|
||||
// Find the corresponding match data for highlighting
|
||||
const matchData =
|
||||
mode === 'search' && searchQuery
|
||||
? filteredSessions.find(result => result.item.id === option.id)
|
||||
mode === 'search' && searchText && option.sessionId
|
||||
? matchedSessions.get(option.sessionId)
|
||||
: null
|
||||
|
||||
return (
|
||||
@@ -205,7 +213,7 @@ export default function CommandPaletteMenu() {
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border/30">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↑↓ j/k Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
</div>
|
||||
<span>ESC Close</span>
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function FuzzySearchInput<T>({
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
spellCheck={false}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
|
||||
// Additional escape key handler for input field
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
|
||||
{view === 'menu'
|
||||
? mode === 'command'
|
||||
? 'Command Palette'
|
||||
: 'Search Sessions'
|
||||
: 'Jump to Session'
|
||||
: 'Create Session'}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
94
humanlayer-wui/src/components/SessionTableSearch.tsx
Normal file
94
humanlayer-wui/src/components/SessionTableSearch.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from './ui/input'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { SessionTableHotkeysScope } from './internal/SessionTable'
|
||||
|
||||
interface SessionTableSearchProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
statusFilter?: string | null
|
||||
onEscape?: () => void
|
||||
}
|
||||
|
||||
export function SessionTableSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search sessions...',
|
||||
className,
|
||||
statusFilter,
|
||||
}: SessionTableSearchProps) {
|
||||
const inputClassId = 'session-table-search-input'
|
||||
|
||||
// For unknown reasons, I can't seem to detect a 'slash' character here. This is the silliest.
|
||||
// But maybe we're all the silliest and this continues a long and highly venerated tradition of silliness.
|
||||
useHotkeys(
|
||||
'*',
|
||||
e => {
|
||||
if (e.key === '/') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const input = document.getElementById(inputClassId) as HTMLInputElement
|
||||
if (input) {
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
scopes: SessionTableHotkeysScope,
|
||||
enabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'escape',
|
||||
() => {
|
||||
const input = document.getElementById(inputClassId) as HTMLInputElement
|
||||
if (input) {
|
||||
input.blur()
|
||||
}
|
||||
},
|
||||
{
|
||||
scopes: SessionTableHotkeysScope,
|
||||
enabled: true,
|
||||
enableOnFormTags: ['INPUT'],
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex items-center gap-2', className)}>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 h-4 w-4 text-muted-foreground top-1/2 -translate-y-1/2" />
|
||||
<Input
|
||||
id={inputClassId}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'w-full h-9 pl-10 pr-3 text-sm',
|
||||
'font-mono',
|
||||
'bg-background border rounded-md',
|
||||
'transition-all duration-200',
|
||||
'placeholder:text-muted-foreground/60',
|
||||
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
|
||||
'focus:outline-none',
|
||||
)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusFilter && (
|
||||
<span
|
||||
className="px-2 py-1 text-xs text-accent-foreground rounded whitespace-nowrap"
|
||||
style={{ backgroundColor: 'var(--terminal-accent)' }}
|
||||
>
|
||||
status: {statusFilter.toLowerCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTheme, type Theme } from '@/contexts/ThemeContext'
|
||||
import { Moon, Sun, Coffee, Cat, ScanEye } from 'lucide-react'
|
||||
import { Moon, Sun, Coffee, Cat, ScanEye, Framer } from 'lucide-react'
|
||||
import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'
|
||||
import { SessionTableHotkeysScope } from './internal/SessionTable'
|
||||
|
||||
@@ -10,6 +10,8 @@ const themes: { value: Theme; label: string; icon: React.ComponentType<{ classNa
|
||||
{ value: 'cappuccino', label: 'Cappuccino', icon: Coffee },
|
||||
{ value: 'catppuccin', label: 'Catppuccin', icon: Cat },
|
||||
{ value: 'high-contrast', label: 'High Contrast', icon: ScanEye },
|
||||
{ value: 'framer-dark', label: 'Framer Dark', icon: Framer },
|
||||
{ value: 'framer-light', label: 'Framer Light', icon: Framer },
|
||||
]
|
||||
|
||||
export const ThemeSelectorHotkeysScope = 'theme-selector'
|
||||
|
||||
9
humanlayer-wui/src/components/internal/CommandToken.tsx
Normal file
9
humanlayer-wui/src/components/internal/CommandToken.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export function CommandToken({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded font-mono text-xs bg-[var(--terminal-bg-alt)] text-[var(--terminal-accent)] border border-[var(--terminal-border)] tracking-tight shadow-sm mr-1 align-middle">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import jsonGrammar from '@wooorm/starry-night/source.json'
|
||||
import textMd from '@wooorm/starry-night/text.md'
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
|
||||
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
|
||||
import { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
ConversationEvent,
|
||||
ConversationEventType,
|
||||
ConversationRole,
|
||||
SessionInfo,
|
||||
ApprovalStatus,
|
||||
} from '@/lib/daemon/types'
|
||||
@@ -20,10 +21,22 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/colla
|
||||
import { useConversation } from '@/hooks/useConversation'
|
||||
import { Skeleton } from '../ui/skeleton'
|
||||
import { useStore } from '@/AppStore'
|
||||
import { Bot, MessageCircleDashed, UserCheck, Wrench, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle,
|
||||
CircleDashed,
|
||||
Hourglass,
|
||||
MessageCircleDashed,
|
||||
UserCheck,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { getStatusTextClass } from '@/utils/component-utils'
|
||||
import { daemonClient } from '@/lib/daemon/client'
|
||||
import { truncate, formatAbsoluteTimestamp } from '@/utils/formatting'
|
||||
import { CommandToken } from './CommandToken'
|
||||
|
||||
/* I, Sundeep, don't know how I feel about what's going on here. */
|
||||
let starryNight: any | null = null
|
||||
@@ -51,10 +64,12 @@ function eventToDisplayObject(
|
||||
denyingApprovalId?: string | null,
|
||||
onStartDeny?: (approvalId: string) => void,
|
||||
onCancelDeny?: () => void,
|
||||
approvingApprovalId?: string | null,
|
||||
) {
|
||||
let subject = <span>Unknown Subject</span>
|
||||
let body = null
|
||||
let iconComponent = null
|
||||
const iconClasses = 'w-4 h-4 align-middle relative top-[1px]'
|
||||
|
||||
// For the moment, don't display tool results.
|
||||
if (event.event_type === ConversationEventType.ToolResult) {
|
||||
@@ -62,7 +77,7 @@ function eventToDisplayObject(
|
||||
}
|
||||
|
||||
if (event.event_type === ConversationEventType.ToolCall) {
|
||||
iconComponent = <Wrench className="w-4 h-4" />
|
||||
iconComponent = <Wrench className={iconClasses} />
|
||||
|
||||
// Claude Code converts "LS" to "List"
|
||||
if (event.tool_name === 'LS') {
|
||||
@@ -85,6 +100,31 @@ function eventToDisplayObject(
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Glob') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<span className="font-bold">{toolInput.pattern}</span> against{' '}
|
||||
<span className="font-bold">{toolInput.path}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Bash') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
<span>
|
||||
<span className="font-bold">{event.tool_name} </span>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<CommandToken>{toolInput.command}</CommandToken>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.tool_name === 'Task') {
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
subject = (
|
||||
@@ -152,7 +192,7 @@ function eventToDisplayObject(
|
||||
[ApprovalStatus.Denied]: 'text-[var(--terminal-error)]',
|
||||
[ApprovalStatus.Resolved]: 'text-[var(--terminal-success)]',
|
||||
}
|
||||
iconComponent = <UserCheck className="w-4 h-4" />
|
||||
iconComponent = <UserCheck className={iconClasses} />
|
||||
subject = (
|
||||
<span>
|
||||
<span className={`font-bold ${approvalStatusToColor[event.approval_status]}`}>
|
||||
@@ -168,6 +208,7 @@ function eventToDisplayObject(
|
||||
// Add approve/deny buttons for pending approvals
|
||||
if (event.approval_status === ApprovalStatus.Pending && event.approval_id && onApprove && onDeny) {
|
||||
const isDenying = denyingApprovalId === event.approval_id
|
||||
const isApproving = approvingApprovalId === event.approval_id
|
||||
|
||||
body = (
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
@@ -176,25 +217,29 @@ function eventToDisplayObject(
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
variant="default"
|
||||
variant={isApproving ? 'outline' : 'default'}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onApprove(event.approval_id!)
|
||||
}}
|
||||
disabled={isApproving}
|
||||
>
|
||||
Approve <kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">A</kbd>
|
||||
</Button>
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onStartDeny?.(event.approval_id!)
|
||||
}}
|
||||
>
|
||||
Deny <kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">D</kbd>
|
||||
{isApproving ? 'Approving...' : 'Approve'}{' '}
|
||||
<kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">A</kbd>
|
||||
</Button>
|
||||
{!isApproving && (
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onStartDeny?.(event.approval_id!)
|
||||
}}
|
||||
>
|
||||
Deny <kbd className="ml-1 px-1 py-0.5 text-xs bg-muted/50 rounded">D</kbd>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DenyForm approvalId={event.approval_id!} onDeny={onDeny} onCancel={onCancelDeny} />
|
||||
@@ -224,7 +269,11 @@ function eventToDisplayObject(
|
||||
}
|
||||
|
||||
if (event.role === 'assistant') {
|
||||
iconComponent = <Bot className="w-4 h-4" />
|
||||
iconComponent = <Bot className={iconClasses} />
|
||||
}
|
||||
|
||||
if (event.role === 'user') {
|
||||
iconComponent = <User className={iconClasses} />
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -238,6 +287,62 @@ function eventToDisplayObject(
|
||||
}
|
||||
}
|
||||
|
||||
function TodoWidget({ event }: { event: ConversationEvent }) {
|
||||
// console.log('todo event', event)
|
||||
const toolInput = JSON.parse(event.tool_input_json!)
|
||||
const priorityGrouped = Object.groupBy(toolInput.todos, (todo: any) => todo.priority)
|
||||
const todos = toolInput.todos
|
||||
const completedCount = todos.filter((todo: any) => todo.status === 'completed').length
|
||||
const pendingCount = todos.filter((todo: any) => todo.status === 'pending').length
|
||||
const displayOrder = ['high', 'medium', 'low']
|
||||
const iconClasses = 'w-3 h-3 align-middle relative top-[1px]'
|
||||
const statusToIcon = {
|
||||
in_progress: <Hourglass className={iconClasses + ' text-[var(--terminal-warning)]'} />,
|
||||
pending: <CircleDashed className={iconClasses + ' text-[var(--terminal-fg-dim)]'} />,
|
||||
completed: <CheckCircle className={iconClasses + ' text-[var(--terminal-success)]'} />,
|
||||
}
|
||||
|
||||
// console.log(todos);
|
||||
|
||||
// console.log('event id', event.id)
|
||||
// console.log('completedCount', completedCount)
|
||||
// console.log('pendingCount', pendingCount)
|
||||
|
||||
// console.log('priorityGrouped', priorityGrouped)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<hgroup className="flex flex-col gap-1 my-2">
|
||||
<h2 className="text-md font-bold text-muted-foreground">TODOs</h2>
|
||||
<small>
|
||||
{completedCount} completed, {pendingCount} pending
|
||||
</small>
|
||||
</hgroup>
|
||||
{displayOrder.map(priority => {
|
||||
const todosInPriority = priorityGrouped[priority] || []
|
||||
// Only render the priority section if there are todos in it
|
||||
if (todosInPriority.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={priority} className="flex flex-col gap-1 mb-2">
|
||||
<h3 className="font-medium text-sm">{priority}</h3>
|
||||
<ul className="text-sm">
|
||||
{todosInPriority.map((todo: any) => (
|
||||
<li key={todo.id} className="flex gap-2 items-start">
|
||||
<span className="flex-shrink-0 mt-1">
|
||||
{statusToIcon[todo.status as keyof typeof statusToIcon]}
|
||||
</span>
|
||||
<span className="whitespace-pre-line font-mono">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventMetaInfo({ event }: { event: ConversationEvent }) {
|
||||
return (
|
||||
<div className="bg-muted/20 rounded p-4 mt-2 text-sm">
|
||||
@@ -260,9 +365,7 @@ function EventMetaInfo({ event }: { event: ConversationEvent }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Created:</span>
|
||||
<span className="ml-2 font-mono text-xs">
|
||||
{formatAbsoluteTimestamp(event.created_at)}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs">{formatAbsoluteTimestamp(event.created_at)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">Completed:</span>
|
||||
@@ -319,11 +422,17 @@ function DenyForm({
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const [reason, setReason] = useState('')
|
||||
const [isDenying, setIsDenying] = useState(false)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (reason.trim() && onDeny) {
|
||||
onDeny(approvalId, reason.trim())
|
||||
if (reason.trim() && onDeny && !isDenying) {
|
||||
try {
|
||||
setIsDenying(true)
|
||||
onDeny(approvalId, reason.trim())
|
||||
} finally {
|
||||
setIsDenying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,11 +451,18 @@ function DenyForm({
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={!reason.trim()}
|
||||
disabled={!reason.trim() || isDenying}
|
||||
>
|
||||
Deny
|
||||
{isDenying ? 'Denying...' : 'Deny'}
|
||||
</Button>
|
||||
<Button className="cursor-pointer" type="button" size="sm" variant="outline" onClick={onCancel}>
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isDenying}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
@@ -363,6 +479,7 @@ function ConversationContent({
|
||||
isWideView,
|
||||
onApprove,
|
||||
onDeny,
|
||||
approvingApprovalId,
|
||||
}: {
|
||||
sessionId: string
|
||||
session: SessionInfo
|
||||
@@ -373,11 +490,12 @@ function ConversationContent({
|
||||
isWideView: boolean
|
||||
onApprove?: (approvalId: string) => void
|
||||
onDeny?: (approvalId: string, reason: string) => void
|
||||
approvingApprovalId?: string | null
|
||||
}) {
|
||||
const { events, loading, error, isInitialLoad } = useConversation(sessionId, undefined, 1000)
|
||||
const [denyingApprovalId, setDenyingApprovalId] = useState<string | null>(null)
|
||||
|
||||
// Create synthetic first user message event from session.query
|
||||
// Create synthetic first user message event from session.query
|
||||
const firstUserMessageEvent: ConversationEvent = {
|
||||
id: -1, // Use negative ID to avoid conflicts
|
||||
session_id: session.id,
|
||||
@@ -385,7 +503,7 @@ function ConversationContent({
|
||||
sequence: 0,
|
||||
event_type: ConversationEventType.Message,
|
||||
created_at: session.start_time,
|
||||
role: 'user',
|
||||
role: ConversationRole.User,
|
||||
content: session.query,
|
||||
is_completed: true,
|
||||
}
|
||||
@@ -394,8 +512,14 @@ function ConversationContent({
|
||||
const eventsWithFirstMessage = [firstUserMessageEvent, ...events]
|
||||
|
||||
const displayObjects = eventsWithFirstMessage.map(event =>
|
||||
eventToDisplayObject(event, onApprove, onDeny, denyingApprovalId, setDenyingApprovalId, () =>
|
||||
setDenyingApprovalId(null),
|
||||
eventToDisplayObject(
|
||||
event,
|
||||
onApprove,
|
||||
onDeny,
|
||||
denyingApprovalId,
|
||||
setDenyingApprovalId,
|
||||
() => setDenyingApprovalId(null),
|
||||
approvingApprovalId,
|
||||
),
|
||||
)
|
||||
const nonEmptyDisplayObjects = displayObjects.filter(displayObject => displayObject !== null)
|
||||
@@ -434,10 +558,13 @@ function ConversationContent({
|
||||
useHotkeys('k', focusPreviousEvent)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const hasAutoScrolledRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && containerRef.current) {
|
||||
// Only auto-scroll once on initial load when events first appear
|
||||
if (!loading && containerRef.current && events.length > 0 && !hasAutoScrolledRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
hasAutoScrolledRef.current = true
|
||||
}
|
||||
|
||||
if (!starryNight) {
|
||||
@@ -445,6 +572,16 @@ function ConversationContent({
|
||||
}
|
||||
}, [loading, events])
|
||||
|
||||
// Scroll focused event into view
|
||||
useEffect(() => {
|
||||
if (focusedEventId && containerRef.current) {
|
||||
const focusedElement = containerRef.current.querySelector(`[data-event-id="${focusedEventId}"]`)
|
||||
if (focusedElement) {
|
||||
focusedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [focusedEventId])
|
||||
|
||||
if (error) {
|
||||
return <div className="text-destructive">Error loading conversation: {error}</div>
|
||||
}
|
||||
@@ -466,26 +603,27 @@ function ConversationContent({
|
||||
<div className="text-muted-foreground mb-2">
|
||||
<MessageCircleDashed className="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground">No conversation yet</h3>
|
||||
<h3 className="text-lg font-medium text-foreground">No conversation just yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The conversation will appear here once it starts.
|
||||
The conversation will appear here once the bot engines begin to fire up.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="max-h-[calc(100vh-375px)] overflow-y-auto">
|
||||
<div ref={containerRef} className="max-h-[calc(100vh-475px)] overflow-y-auto">
|
||||
<div>
|
||||
{nonEmptyDisplayObjects.map((displayObject, index) => (
|
||||
<div key={displayObject.id}>
|
||||
<div
|
||||
data-event-id={displayObject.id}
|
||||
onMouseEnter={() => setFocusedEventId(displayObject.id)}
|
||||
onMouseLeave={() => setFocusedEventId(null)}
|
||||
onClick={() =>
|
||||
setExpandedEventId(expandedEventId === displayObject.id ? null : displayObject.id)
|
||||
}
|
||||
className={`pt-2 pb-4 px-2 cursor-pointer ${
|
||||
className={`pt-2 pb-8 px-2 cursor-pointer ${
|
||||
index !== nonEmptyDisplayObjects.length - 1 ? 'border-b' : ''
|
||||
} ${focusedEventId === displayObject.id ? '!bg-accent/20 -mx-2 px-4 rounded' : ''}`}
|
||||
>
|
||||
@@ -496,14 +634,15 @@ function ConversationContent({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
{displayObject.iconComponent && (
|
||||
<span className="text-sm text-accent">{displayObject.iconComponent}</span>
|
||||
<span className="text-sm text-accent align-middle relative top-[1px]">
|
||||
{displayObject.iconComponent}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="whitespace-pre-wrap text-accent">{displayObject.subject}</span>
|
||||
{/* <span className="font-medium">{displayObject.role}</span> */}
|
||||
{/* <span className="text-sm text-muted-foreground">{displayObject.timestamp.toLocaleTimeString()}</span> */}
|
||||
<span className="whitespace-pre-wrap text-accent max-w-[90%]">
|
||||
{displayObject.subject}
|
||||
</span>
|
||||
</div>
|
||||
{displayObject.body && (
|
||||
<p className="whitespace-pre-wrap text-foreground">{displayObject.body}</p>
|
||||
@@ -529,18 +668,27 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
const [responseInput, setResponseInput] = useState('')
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const [isQueryExpanded, setIsQueryExpanded] = useState(false)
|
||||
const [approvingApprovalId, setApprovingApprovalId] = useState<string | null>(null)
|
||||
const interruptSession = useStore(state => state.interruptSession)
|
||||
const navigate = useNavigate()
|
||||
const isRunning = session.status === 'running'
|
||||
|
||||
// Get events for sidebar access
|
||||
const { events } = useConversation(session.id)
|
||||
|
||||
const lastTodo = events
|
||||
?.toReversed()
|
||||
.find(e => e.event_type === 'tool_call' && e.tool_name === 'TodoWrite')
|
||||
|
||||
// Approval handlers
|
||||
const handleApprove = async (approvalId: string) => {
|
||||
try {
|
||||
setApprovingApprovalId(approvalId)
|
||||
await daemonClient.approveFunctionCall(approvalId)
|
||||
} catch (error) {
|
||||
console.error('Failed to approve:', error)
|
||||
} finally {
|
||||
setApprovingApprovalId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,7 +813,6 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const isLongQuery = session.query.length > 50
|
||||
|
||||
return (
|
||||
@@ -713,6 +860,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
)}
|
||||
</hgroup>
|
||||
<div className={`flex gap-4 ${isWideView ? 'flex-row' : 'flex-col'}`}>
|
||||
{/* Conversation content and Loading */}
|
||||
<Card className={isWideView ? 'flex-1' : 'w-full'}>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
@@ -726,6 +874,7 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
>
|
||||
<ConversationContent
|
||||
sessionId={session.id}
|
||||
session={session}
|
||||
focusedEventId={focusedEventId}
|
||||
setFocusedEventId={setFocusedEventId}
|
||||
expandedEventId={expandedEventId}
|
||||
@@ -733,18 +882,38 @@ function SessionDetail({ session, onClose }: SessionDetailProps) {
|
||||
isWideView={isWideView}
|
||||
onApprove={handleApprove}
|
||||
onDeny={handleDeny}
|
||||
approvingApprovalId={approvingApprovalId}
|
||||
/>
|
||||
{isRunning && (
|
||||
<div className="flex flex-col gap-2 mt-4 border-t pt-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
robot magic is happening
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
<Skeleton className="h-4 w-1/5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar for wide view */}
|
||||
{isWideView && expandedEventId && (
|
||||
{/* {isWideView && expandedEventId && (
|
||||
<Card className="w-[40%]">
|
||||
<CardContent>
|
||||
<EventMetaInfo event={events.find(e => e.id === expandedEventId)!} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)} */}
|
||||
|
||||
{lastTodo && (
|
||||
<Card className="w-[20%]">
|
||||
<CardContent>
|
||||
<TodoWidget event={lastTodo} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
} from '../ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { CircleOff, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { getStatusTextClass } from '@/utils/component-utils'
|
||||
import { truncate, formatTimestamp, formatAbsoluteTimestamp } from '@/utils/formatting'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible'
|
||||
import { highlightMatches } from '@/lib/fuzzy-search'
|
||||
|
||||
interface SessionTableProps {
|
||||
sessions: SessionInfo[]
|
||||
@@ -24,6 +25,8 @@ interface SessionTableProps {
|
||||
handleFocusPreviousSession?: () => void
|
||||
handleActivateSession?: (session: SessionInfo) => void
|
||||
focusedSession: SessionInfo | null
|
||||
searchText?: string
|
||||
matchedSessions?: Map<string, any>
|
||||
}
|
||||
|
||||
export const SessionTableHotkeysScope = 'session-table'
|
||||
@@ -36,9 +39,38 @@ export default function SessionTable({
|
||||
handleFocusPreviousSession,
|
||||
handleActivateSession,
|
||||
focusedSession,
|
||||
searchText,
|
||||
matchedSessions,
|
||||
}: SessionTableProps) {
|
||||
const { enableScope, disableScope } = useHotkeysContext()
|
||||
const [expandedQueryId, setExpandedQueryId] = useState<string | null>(null)
|
||||
const tableRef = useRef<HTMLTableElement>(null)
|
||||
|
||||
// Helper to render highlighted text
|
||||
const renderHighlightedText = (text: string, sessionId: string) => {
|
||||
if (!searchText || !matchedSessions) return text
|
||||
|
||||
const matchData = matchedSessions.get(sessionId)
|
||||
if (!matchData) return text
|
||||
|
||||
// Find matches for the query field
|
||||
const queryMatch = matchData.matches?.find((m: any) => m.key === 'query')
|
||||
if (!queryMatch || !queryMatch.indices) return text
|
||||
|
||||
const segments = highlightMatches(text, queryMatch.indices)
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={segment.highlighted ? 'bg-yellow-200/80 dark:bg-yellow-900/60 font-medium' : ''}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
enableScope(SessionTableHotkeysScope)
|
||||
@@ -47,6 +79,16 @@ export default function SessionTable({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll focused session into view
|
||||
useEffect(() => {
|
||||
if (focusedSession && tableRef.current) {
|
||||
const focusedRow = tableRef.current.querySelector(`[data-session-id="${focusedSession.id}"]`)
|
||||
if (focusedRow) {
|
||||
focusedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [focusedSession])
|
||||
|
||||
useHotkeys('j', () => handleFocusNextSession?.(), { scopes: SessionTableHotkeysScope })
|
||||
useHotkeys('k', () => handleFocusPreviousSession?.(), { scopes: SessionTableHotkeysScope })
|
||||
useHotkeys(
|
||||
@@ -60,69 +102,80 @@ export default function SessionTable({
|
||||
)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableCaption>A list of your recent sessions.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Session Name</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Last Activity</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map(session => (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
onMouseEnter={() => handleFocusSession?.(session)}
|
||||
onMouseLeave={() => handleBlurSession?.()}
|
||||
onClick={() => handleActivateSession?.(session)}
|
||||
className={`cursor-pointer ${focusedSession?.id === session.id ? '!bg-accent/20' : ''}`}
|
||||
>
|
||||
<TableCell className={getStatusTextClass(session.status)}>{session.status}</TableCell>
|
||||
<TableCell>
|
||||
{session.query.length > 50 ? (
|
||||
<Collapsible
|
||||
open={expandedQueryId === session.id}
|
||||
onOpenChange={open => setExpandedQueryId(open ? session.id : null)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-left">
|
||||
<span>{truncate(session.query, 50)}</span>
|
||||
{expandedQueryId === session.id ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 text-sm text-foreground">{session.query}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<span>{session.query}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{session.model || <CircleOff className="w-4 h-4" />}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatTimestamp(session.start_time)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatAbsoluteTimestamp(session.start_time)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatTimestamp(session.last_activity_at)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatAbsoluteTimestamp(session.last_activity_at)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<>
|
||||
<Table ref={tableRef}>
|
||||
<TableCaption>A list of your recent sessions.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Query</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Last Activity</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map(session => (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
data-session-id={session.id}
|
||||
onMouseEnter={() => handleFocusSession?.(session)}
|
||||
onMouseLeave={() => handleBlurSession?.()}
|
||||
onClick={() => handleActivateSession?.(session)}
|
||||
className={`cursor-pointer ${focusedSession?.id === session.id ? '!bg-accent/20' : ''}`}
|
||||
>
|
||||
<TableCell className={getStatusTextClass(session.status)}>{session.status}</TableCell>
|
||||
<TableCell>
|
||||
{session.query.length > 50 ? (
|
||||
<Collapsible
|
||||
open={expandedQueryId === session.id}
|
||||
onOpenChange={open => setExpandedQueryId(open ? session.id : null)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-left">
|
||||
<span>{renderHighlightedText(truncate(session.query, 50), session.id)}</span>
|
||||
{expandedQueryId === session.id ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
{renderHighlightedText(session.query, session.id)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<span>{renderHighlightedText(session.query, session.id)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{session.model || <CircleOff className="w-4 h-4" />}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatTimestamp(session.start_time)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatAbsoluteTimestamp(session.start_time)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{formatTimestamp(session.last_activity_at)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatAbsoluteTimestamp(session.last_activity_at)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p className="text-sm">No sessions found</p>
|
||||
{searchText && <p className="text-xs mt-1">Try adjusting your search filters</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
export type Theme = 'solarized-dark' | 'solarized-light' | 'cappuccino' | 'catppuccin' | 'high-contrast'
|
||||
export type Theme =
|
||||
| 'solarized-dark'
|
||||
| 'solarized-light'
|
||||
| 'cappuccino'
|
||||
| 'catppuccin'
|
||||
| 'high-contrast'
|
||||
| 'framer-dark'
|
||||
| 'framer-light'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
|
||||
92
humanlayer-wui/src/hooks/useSessionFilter.ts
Normal file
92
humanlayer-wui/src/hooks/useSessionFilter.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react'
|
||||
import { SessionStatus, SessionInfo } from '@/lib/daemon/types'
|
||||
import { fuzzySearch } from '@/lib/fuzzy-search'
|
||||
|
||||
interface ParsedFilter {
|
||||
statusFilter: SessionStatus | null
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export function parseStatusFilter(query: string): ParsedFilter {
|
||||
// Check if query contains "status:" pattern
|
||||
const statusMatch = query.match(/status:(\S+)/i)
|
||||
|
||||
if (!statusMatch) {
|
||||
return { statusFilter: null, searchText: query }
|
||||
}
|
||||
|
||||
const statusValue = statusMatch[1]
|
||||
|
||||
// Find matching SessionStatus enum value (case-insensitive)
|
||||
const matchingStatus = Object.entries(SessionStatus).find(
|
||||
([, value]) => value.toLowerCase() === statusValue.toLowerCase(),
|
||||
)
|
||||
|
||||
if (!matchingStatus) {
|
||||
return { statusFilter: null, searchText: query }
|
||||
}
|
||||
|
||||
// Remove the status filter from search text
|
||||
const searchTextWithoutFilter = query.replace(statusMatch[0], '').trim()
|
||||
|
||||
return {
|
||||
statusFilter: matchingStatus[1] as SessionStatus,
|
||||
searchText: searchTextWithoutFilter,
|
||||
}
|
||||
}
|
||||
|
||||
interface UseSessionFilterOptions {
|
||||
sessions: SessionInfo[]
|
||||
query: string
|
||||
searchFields?: string[]
|
||||
}
|
||||
|
||||
interface UseSessionFilterResult {
|
||||
filteredSessions: SessionInfo[]
|
||||
statusFilter: SessionStatus | null
|
||||
searchText: string
|
||||
matchedSessions: Map<string, any> // session id -> fuzzy match data
|
||||
}
|
||||
|
||||
export function useSessionFilter({
|
||||
sessions,
|
||||
query,
|
||||
searchFields = ['query'],
|
||||
}: UseSessionFilterOptions): UseSessionFilterResult {
|
||||
return useMemo(() => {
|
||||
// Parse the filter
|
||||
const { statusFilter, searchText } = parseStatusFilter(query)
|
||||
|
||||
// Apply status filter
|
||||
let filtered = sessions
|
||||
if (statusFilter) {
|
||||
filtered = sessions.filter(session => session.status === statusFilter)
|
||||
}
|
||||
|
||||
// Apply fuzzy search if there's search text
|
||||
const matchedSessions = new Map<string, any>()
|
||||
|
||||
if (searchText) {
|
||||
const searchResults = fuzzySearch(filtered, searchText, {
|
||||
keys: searchFields,
|
||||
threshold: 0.1,
|
||||
includeMatches: true,
|
||||
})
|
||||
|
||||
// Build a map of session id to match data for highlighting
|
||||
searchResults.forEach(result => {
|
||||
matchedSessions.set(result.item.id, result)
|
||||
})
|
||||
|
||||
// Update filtered to only include matched sessions
|
||||
filtered = searchResults.map(result => result.item)
|
||||
}
|
||||
|
||||
return {
|
||||
filteredSessions: filtered,
|
||||
statusFilter,
|
||||
searchText,
|
||||
matchedSessions,
|
||||
}
|
||||
}, [sessions, query, searchFields])
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { daemonClient } from '@/lib/daemon'
|
||||
import type { LaunchSessionRequest } from '@/lib/daemon/types'
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook'
|
||||
import { SessionTableHotkeysScope } from '@/components/internal/SessionTable'
|
||||
|
||||
interface SessionConfig {
|
||||
query: string
|
||||
@@ -156,6 +158,8 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
|
||||
|
||||
// Helper hook for global hotkey management
|
||||
export function useSessionLauncherHotkeys() {
|
||||
const { activeScopes } = useHotkeysContext()
|
||||
|
||||
const { open, close, isOpen, gPrefixMode, setGPrefixMode, createNewSession } = useSessionLauncher()
|
||||
|
||||
// Helper to check if user is actively typing in a text input
|
||||
@@ -196,7 +200,13 @@ export function useSessionLauncherHotkeys() {
|
||||
}
|
||||
|
||||
// / - Search sessions and approvals (only when not typing)
|
||||
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
|
||||
if (
|
||||
e.key === '/' &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!isTypingInInput() &&
|
||||
!activeScopes.includes(SessionTableHotkeysScope)
|
||||
) {
|
||||
e.preventDefault()
|
||||
open('search')
|
||||
return
|
||||
|
||||
@@ -1,28 +1,140 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useStore } from '@/AppStore'
|
||||
import SessionTable from '@/components/internal/SessionTable'
|
||||
import { SessionTableSearch } from '@/components/SessionTableSearch'
|
||||
import { useSessionFilter } from '@/hooks/useSessionFilter'
|
||||
import { SessionStatus } from '@/lib/daemon/types'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
// Status values to cycle through with Tab
|
||||
const STATUS_CYCLE = [
|
||||
'', // No filter
|
||||
`status:${SessionStatus.Running}`,
|
||||
`status:${SessionStatus.WaitingInput}`,
|
||||
`status:${SessionStatus.Completed}`,
|
||||
`status:${SessionStatus.Failed}`,
|
||||
`status:${SessionStatus.Starting}`,
|
||||
`status:${SessionStatus.Completing}`,
|
||||
]
|
||||
|
||||
export function SessionTablePage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const tableRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize search from URL params
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') || '')
|
||||
|
||||
const sessions = useStore(state => state.sessions)
|
||||
const focusedSession = useStore(state => state.focusedSession)
|
||||
const setFocusedSession = useStore(state => state.setFocusedSession)
|
||||
const focusNextSession = useStore(state => state.focusNextSession)
|
||||
const focusPreviousSession = useStore(state => state.focusPreviousSession)
|
||||
|
||||
// Update URL when search changes
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setSearchParams({ q: searchQuery })
|
||||
} else {
|
||||
setSearchParams({})
|
||||
}
|
||||
}, [searchQuery, setSearchParams])
|
||||
|
||||
// Use the shared session filter hook
|
||||
const { filteredSessions, statusFilter, searchText, matchedSessions } = useSessionFilter({
|
||||
sessions,
|
||||
query: searchQuery,
|
||||
searchFields: ['query'], // Only search in query field for the table
|
||||
})
|
||||
|
||||
const handleActivateSession = (session: any) => {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
|
||||
// Custom navigation functions that work with filtered sessions
|
||||
const focusNextSession = () => {
|
||||
if (filteredSessions.length === 0) return
|
||||
|
||||
const currentIndex = focusedSession
|
||||
? filteredSessions.findIndex(s => s.id === focusedSession.id)
|
||||
: -1
|
||||
|
||||
// If no session is focused or we're at the last session, focus the first session
|
||||
if (currentIndex === -1 || currentIndex === filteredSessions.length - 1) {
|
||||
setFocusedSession(filteredSessions[0])
|
||||
} else {
|
||||
// Focus the next session
|
||||
setFocusedSession(filteredSessions[currentIndex + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviousSession = () => {
|
||||
if (filteredSessions.length === 0) return
|
||||
|
||||
const currentIndex = focusedSession
|
||||
? filteredSessions.findIndex(s => s.id === focusedSession.id)
|
||||
: -1
|
||||
|
||||
// If no session is focused or we're at the first session, focus the last session
|
||||
if (currentIndex === -1 || currentIndex === 0) {
|
||||
setFocusedSession(filteredSessions[filteredSessions.length - 1])
|
||||
} else {
|
||||
// Focus the previous session
|
||||
setFocusedSession(filteredSessions[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Tab key to cycle through status filters
|
||||
useHotkeys(
|
||||
'tab',
|
||||
e => {
|
||||
e.preventDefault()
|
||||
const currentStatusIndex = STATUS_CYCLE.findIndex(status => {
|
||||
if (!status && !statusFilter) return true
|
||||
return status === `status:${statusFilter}`
|
||||
})
|
||||
const nextIndex = (currentStatusIndex + 1) % STATUS_CYCLE.length
|
||||
setSearchQuery(STATUS_CYCLE[nextIndex])
|
||||
},
|
||||
{ enableOnFormTags: false },
|
||||
)
|
||||
|
||||
// Handle Shift+Tab to cycle backwards through status filters
|
||||
useHotkeys(
|
||||
'shift+tab',
|
||||
e => {
|
||||
e.preventDefault()
|
||||
const currentStatusIndex = STATUS_CYCLE.findIndex(status => {
|
||||
if (!status && !statusFilter) return true
|
||||
return status === `status:${statusFilter}`
|
||||
})
|
||||
const prevIndex = currentStatusIndex <= 0 ? STATUS_CYCLE.length - 1 : currentStatusIndex - 1
|
||||
setSearchQuery(STATUS_CYCLE[prevIndex])
|
||||
},
|
||||
{ enableOnFormTags: false },
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionTable
|
||||
sessions={sessions}
|
||||
handleFocusSession={session => setFocusedSession(session)}
|
||||
handleBlurSession={() => setFocusedSession(null)}
|
||||
handleActivateSession={handleActivateSession}
|
||||
focusedSession={focusedSession}
|
||||
handleFocusNextSession={focusNextSession}
|
||||
handleFocusPreviousSession={focusPreviousSession}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SessionTableSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
statusFilter={statusFilter}
|
||||
placeholder="Search sessions or filter by status:..."
|
||||
/>
|
||||
|
||||
<div ref={tableRef} tabIndex={-1} className="focus:outline-none">
|
||||
<SessionTable
|
||||
sessions={filteredSessions}
|
||||
handleFocusSession={session => setFocusedSession(session)}
|
||||
handleBlurSession={() => setFocusedSession(null)}
|
||||
handleActivateSession={handleActivateSession}
|
||||
focusedSession={focusedSession}
|
||||
handleFocusNextSession={focusNextSession}
|
||||
handleFocusPreviousSession={focusPreviousSession}
|
||||
searchText={searchText}
|
||||
matchedSessions={matchedSessions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ export function formatTimestamp(date: Date | string): string {
|
||||
|
||||
// Use date-fns for relative time formatting
|
||||
const distance = formatDistanceToNow(d, { addSuffix: true })
|
||||
|
||||
|
||||
// For dates older than 7 days, show actual date
|
||||
const daysDiff = Math.floor((Date.now() - d.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysDiff > 7) {
|
||||
return format(d, 'MMM d, yyyy')
|
||||
}
|
||||
|
||||
|
||||
return distance
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ export function formatAbsoluteTimestamp(date: Date | string): string {
|
||||
export function formatDuration(startTime: Date | string, endTime?: Date | string): string {
|
||||
const start = parseDate(startTime)
|
||||
const end = endTime ? parseDate(endTime) : new Date()
|
||||
|
||||
|
||||
if (!isValid(start) || !isValid(end)) return 'Invalid duration'
|
||||
|
||||
|
||||
const duration = intervalToDuration({ start, end })
|
||||
|
||||
|
||||
if (duration.hours && duration.hours > 0) {
|
||||
return `${duration.hours}h ${duration.minutes || 0}m`
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"target": "ES2020",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
const port = process.env.VITE_PORT ? parseInt(process.env.VITE_PORT) : 1420
|
||||
const hmrPort = port + 1
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
@@ -22,14 +24,14 @@ export default defineConfig(async () => ({
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
port: port,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
port: hmrPort,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
|
||||
Reference in New Issue
Block a user