## CHANGES
- Explore markdown relationships with the new interactive Document Graph view 🕸️ - Jump into Document Graph from explorer, quick actions, and file preview ⌨️ - Navigate graphs keyboard-first with depth control, search, and node positioning 🧭 - Optionally surface external-link domains as nodes for instant reference 🔗 - Group Chat now mixes local and SSH-remote agents for cross-machine teamwork 🛰️ - SSH Remote Execution docs revamped with clearer setup, mapping, and status cues 🛠️ - Remote sessions now fully support File Explorer, Auto Run, worktrees, and terminal 🧰 - File explorer context menu adds “Document Graph” for markdown files only 📁 - Edit Agent modal lets you copy session ID from a slick custom header 🪪 - Freshened app branding with updated icons across platforms 🎨
BIN
build/icon.icns
BIN
build/icon.ico
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 68 KiB |
BIN
build/icon.png
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
build/new-icon/icon.icns
Normal file
BIN
build/new-icon/icon.ico
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
build/new-icon/icon.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
@@ -61,6 +61,7 @@
|
||||
"openspec-commands",
|
||||
"history",
|
||||
"context-management",
|
||||
"document-graph",
|
||||
"autorun-playbooks",
|
||||
"playbook-exchange",
|
||||
"git-worktrees",
|
||||
|
||||
143
docs/document-graph.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: Document Graph
|
||||
description: Visualize markdown file relationships and wiki-link connections in an interactive graph view.
|
||||
icon: diagram-project
|
||||
---
|
||||
|
||||
The Document Graph provides an interactive visualization of your markdown files and their connections. See how documents link to each other through wiki-links (`[[link]]`) and standard markdown links, making it easy to understand your documentation structure at a glance.
|
||||
|
||||

|
||||
|
||||
## Opening the Document Graph
|
||||
|
||||
There are several ways to access the Document Graph:
|
||||
|
||||
### From the File Explorer
|
||||
|
||||
Click the **graph icon** (circular arrows) in the Files tab header to open the Document Graph for your current project.
|
||||
|
||||

|
||||
|
||||
### From Quick Actions
|
||||
|
||||
Press `Cmd+K` / `Ctrl+K` and search for "Document Graph" to open it directly.
|
||||
|
||||
### From File Preview
|
||||
|
||||
When viewing a markdown file in File Preview, press `Cmd+Shift+G` / `Ctrl+Shift+G` to open the Document Graph focused on that file. Press `Esc` to return to the File Preview.
|
||||
|
||||
### Using Go to File
|
||||
|
||||
Press `Cmd+G` / `Ctrl+G` to open the fuzzy file finder, navigate to any markdown file, then use `Cmd+Shift+G` to jump to the Document Graph from there.
|
||||
|
||||
## Navigating the Graph
|
||||
|
||||
The Document Graph is designed for keyboard-first navigation:
|
||||
|
||||
| Action | Key |
|
||||
|--------|-----|
|
||||
| Navigate to connected nodes | `Arrow Keys` (spatial detection) |
|
||||
| Focus/select a node | `Enter` |
|
||||
| Open the selected document | `O` |
|
||||
| Close the graph | `Esc` |
|
||||
| Cycle through connected nodes | `Tab` |
|
||||
|
||||
### Mouse Controls
|
||||
|
||||
- **Click** a node to select it
|
||||
- **Double-click** a node to recenter the view on it
|
||||
- **Drag** nodes to reposition them — positions are saved
|
||||
- **Scroll** to zoom in and out
|
||||
- **Pan** by dragging the background
|
||||
|
||||
## Graph Controls
|
||||
|
||||
The toolbar at the top of the Document Graph provides several options:
|
||||
|
||||
### Depth Control
|
||||
|
||||
Adjust the **Depth** setting to control how many levels of connections are shown from the focused document:
|
||||
|
||||
- **Depth: 1** — Show only direct connections
|
||||
- **Depth: 2** — Show connections and their connections (default)
|
||||
- **Depth: 3+** — Show deeper relationship chains
|
||||
|
||||
Lower depth values keep the graph focused; higher values reveal the full document ecosystem.
|
||||
|
||||
### External Links
|
||||
|
||||
Toggle **External** to show or hide external URL links found in your documents:
|
||||
|
||||
- **Enabled** — External links appear as separate domain nodes (e.g., "github.com", "docs.example.com")
|
||||
- **Disabled** — Only internal document relationships are shown
|
||||
|
||||
External link nodes help you see which external resources your documentation references.
|
||||
|
||||
### Search
|
||||
|
||||
Use the search box to filter documents by name. Matching documents are highlighted in the graph.
|
||||
|
||||
## Understanding the Graph
|
||||
|
||||
### Node Types
|
||||
|
||||
- **Document nodes** — Your markdown files, showing the filename and a preview of content
|
||||
- **External link nodes** — Domains of external URLs referenced in your documents
|
||||
- **Focused node** — The currently selected document (highlighted with a different border)
|
||||
|
||||
### Edge Types
|
||||
|
||||
Lines between nodes represent different types of connections:
|
||||
|
||||
- **Wiki-links** — `[[document-name]]` style links
|
||||
- **Markdown links** — `[text](path/to/file.md)` style links
|
||||
- **External links** — Links to URLs outside your project
|
||||
|
||||
### Node Information
|
||||
|
||||
Each document node displays:
|
||||
|
||||
- **Filename** — The document name
|
||||
- **Folder indicator** — Shows the parent directory (e.g., "docs")
|
||||
- **Content preview** — A snippet of the document's content
|
||||
|
||||
## Tips for Effective Use
|
||||
|
||||
### Workflow Integration
|
||||
|
||||
1. Use `Cmd+G` to quickly find a file
|
||||
2. Open it in File Preview to read or edit
|
||||
3. Press `Cmd+Shift+G` to see its connections in the Document Graph
|
||||
4. Press `O` to open a connected document
|
||||
5. Press `Esc` to return to File Preview
|
||||
|
||||
### Large Documentation Sets
|
||||
|
||||
For projects with many markdown files:
|
||||
|
||||
- Start with **Depth: 1** to see immediate connections
|
||||
- Increase depth gradually to explore relationships
|
||||
- Use **Search** to find specific documents quickly
|
||||
- Drag nodes to organize the view — positions persist
|
||||
|
||||
### Understanding Documentation Structure
|
||||
|
||||
The Document Graph is especially useful for:
|
||||
|
||||
- **Auditing links** — Find orphaned documents with no incoming links
|
||||
- **Understanding navigation** — See how documents connect for readers
|
||||
- **Planning restructuring** — Visualize the impact of moving or renaming files
|
||||
- **Onboarding** — Help new team members understand documentation architecture
|
||||
|
||||
## Keyboard Shortcut Summary
|
||||
|
||||
| Action | macOS | Windows/Linux |
|
||||
|--------|-------|---------------|
|
||||
| Open Document Graph | Via `Cmd+K` menu | Via `Ctrl+K` menu |
|
||||
| Open from File Preview | `Cmd+Shift+G` | `Ctrl+Shift+G` |
|
||||
| Go to File (fuzzy finder) | `Cmd+G` | `Ctrl+G` |
|
||||
| Navigate nodes | `Arrow Keys` | `Arrow Keys` |
|
||||
| Select/focus node | `Enter` | `Enter` |
|
||||
| Open document | `O` | `O` |
|
||||
| Cycle connected nodes | `Tab` | `Tab` |
|
||||
| Close graph | `Esc` | `Esc` |
|
||||
@@ -23,6 +23,7 @@ icon: sparkles
|
||||
- 📋 **Session Discovery** - Automatically discovers and imports existing sessions from all supported providers, including conversations from before Maestro was installed. Browse, search, star, rename, and resume any session.
|
||||
- 🔀 **Git Integration** - Automatic repo detection, branch display, diff viewer, commit logs, and git-aware file completion. Work with git without leaving the app.
|
||||
- 📁 **[File Explorer](./general-usage)** - Browse project files with syntax highlighting, markdown preview, and image viewing. Reference files in prompts with `@` mentions.
|
||||
- 🕸️ **[Document Graph](./document-graph)** - Visualize markdown file relationships and wiki-link connections in an interactive graph. Navigate with keyboard shortcuts, adjust depth, and see how your documentation connects.
|
||||
- 🔍 **[Powerful Output Filtering](./general-usage)** - Search and filter AI output with include/exclude modes, regex support, and per-response local filters.
|
||||
- ⚡ **[Slash Commands](./slash-commands)** - Extensible command system with autocomplete. Create custom commands with template variables for your workflows. Includes bundled [Spec-Kit](./speckit-commands) for feature specifications and [OpenSpec](./openspec-commands) for change proposals.
|
||||
- 💾 **Draft Auto-Save** - Never lose work. Drafts are automatically saved and restored per session.
|
||||
|
||||
@@ -14,6 +14,7 @@ Group Chat lets you coordinate multiple AI agents in a single conversation. A mo
|
||||
- **Architecture discussions**: Get perspectives from agents with different codebase contexts
|
||||
- **Comparative analysis**: "Compare the testing approach in these three repositories"
|
||||
- **Knowledge synthesis**: Combine expertise from specialized agents
|
||||
- **Cross-machine collaboration**: Coordinate agents running on different machines via [SSH Remote Execution](./ssh-remote-execution)
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -57,9 +58,30 @@ Moderator: "Here's how they relate:
|
||||
Next steps: Would you like details on any specific integration?"
|
||||
```
|
||||
|
||||
## Remote Agents in Group Chat
|
||||
|
||||
Group Chat works seamlessly with [SSH Remote Execution](./ssh-remote-execution). You can mix local and remote agents in the same conversation:
|
||||
|
||||

|
||||
|
||||
**Supported configurations:**
|
||||
- Local moderator with remote participants
|
||||
- Remote moderator with local participants
|
||||
- Any mix of local and remote agents
|
||||
- Agents spread across multiple SSH hosts
|
||||
|
||||
Remote agents are identified by the **REMOTE** pill in the participant list. Each agent works in their own environment — the moderator coordinates across machines transparently.
|
||||
|
||||
**Use cases for remote Group Chat:**
|
||||
- Compare implementations across development and production environments
|
||||
- Get perspectives from agents with access to different servers
|
||||
- Coordinate changes that span multiple machines
|
||||
- Synthesize information from agents with different tool installations
|
||||
|
||||
## Tips for Effective Group Chats
|
||||
|
||||
- **Name agents descriptively** - Agent names appear in the chat, so "Frontend-React" is clearer than "Agent1"
|
||||
- **Be specific in questions** - The more context you provide, the better the moderator can route
|
||||
- **@mention explicitly** - You can direct questions to specific agents: "What does @Backend think?"
|
||||
- **Let the moderator work** - It may take multiple rounds for complex questions
|
||||
- **Mix local and remote** - Combine agents across machines for maximum coverage
|
||||
|
||||
@@ -124,15 +124,16 @@ In AI mode, use `@` to reference files in your prompts:
|
||||
|
||||
## Navigation & Search
|
||||
|
||||
| Action | Key |
|
||||
|--------|-----|
|
||||
| Navigate Agents | `Up/Down Arrow` while in sidebar |
|
||||
| Select Agent | `Enter` while in sidebar |
|
||||
| Open Session Filter | `Cmd+F` while in sidebar |
|
||||
| Navigate Files | `Up/Down Arrow` while in file tree |
|
||||
| Open File Tree Filter | `Cmd+F` while in file tree |
|
||||
| Open File Preview | `Enter` on selected file |
|
||||
| Close Preview/Filter/Modal | `Esc` |
|
||||
| Action | macOS | Windows/Linux |
|
||||
|--------|-------|---------------|
|
||||
| Go to File (fuzzy finder) | `Cmd+G` | `Ctrl+G` |
|
||||
| Navigate Agents | `Up/Down Arrow` while in sidebar | `Up/Down Arrow` while in sidebar |
|
||||
| Select Agent | `Enter` while in sidebar | `Enter` while in sidebar |
|
||||
| Open Session Filter | `Cmd+F` while in sidebar | `Ctrl+F` while in sidebar |
|
||||
| Navigate Files | `Up/Down Arrow` while in file tree | `Up/Down Arrow` while in file tree |
|
||||
| Open File Tree Filter | `Cmd+F` while in file tree | `Ctrl+F` while in file tree |
|
||||
| Open File Preview | `Enter` on selected file | `Enter` on selected file |
|
||||
| Close Preview/Filter/Modal | `Esc` | `Esc` |
|
||||
|
||||
## File Preview
|
||||
|
||||
@@ -140,9 +141,20 @@ In AI mode, use `@` to reference files in your prompts:
|
||||
|--------|-------|---------------|
|
||||
| Copy File Path | `Cmd+P` | `Ctrl+P` |
|
||||
| Open Search | `Cmd+F` | `Ctrl+F` |
|
||||
| Open Document Graph | `Cmd+Shift+G` | `Ctrl+Shift+G` |
|
||||
| Scroll | `Up/Down Arrow` | `Up/Down Arrow` |
|
||||
| Close | `Esc` | `Esc` |
|
||||
|
||||
## Document Graph
|
||||
|
||||
| Action | Key |
|
||||
|--------|-----|
|
||||
| Navigate to connected nodes | `Arrow Keys` |
|
||||
| Focus/select a node | `Enter` |
|
||||
| Open the selected document | `O` |
|
||||
| Cycle through connected nodes | `Tab` |
|
||||
| Close the graph | `Esc` |
|
||||
|
||||
## Customizing Shortcuts
|
||||
|
||||
Most shortcuts can be remapped to fit your workflow:
|
||||
|
||||
BIN
docs/screenshots/document-graph-last-graph.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/screenshots/document-graph.png
Normal file
|
After Width: | Height: | Size: 697 KiB |
BIN
docs/screenshots/group-chat-over-ssh.png
Normal file
|
After Width: | Height: | Size: 835 KiB |
BIN
docs/screenshots/ssh-agents-mapping.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
docs/screenshots/ssh-agents-servers.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/screenshots/ssh-agents-status.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
@@ -15,16 +15,20 @@ SSH Remote Execution wraps agent commands in SSH, executing them on a configured
|
||||
- Access tools or SDKs installed only on specific servers
|
||||
- Work with codebases that require particular OS or architecture
|
||||
- Execute agents in secure/isolated environments
|
||||
- Coordinate multiple agents across different machines in [Group Chat](./group-chat)
|
||||
- Run Auto Run playbooks on remote projects
|
||||
|
||||
## Configuring SSH Remotes
|
||||
|
||||
### Adding a Remote Host
|
||||
|
||||
1. Open **Settings** (`Cmd+,` / `Ctrl+,`)
|
||||
2. Scroll to the **SSH Remote Hosts** section under Remote Execution
|
||||
2. Navigate to the **SSH Hosts** tab
|
||||
3. Click **Add SSH Remote**
|
||||
4. Configure the connection:
|
||||
|
||||

|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | Display name for this remote (e.g., "Dev Server", "GPU Box") |
|
||||
@@ -118,9 +122,11 @@ Each agent can have its own SSH remote setting, overriding the global default.
|
||||
### Configuring an Agent
|
||||
|
||||
1. Open the agent's configuration panel (gear icon in session header, or via Settings → Agents)
|
||||
2. Find the **SSH Remote** dropdown
|
||||
2. Find the **SSH Remote Execution** dropdown
|
||||
3. Select an option:
|
||||
|
||||

|
||||
|
||||
| Option | Behavior |
|
||||
|--------|----------|
|
||||
| **Use Global Default** | Follows the global setting (shows which remote if one is set) |
|
||||
@@ -139,11 +145,64 @@ When spawning an agent, Maestro resolves which SSH remote to use:
|
||||
|
||||
## Status Visibility
|
||||
|
||||
When a session is running via SSH remote:
|
||||
- The session displays the remote host name in the status area
|
||||
When a session is running via SSH remote, you can easily identify it:
|
||||
|
||||

|
||||
|
||||
- **REMOTE pill** — Appears in the Left Bar next to the agent, indicating it's configured for remote execution
|
||||
- **Host name badge** — Displayed in the Main Panel header showing which SSH host the agent is running on (e.g., "PEDTOME")
|
||||
- **Agent type indicator** — Shows "claude-code (SSH)" to clarify the execution mode
|
||||
- Connection state reflects SSH connectivity
|
||||
- Errors are detected and displayed with SSH-specific context
|
||||
|
||||
## Full Remote Capabilities
|
||||
|
||||
Remote agents support all the features you'd expect from local agents:
|
||||
|
||||
### Remote File System Access
|
||||
|
||||
The File Explorer works seamlessly with remote agents:
|
||||
- Browse files and directories on the remote host
|
||||
- Open and edit files directly
|
||||
- Use `@` file mentions to reference remote files in prompts
|
||||
|
||||
### Remote Auto Run
|
||||
|
||||
Run Auto Run playbooks on remote projects:
|
||||
- Auto Run documents can reference files on the remote host
|
||||
- Task execution happens on the remote machine
|
||||
- Progress and results stream back to Maestro in real-time
|
||||
|
||||
### Remote Git Worktrees
|
||||
|
||||
Create and manage git worktrees on remote repositories:
|
||||
- Worktree sub-agents run on the same remote host
|
||||
- Branch isolation works just like local worktrees
|
||||
- PR creation connects to the remote repository
|
||||
|
||||
### Remote Command Terminal
|
||||
|
||||
The Command Terminal executes commands on the remote host:
|
||||
- Full PTY support for interactive commands
|
||||
- Tab completion works with remote file paths
|
||||
- Command history is preserved per-session
|
||||
|
||||
### Group Chat with Remote Agents
|
||||
|
||||
Remote agents can participate in Group Chat alongside local agents. This enables powerful cross-machine collaboration:
|
||||
|
||||

|
||||
|
||||
- Mix local and remote agents in the same conversation
|
||||
- The moderator can be local or remote
|
||||
- Each agent works in their own environment (local or remote)
|
||||
- Synthesize information across different machines and codebases
|
||||
|
||||
This is especially useful for:
|
||||
- Comparing implementations across different environments
|
||||
- Coordinating changes that span multiple servers
|
||||
- Getting perspectives from agents with access to different resources
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Tests for the GraphLegend component
|
||||
*
|
||||
* The GraphLegend displays a collapsible panel explaining node types, edge types,
|
||||
* keyboard shortcuts, and interaction hints in the Mind Map visualization.
|
||||
* The GraphLegend displays a sliding panel explaining node types, edge types,
|
||||
* keyboard shortcuts, and interaction hints in the Document Graph visualization.
|
||||
*
|
||||
* The panel is always shown (no collapsed state) and can be closed via onClose callback.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
@@ -55,31 +57,32 @@ const lightTheme: Theme = {
|
||||
const defaultProps: GraphLegendProps = {
|
||||
theme: mockTheme,
|
||||
showExternalLinks: true,
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
describe('GraphLegend', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders in collapsed state by default', () => {
|
||||
it('renders as a sliding panel with Help heading', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// Should show the header button
|
||||
expect(screen.getByRole('button', { name: /legend/i })).toBeInTheDocument();
|
||||
// Should show the Help heading
|
||||
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show the content sections
|
||||
expect(screen.queryByText('Node Types')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders in expanded state when defaultExpanded is true', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
// Should show the content sections
|
||||
// Should show all content sections
|
||||
expect(screen.getByText('Node Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connection Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Selection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has correct aria label on the panel', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const panel = screen.getByRole('region', { name: /help panel/i });
|
||||
expect(panel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all node types when showExternalLinks is true', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks />);
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks />);
|
||||
|
||||
expect(screen.getByText('Document')).toBeInTheDocument();
|
||||
// External Link appears in both Node Types and Connection Types sections
|
||||
@@ -88,7 +91,7 @@ describe('GraphLegend', () => {
|
||||
});
|
||||
|
||||
it('hides external node type when showExternalLinks is false', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks={false} />);
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks={false} />);
|
||||
|
||||
expect(screen.getByText('Document')).toBeInTheDocument();
|
||||
// External Link should appear once in "Connection Types" section for edges
|
||||
@@ -98,460 +101,330 @@ describe('GraphLegend', () => {
|
||||
});
|
||||
|
||||
it('renders all edge types when showExternalLinks is true', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks />);
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks />);
|
||||
|
||||
expect(screen.getByText('Internal Link')).toBeInTheDocument();
|
||||
// External Link appears in both Node Types and Connection Types sections
|
||||
const allExternalLinks = screen.getAllByText('External Link');
|
||||
expect(allExternalLinks.length).toBe(2); // One in nodes, one in edges
|
||||
expect(screen.getAllByText('External Link').length).toBe(2);
|
||||
});
|
||||
|
||||
it('hides external edge type when showExternalLinks is false', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks={false} />);
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks={false} />);
|
||||
|
||||
expect(screen.getByText('Internal Link')).toBeInTheDocument();
|
||||
// External Link edge type should not be shown
|
||||
const connectionTypesSection = screen.getByText('Connection Types').parentElement;
|
||||
expect(within(connectionTypesSection!).queryByText('External Link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// External Link should not appear at all when external links are disabled
|
||||
expect(screen.queryByText('External Link')).not.toBeInTheDocument();
|
||||
describe('Close Button', () => {
|
||||
it('renders close button with correct title', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const closeButton = screen.getByTitle('Close (Esc)');
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders selection section with selected node preview', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<GraphLegend {...defaultProps} onClose={onClose} />);
|
||||
|
||||
expect(screen.getByText('Selection')).toBeInTheDocument();
|
||||
expect(screen.getByText('Selected Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected Edge')).toBeInTheDocument();
|
||||
const closeButton = screen.getByTitle('Close (Esc)');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders keyboard shortcuts section', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
describe('Keyboard Shortcuts Section', () => {
|
||||
it('shows keyboard shortcuts section', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays navigation shortcut', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('↑ ↓ ← →')).toBeInTheDocument();
|
||||
expect(screen.getByText('Navigate between nodes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays enter shortcut', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Enter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recenter on focused node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays open shortcut', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('O')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open file in preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders interaction hints', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
it('displays search shortcut', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('⌘F')).toBeInTheDocument();
|
||||
expect(screen.getByText('Focus search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays escape shortcut', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
expect(screen.getByText('Close panel / modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Actions Section', () => {
|
||||
it('shows mouse actions section', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Mouse Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays click action', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Click')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays double-click action', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Double-click')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recenter view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays right-click action', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Right-click')).toBeInTheDocument();
|
||||
expect(screen.getByText('Context menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays drag action', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Drag')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reposition node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays scroll action', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Scroll')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zoom in/out')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status indicators section', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Status Indicators')).toBeInTheDocument();
|
||||
expect(screen.getByText('Broken Links')).toBeInTheDocument();
|
||||
expect(screen.getByText('Links to non-existent files')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toggle Behavior', () => {
|
||||
it('expands when header is clicked', () => {
|
||||
describe('Selection Section', () => {
|
||||
it('shows selection section', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByText('Node Types')).not.toBeInTheDocument();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(screen.getByRole('button', { name: /legend/i }));
|
||||
|
||||
// Should now show content
|
||||
expect(screen.getByText('Node Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Selection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses when header is clicked again', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
// Initially expanded
|
||||
expect(screen.getByText('Node Types')).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(screen.getByRole('button', { name: /legend/i }));
|
||||
|
||||
// Should no longer show content
|
||||
expect(screen.queryByText('Node Types')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles multiple times correctly', () => {
|
||||
it('displays selected node info', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
|
||||
// First click: expand
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Node Types')).toBeInTheDocument();
|
||||
|
||||
// Second click: collapse
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText('Node Types')).not.toBeInTheDocument();
|
||||
|
||||
// Third click: expand again
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByText('Node Types')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has region role with aria-label', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// Mind map legend uses "Mind map legend" as aria-label
|
||||
expect(screen.getByRole('region', { name: /mind map legend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggle button has aria-expanded attribute', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('aria-expanded updates when toggled', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('toggle button has aria-controls referencing content', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
expect(button).toHaveAttribute('aria-controls', 'legend-content');
|
||||
});
|
||||
|
||||
it('content container has matching id', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(document.getElementById('legend-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('node previews have aria-label', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
// Document node card appears in Node Types and Selection (as selected)
|
||||
const docNodes = screen.getAllByRole('img', { name: /document node card/i });
|
||||
expect(docNodes.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByRole('img', { name: /external link node pill/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /document node card \(selected\)/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edge previews have aria-label', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
// Internal link edge appears in Connection Types and Selection (as highlighted)
|
||||
const internalEdges = screen.getAllByRole('img', { name: /internal link edge/i });
|
||||
expect(internalEdges.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByRole('img', { name: /external link edge/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /internal link edge \(highlighted\)/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('broken links indicator has aria-label', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByRole('img', { name: /broken links warning indicator/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Styling', () => {
|
||||
it('applies theme background color to container', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity });
|
||||
});
|
||||
|
||||
it('applies theme border color to container', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
// Border is a shorthand, check individual properties
|
||||
expect(legend).toHaveStyle({ borderWidth: '1px', borderStyle: 'solid' });
|
||||
});
|
||||
|
||||
it('applies light theme colors correctly', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} theme={lightTheme} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity });
|
||||
});
|
||||
|
||||
it('applies accent color to header background', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
expect(button).toHaveStyle({ backgroundColor: `${mockTheme.colors.accent}10` });
|
||||
});
|
||||
|
||||
it('section headers use dim text color', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
// Get all section headers
|
||||
const nodeTypesHeader = screen.getByText('Node Types');
|
||||
expect(nodeTypesHeader).toHaveStyle({ color: mockTheme.colors.textDim });
|
||||
});
|
||||
|
||||
it('item labels use main text color', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const documentLabel = screen.getByText('Document');
|
||||
expect(documentLabel).toHaveStyle({ color: mockTheme.colors.textMain });
|
||||
});
|
||||
|
||||
it('item descriptions use dim text color with opacity', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const description = screen.getByText('Card with title and description');
|
||||
expect(description).toHaveStyle({ color: mockTheme.colors.textDim, opacity: '0.8' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Preview Styling (Mind Map Cards)', () => {
|
||||
it('document node preview renders as SVG card with rect', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const docNode = screen.getByRole('img', { name: /^document node card$/i });
|
||||
const rect = docNode.querySelector('rect');
|
||||
expect(rect).toBeInTheDocument();
|
||||
// Card has rounded corners (rx=4)
|
||||
expect(rect).toHaveAttribute('rx', '4');
|
||||
});
|
||||
|
||||
it('external node preview renders as pill-shaped SVG', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const extNode = screen.getByRole('img', { name: /^external link node pill$/i });
|
||||
const rect = extNode.querySelector('rect');
|
||||
expect(rect).toBeInTheDocument();
|
||||
// Pill has high rx value for rounded ends (rx=7)
|
||||
expect(rect).toHaveAttribute('rx', '7');
|
||||
});
|
||||
|
||||
it('selected document node preview has accent stroke', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const selectedNode = screen.getByRole('img', { name: /document node card \(selected\)/i });
|
||||
const rect = selectedNode.querySelector('rect');
|
||||
expect(rect).toBeInTheDocument();
|
||||
// Selected nodes have accent stroke color
|
||||
expect(rect).toHaveAttribute('stroke', mockTheme.colors.accent);
|
||||
});
|
||||
|
||||
it('document node preview uses bgActivity fill color', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const docNode = screen.getByRole('img', { name: /^document node card$/i });
|
||||
const rect = docNode.querySelector('rect');
|
||||
expect(rect).toBeInTheDocument();
|
||||
expect(rect).toHaveAttribute('fill', mockTheme.colors.bgActivity);
|
||||
});
|
||||
|
||||
it('external node preview uses bgMain fill color', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const extNode = screen.getByRole('img', { name: /^external link node pill$/i });
|
||||
const rect = extNode.querySelector('rect');
|
||||
expect(rect).toBeInTheDocument();
|
||||
expect(rect).toHaveAttribute('fill', mockTheme.colors.bgMain);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Preview Styling (Bezier Curves)', () => {
|
||||
it('internal edge preview uses bezier path', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i });
|
||||
const path = internalEdge.querySelector('path');
|
||||
expect(path).toBeInTheDocument();
|
||||
// Should NOT have stroke-dasharray (solid line)
|
||||
expect(path).not.toHaveAttribute('stroke-dasharray');
|
||||
});
|
||||
|
||||
it('external edge preview is dashed bezier path', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const externalEdge = screen.getByRole('img', { name: /external link edge(?! \(highlighted\))/i });
|
||||
const path = externalEdge.querySelector('path');
|
||||
expect(path).toBeInTheDocument();
|
||||
expect(path).toHaveAttribute('stroke-dasharray', '4 3');
|
||||
});
|
||||
|
||||
it('internal edge uses dim text color for stroke', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i });
|
||||
const path = internalEdge.querySelector('path');
|
||||
expect(path).toHaveAttribute('stroke', mockTheme.colors.textDim);
|
||||
});
|
||||
|
||||
it('highlighted edge uses accent color for stroke', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i });
|
||||
const path = highlightedEdge.querySelector('path');
|
||||
expect(path).toHaveAttribute('stroke', mockTheme.colors.accent);
|
||||
});
|
||||
|
||||
it('highlighted edge has thicker stroke width', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i });
|
||||
const path = highlightedEdge.querySelector('path');
|
||||
expect(path).toHaveAttribute('stroke-width', '2');
|
||||
});
|
||||
|
||||
it('normal edge has thinner stroke width', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
const normalEdge = screen.getByRole('img', { name: /^internal link edge$/i });
|
||||
const path = normalEdge.querySelector('path');
|
||||
expect(path).toHaveAttribute('stroke-width', '1.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Styling', () => {
|
||||
it('has correct CSS class for styling', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(container.querySelector('.graph-legend')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is positioned absolutely at bottom center', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveClass('absolute');
|
||||
// Position is set via inline styles: bottom: 16, left: '50%', transform: 'translateX(-50%)'
|
||||
expect(legend).toHaveStyle({
|
||||
bottom: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
});
|
||||
});
|
||||
|
||||
it('has max-width constraint', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveStyle({ maxWidth: '300px' });
|
||||
});
|
||||
|
||||
it('has z-index for stacking above graph', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveStyle({ zIndex: '10' });
|
||||
});
|
||||
|
||||
it('has rounded corners', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveClass('rounded-lg');
|
||||
});
|
||||
|
||||
it('has shadow for elevation', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveClass('shadow-lg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Descriptions', () => {
|
||||
it('document node has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Card with title and description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('external node has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Pill showing domain name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('internal edge has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Connection between markdown files')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('external edge has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Connection to external domain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selected node has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
|
||||
expect(screen.getByText('Selected Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Click or navigate to select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('connected edge has correct description', () => {
|
||||
render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
it('displays connected edge info', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Connected Edge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edges to/from selected node')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chevron Icons', () => {
|
||||
it('shows ChevronUp when collapsed (indicating can expand)', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
describe('Status Indicators Section', () => {
|
||||
it('shows status indicators section', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// When collapsed, clicking expands - so show up chevron
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
const svgs = button.querySelectorAll('svg');
|
||||
expect(svgs.length).toBe(1); // Should have one chevron icon
|
||||
expect(screen.getByText('Status Indicators')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows ChevronDown when expanded (indicating can collapse)', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} defaultExpanded />);
|
||||
it('displays broken links indicator', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /legend/i });
|
||||
const svgs = button.querySelectorAll('svg');
|
||||
expect(svgs.length).toBe(1); // Should have one chevron icon
|
||||
expect(screen.getByText('Broken Links')).toBeInTheDocument();
|
||||
expect(screen.getByText('Links to non-existent files')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has accessible broken links indicator icon', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const indicator = screen.getByRole('img', { name: /broken links warning indicator/i });
|
||||
expect(indicator).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Preview Icons', () => {
|
||||
it('renders document node preview with aria-label', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const docPreviews = screen.getAllByRole('img', { name: /document node card/i });
|
||||
expect(docPreviews.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders external link node preview when showExternalLinks is true', () => {
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks />);
|
||||
|
||||
const extPreviews = screen.getAllByRole('img', { name: /external link node pill/i });
|
||||
expect(extPreviews.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not render external link node preview when showExternalLinks is false', () => {
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks={false} />);
|
||||
|
||||
expect(screen.queryByRole('img', { name: /external link node pill/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders selected document node preview', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// Selected node preview has (selected) in aria-label
|
||||
const selectedPreviews = screen.getAllByRole('img', { name: /document node card \(selected\)/i });
|
||||
expect(selectedPreviews.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Preview Icons', () => {
|
||||
it('renders internal link edge preview', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const internalEdges = screen.getAllByRole('img', { name: /internal link edge/i });
|
||||
expect(internalEdges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders external link edge preview when showExternalLinks is true', () => {
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks />);
|
||||
|
||||
const externalEdges = screen.getAllByRole('img', { name: /external link edge/i });
|
||||
expect(externalEdges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not render external link edge preview when showExternalLinks is false', () => {
|
||||
render(<GraphLegend {...defaultProps} showExternalLinks={false} />);
|
||||
|
||||
expect(screen.queryByRole('img', { name: /external link edge/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders highlighted edge preview for connected edges', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
// Highlighted edge preview has (highlighted) in aria-label
|
||||
const highlightedEdges = screen.getAllByRole('img', { name: /link edge \(highlighted\)/i });
|
||||
expect(highlightedEdges.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Integration', () => {
|
||||
it('applies theme background color to panel', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity });
|
||||
});
|
||||
|
||||
it('applies theme border color', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveStyle({ borderRight: `1px solid ${mockTheme.colors.border}` });
|
||||
});
|
||||
|
||||
it('applies theme text color to heading', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const heading = screen.getByText('Help');
|
||||
expect(heading).toHaveStyle({ color: mockTheme.colors.textMain });
|
||||
});
|
||||
|
||||
it('applies theme dim text color to section headers', () => {
|
||||
render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const nodeTypesHeader = screen.getByText('Node Types');
|
||||
expect(nodeTypesHeader).toHaveStyle({ color: mockTheme.colors.textDim });
|
||||
});
|
||||
|
||||
it('works with light theme', () => {
|
||||
render(<GraphLegend {...defaultProps} theme={lightTheme} />);
|
||||
|
||||
const heading = screen.getByText('Help');
|
||||
expect(heading).toHaveStyle({ color: lightTheme.colors.textMain });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic Content', () => {
|
||||
it('updates when showExternalLinks prop changes', () => {
|
||||
const { rerender } = render(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks />);
|
||||
const { rerender } = render(<GraphLegend {...defaultProps} showExternalLinks />);
|
||||
|
||||
// Initially showing external links
|
||||
expect(screen.getAllByText('External Link').length).toBe(2);
|
||||
|
||||
// Rerender with external links disabled
|
||||
rerender(<GraphLegend {...defaultProps} defaultExpanded showExternalLinks={false} />);
|
||||
rerender(<GraphLegend {...defaultProps} showExternalLinks={false} />);
|
||||
|
||||
// External Link should no longer appear
|
||||
expect(screen.queryByText('External Link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies theme changes dynamically', () => {
|
||||
const { container, rerender } = render(<GraphLegend {...defaultProps} />);
|
||||
describe('Panel Layout', () => {
|
||||
it('has correct width', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const legend = container.querySelector('.graph-legend');
|
||||
expect(legend).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity });
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveStyle({ width: '280px' });
|
||||
});
|
||||
|
||||
// Rerender with light theme
|
||||
rerender(<GraphLegend {...defaultProps} theme={lightTheme} />);
|
||||
it('has correct z-index', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
expect(legend).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity });
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveStyle({ zIndex: 20 });
|
||||
});
|
||||
|
||||
it('is positioned at top-left', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveClass('top-0', 'left-0');
|
||||
});
|
||||
|
||||
it('has animation class for slide-in effect', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const panel = container.querySelector('.graph-legend');
|
||||
expect(panel).toHaveClass('animate-in', 'slide-in-from-left');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Organization', () => {
|
||||
it('displays sections in correct order', () => {
|
||||
const { container } = render(<GraphLegend {...defaultProps} />);
|
||||
|
||||
const sections = container.querySelectorAll('h4');
|
||||
const sectionTexts = Array.from(sections).map(s => s.textContent);
|
||||
|
||||
expect(sectionTexts).toEqual([
|
||||
'Node Types',
|
||||
'Connection Types',
|
||||
'Selection',
|
||||
'Status Indicators',
|
||||
'Keyboard Shortcuts',
|
||||
'Mouse Actions',
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1204,11 +1204,12 @@ describe('FileExplorerPanel', () => {
|
||||
expect(screen.getByText('src')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles very long cwd path', () => {
|
||||
it('handles very long projectRoot path', () => {
|
||||
const longPath = '/Users/test/very/long/path/to/project/that/is/really/deep';
|
||||
const session = createMockSession({ cwd: longPath });
|
||||
const session = createMockSession({ projectRoot: longPath });
|
||||
render(<FileExplorerPanel {...defaultProps} session={session} />);
|
||||
|
||||
// FileExplorerPanel header uses projectRoot for the title attribute
|
||||
expect(screen.getByTitle(longPath)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1359,30 +1360,30 @@ describe('FileExplorerPanel', () => {
|
||||
expect(screen.getByText('Reveal in Finder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Focus in Graph option only for markdown files', () => {
|
||||
it('shows Document Graph option only for markdown files', () => {
|
||||
const onFocusFileInGraph = vi.fn();
|
||||
const { container } = render(
|
||||
<FileExplorerPanel {...defaultProps} onFocusFileInGraph={onFocusFileInGraph} />
|
||||
);
|
||||
|
||||
// Right-click on markdown file - should show Focus in Graph
|
||||
// Right-click on markdown file - should show Document Graph
|
||||
const mdFile = Array.from(container.querySelectorAll('[data-file-index]'))
|
||||
.find(el => el.textContent?.includes('README.md'));
|
||||
fireEvent.contextMenu(mdFile!, { clientX: 100, clientY: 200 });
|
||||
expect(screen.getByText('Focus in Graph')).toBeInTheDocument();
|
||||
expect(screen.getByText('Document Graph')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Focus in Graph option for non-markdown files', () => {
|
||||
it('does not show Document Graph option for non-markdown files', () => {
|
||||
const onFocusFileInGraph = vi.fn();
|
||||
const { container } = render(
|
||||
<FileExplorerPanel {...defaultProps} onFocusFileInGraph={onFocusFileInGraph} />
|
||||
);
|
||||
|
||||
// Right-click on non-markdown file - should not show Focus in Graph
|
||||
// Right-click on non-markdown file - should not show Document Graph
|
||||
const jsonFile = Array.from(container.querySelectorAll('[data-file-index]'))
|
||||
.find(el => el.textContent?.includes('package.json'));
|
||||
fireEvent.contextMenu(jsonFile!, { clientX: 100, clientY: 200 });
|
||||
expect(screen.queryByText('Focus in Graph')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Document Graph')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFocusFileInGraph with relative path when clicked', () => {
|
||||
@@ -1395,7 +1396,7 @@ describe('FileExplorerPanel', () => {
|
||||
.find(el => el.textContent?.includes('README.md'));
|
||||
fireEvent.contextMenu(mdFile!, { clientX: 100, clientY: 200 });
|
||||
|
||||
const focusButton = screen.getByText('Focus in Graph');
|
||||
const focusButton = screen.getByText('Document Graph');
|
||||
fireEvent.click(focusButton);
|
||||
|
||||
expect(onFocusFileInGraph).toHaveBeenCalledWith('README.md');
|
||||
|
||||
@@ -331,7 +331,8 @@ describe('useAutoRunHandlers', () => {
|
||||
|
||||
expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith(
|
||||
'/test/autorun',
|
||||
'Phase 2.md'
|
||||
'Phase 2.md',
|
||||
undefined // sshRemoteId - not set in test session
|
||||
);
|
||||
|
||||
const updateFn = mockDeps.setSessions.mock.calls[0][0];
|
||||
@@ -443,7 +444,7 @@ describe('useAutoRunHandlers', () => {
|
||||
});
|
||||
|
||||
expect(mockDeps.setAutoRunIsLoadingDocuments).toHaveBeenCalledWith(true);
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun');
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun', undefined);
|
||||
expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'Phase 2', 'Phase 3']);
|
||||
expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalled();
|
||||
expect(mockDeps.setAutoRunIsLoadingDocuments).toHaveBeenCalledWith(false);
|
||||
@@ -625,7 +626,8 @@ describe('useAutoRunHandlers', () => {
|
||||
expect(window.maestro.autorun.writeDoc).toHaveBeenCalledWith(
|
||||
'/test/autorun',
|
||||
'New Document.md',
|
||||
''
|
||||
'',
|
||||
undefined // sshRemoteId - not set in test session
|
||||
);
|
||||
});
|
||||
|
||||
@@ -648,7 +650,7 @@ describe('useAutoRunHandlers', () => {
|
||||
await result.current.handleAutoRunCreateDocument('New Doc');
|
||||
});
|
||||
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun');
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/test/autorun', undefined);
|
||||
expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'New Doc']);
|
||||
expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalledWith([
|
||||
{ name: 'Phase 1', type: 'file', path: 'Phase 1.md' }
|
||||
@@ -806,7 +808,8 @@ describe('useAutoRunHandlers', () => {
|
||||
expect(count).toBe(3);
|
||||
expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith(
|
||||
'/test/autorun',
|
||||
'Tasks.md'
|
||||
'Tasks.md',
|
||||
undefined // sshRemoteId - not set in test session
|
||||
);
|
||||
});
|
||||
|
||||
@@ -991,7 +994,7 @@ describe('useAutoRunHandlers', () => {
|
||||
await result.current.handleAutoRunFolderSelected('/new/folder');
|
||||
});
|
||||
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/new/folder');
|
||||
expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/new/folder', undefined);
|
||||
expect(mockDeps.setAutoRunDocumentList).toHaveBeenCalledWith(['Phase 1', 'Phase 2']);
|
||||
expect(mockDeps.setAutoRunDocumentTree).toHaveBeenCalled();
|
||||
expect(mockDeps.setAutoRunSetupModalOpen).toHaveBeenCalledWith(false);
|
||||
@@ -1022,7 +1025,7 @@ describe('useAutoRunHandlers', () => {
|
||||
await result.current.handleAutoRunFolderSelected('/folder');
|
||||
});
|
||||
|
||||
expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith('/folder', 'First Doc.md');
|
||||
expect(window.maestro.autorun.readDoc).toHaveBeenCalledWith('/folder', 'First Doc.md', undefined);
|
||||
|
||||
const updateFn = mockDeps.setSessions.mock.calls[0][0];
|
||||
const updatedSessions = updateFn([mockSession]);
|
||||
|
||||
@@ -189,8 +189,9 @@ describe('useFileTreeManagement', () => {
|
||||
await result.current.refreshGitFileState(session.id);
|
||||
});
|
||||
|
||||
// loadFileTree is now called with (path, maxDepth, currentDepth, sshContext)
|
||||
expect(loadFileTree).toHaveBeenCalledWith('/test/shell', 10, 0, undefined);
|
||||
// loadFileTree always uses projectRoot (treeRoot), not shellCwd
|
||||
// But git operations use shellCwd when inputMode is 'terminal'
|
||||
expect(loadFileTree).toHaveBeenCalledWith('/test/project', 10, 0, undefined);
|
||||
expect(gitService.isRepo).toHaveBeenCalledWith('/test/shell');
|
||||
expect(gitService.getBranches).toHaveBeenCalledWith('/test/shell');
|
||||
expect(gitService.getTags).toHaveBeenCalledWith('/test/shell');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Folder, RefreshCw, ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { Folder, RefreshCw, ChevronRight, AlertTriangle, Copy, Check, X } from 'lucide-react';
|
||||
import type { AgentConfig, Session, ToolType } from '../types';
|
||||
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -856,12 +856,25 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
const [customEnvVars, setCustomEnvVars] = useState<Record<string, string>>({});
|
||||
const [_customModel, setCustomModel] = useState('');
|
||||
const [refreshingAgent, setRefreshingAgent] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(false);
|
||||
// SSH Remote configuration
|
||||
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
|
||||
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(undefined);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Copy session ID to clipboard
|
||||
const handleCopySessionId = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(session.id);
|
||||
setCopiedId(true);
|
||||
setTimeout(() => setCopiedId(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy session ID:', err);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Load agent info, config, custom settings, and models when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && session) {
|
||||
@@ -1041,6 +1054,44 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
onClose={onClose}
|
||||
width={500}
|
||||
initialFocusRef={nameInputRef}
|
||||
customHeader={
|
||||
<div
|
||||
className="p-4 border-b flex items-center justify-between shrink-0"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<h2
|
||||
className="text-sm font-bold"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Edit Agent: {session.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySessionId}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-mono font-bold uppercase transition-colors hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: copiedId ? theme.colors.success + '20' : theme.colors.accent + '20',
|
||||
color: copiedId ? theme.colors.success : theme.colors.accent,
|
||||
border: `1px solid ${copiedId ? theme.colors.success : theme.colors.accent}40`
|
||||
}}
|
||||
title={copiedId ? 'Copied!' : `Click to copy: ${session.id}`}
|
||||
>
|
||||
{copiedId ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{session.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<ModalFooter
|
||||
theme={theme}
|
||||
|
||||
@@ -61,8 +61,11 @@ export function WorktreeConfigModal({
|
||||
// gh CLI status
|
||||
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | null>(null);
|
||||
|
||||
// SSH remote awareness
|
||||
const isRemoteSession = !!session.sshRemoteId;
|
||||
// SSH remote awareness - check both runtime sshRemoteId and configured sessionSshRemoteConfig
|
||||
// Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions,
|
||||
// we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
||||
const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
|
||||
const isRemoteSession = !!sshRemoteId;
|
||||
|
||||
// Register with layer stack for Escape handling
|
||||
useEffect(() => {
|
||||
@@ -118,7 +121,7 @@ export function WorktreeConfigModal({
|
||||
setIsValidating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const exists = await validateDirectory(basePath.trim(), session.sshRemoteId);
|
||||
const exists = await validateDirectory(basePath.trim(), sshRemoteId);
|
||||
if (!exists) {
|
||||
setError(isRemoteSession
|
||||
? 'Directory not found on remote server. Please enter a valid path.'
|
||||
|
||||
@@ -402,7 +402,10 @@ export function useBatchProcessor({
|
||||
|
||||
// Set up worktree if enabled using extracted hook
|
||||
// Inject sshRemoteId from session into worktree config for remote worktree operations
|
||||
const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId: session.sshRemoteId } : undefined;
|
||||
// Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions,
|
||||
// we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
||||
const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
|
||||
const worktreeWithSsh = worktree ? { ...worktree, sshRemoteId } : undefined;
|
||||
const worktreeResult = await worktreeManager.setupWorktree(session.cwd, worktreeWithSsh);
|
||||
if (!worktreeResult.success) {
|
||||
window.maestro.logger.log('error', 'Worktree setup failed', 'BatchProcessor', { sessionId, error: worktreeResult.error });
|
||||
|
||||
@@ -9,14 +9,18 @@ import { gitService } from '../../services/git';
|
||||
/**
|
||||
* Extract SSH context from session for remote file operations.
|
||||
* Returns undefined if no SSH remote is configured.
|
||||
*
|
||||
* Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions,
|
||||
* we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
||||
*/
|
||||
function getSshContext(session: Session): SshContext | undefined {
|
||||
if (!session.sshRemoteId) {
|
||||
const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
|
||||
if (!sshRemoteId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sshRemoteId: session.sshRemoteId,
|
||||
remoteCwd: session.remoteCwd,
|
||||
sshRemoteId,
|
||||
remoteCwd: session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,9 @@ export function useGitStatusPolling(
|
||||
const isActiveSession = session.id === currentActiveSessionId;
|
||||
|
||||
// Get SSH remote ID from session for remote git operations
|
||||
const sshRemoteId = session.sshRemoteId;
|
||||
// Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions,
|
||||
// we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions".
|
||||
const sshRemoteId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
|
||||
|
||||
// For non-active sessions, just get basic status (file count)
|
||||
if (!isActiveSession) {
|
||||
|
||||