## 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 🎨
This commit is contained in:
Pedram Amini
2025-12-31 15:21:12 -06:00
parent 5a8f529676
commit dc6f8f5912
28 changed files with 806 additions and 3755 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
build/new-icon/icon.icns Normal file

Binary file not shown.

BIN
build/new-icon/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
build/new-icon/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -61,6 +61,7 @@
"openspec-commands",
"history",
"context-management",
"document-graph",
"autorun-playbooks",
"playbook-exchange",
"git-worktrees",

143
docs/document-graph.md Normal file
View 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.
![Document Graph](./screenshots/document-graph.png)
## 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.
![Last Graph Button](./screenshots/document-graph-last-graph.png)
### 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` |

View File

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

View File

@@ -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:
![Group Chat with Remote Agents](./screenshots/group-chat-over-ssh.png)
**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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -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:
![SSH Remote Hosts Settings](./screenshots/ssh-agents-servers.png)
| 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:
![SSH Agent Mapping](./screenshots/ssh-agents-mapping.png)
| 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:
![SSH Agent Status](./screenshots/ssh-agents-status.png)
- **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:
![Group Chat with SSH Agents](./screenshots/group-chat-over-ssh.png)
- 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

View File

@@ -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',
]);
});
});
});

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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