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:
dexhorthy
2025-06-24 13:18:31 -07:00
parent ed86f654a7
commit 08bca9744d
28 changed files with 1950 additions and 1166 deletions

3
.gitignore vendored
View File

@@ -178,6 +178,3 @@ cython_debug/
.Trashes
.idea/
# HumanLayer thoughts directory
/thoughts/

152
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Generated Tauri config for local development
src-tauri/tauri.conf.local.json

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,6 +136,7 @@ export default function FuzzySearchInput<T>({
return (
<div className="relative">
<input
spellCheck={false}
ref={inputRef}
type="text"
value={value}

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

1787
uv.lock generated

File diff suppressed because it is too large Load Diff