diff --git a/build/icon.icns b/build/icon.icns
index dc1680fc..ea0da748 100644
Binary files a/build/icon.icns and b/build/icon.icns differ
diff --git a/build/icon.ico b/build/icon.ico
index c82bca26..4fa57362 100644
Binary files a/build/icon.ico and b/build/icon.ico differ
diff --git a/build/icon.png b/build/icon.png
index d1d65ac6..e344d359 100644
Binary files a/build/icon.png and b/build/icon.png differ
diff --git a/build/new-icon/icon.icns b/build/new-icon/icon.icns
new file mode 100644
index 00000000..dc1680fc
Binary files /dev/null and b/build/new-icon/icon.icns differ
diff --git a/build/new-icon/icon.ico b/build/new-icon/icon.ico
new file mode 100644
index 00000000..c82bca26
Binary files /dev/null and b/build/new-icon/icon.ico differ
diff --git a/build/new-icon/icon.png b/build/new-icon/icon.png
new file mode 100644
index 00000000..d1d65ac6
Binary files /dev/null and b/build/new-icon/icon.png differ
diff --git a/docs/docs.json b/docs/docs.json
index 54f07ef2..38267c00 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -61,6 +61,7 @@
"openspec-commands",
"history",
"context-management",
+ "document-graph",
"autorun-playbooks",
"playbook-exchange",
"git-worktrees",
diff --git a/docs/document-graph.md b/docs/document-graph.md
new file mode 100644
index 00000000..9d2ae820
--- /dev/null
+++ b/docs/document-graph.md
@@ -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` |
diff --git a/docs/features.md b/docs/features.md
index 6ed15d18..7876b1de 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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.
diff --git a/docs/group-chat.md b/docs/group-chat.md
index 5117aaf6..20559ffe 100644
--- a/docs/group-chat.md
+++ b/docs/group-chat.md
@@ -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
diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md
index 31f1c2de..dfc952e3 100644
--- a/docs/keyboard-shortcuts.md
+++ b/docs/keyboard-shortcuts.md
@@ -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:
diff --git a/docs/screenshots/document-graph-last-graph.png b/docs/screenshots/document-graph-last-graph.png
new file mode 100644
index 00000000..94e69c4e
Binary files /dev/null and b/docs/screenshots/document-graph-last-graph.png differ
diff --git a/docs/screenshots/document-graph.png b/docs/screenshots/document-graph.png
new file mode 100644
index 00000000..b21a8665
Binary files /dev/null and b/docs/screenshots/document-graph.png differ
diff --git a/docs/screenshots/group-chat-over-ssh.png b/docs/screenshots/group-chat-over-ssh.png
new file mode 100644
index 00000000..f2d042c9
Binary files /dev/null and b/docs/screenshots/group-chat-over-ssh.png differ
diff --git a/docs/screenshots/ssh-agents-mapping.png b/docs/screenshots/ssh-agents-mapping.png
new file mode 100644
index 00000000..9b174ce8
Binary files /dev/null and b/docs/screenshots/ssh-agents-mapping.png differ
diff --git a/docs/screenshots/ssh-agents-servers.png b/docs/screenshots/ssh-agents-servers.png
new file mode 100644
index 00000000..6974b0d0
Binary files /dev/null and b/docs/screenshots/ssh-agents-servers.png differ
diff --git a/docs/screenshots/ssh-agents-status.png b/docs/screenshots/ssh-agents-status.png
new file mode 100644
index 00000000..182497de
Binary files /dev/null and b/docs/screenshots/ssh-agents-status.png differ
diff --git a/docs/ssh-remote-execution.md b/docs/ssh-remote-execution.md
index 250b5a6e..6f61538a 100644
--- a/docs/ssh-remote-execution.md
+++ b/docs/ssh-remote-execution.md
@@ -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
diff --git a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx
index 2ee3491f..02b109f9 100644
--- a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx
+++ b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx
@@ -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( );
- // 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( );
-
- // 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( );
+
+ const panel = screen.getByRole('region', { name: /help panel/i });
+ expect(panel).toBeInTheDocument();
+ });
+
it('renders all node types when showExternalLinks is true', () => {
- render( );
+ render( );
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( );
+ render( );
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( );
+ render( );
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( );
+ render( );
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( );
+
+ const closeButton = screen.getByTitle('Close (Esc)');
+ expect(closeButton).toBeInTheDocument();
});
- it('renders selection section with selected node preview', () => {
- render( );
+ it('calls onClose when close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
- 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( );
+ describe('Keyboard Shortcuts Section', () => {
+ it('shows keyboard shortcuts section', () => {
+ render( );
expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument();
+ });
+
+ it('displays navigation shortcut', () => {
+ render( );
+
expect(screen.getByText('↑ ↓ ← →')).toBeInTheDocument();
expect(screen.getByText('Navigate between nodes')).toBeInTheDocument();
+ });
+
+ it('displays enter shortcut', () => {
+ render( );
+
expect(screen.getByText('Enter')).toBeInTheDocument();
expect(screen.getByText('Recenter on focused node')).toBeInTheDocument();
+ });
+
+ it('displays open shortcut', () => {
+ render( );
+
expect(screen.getByText('O')).toBeInTheDocument();
expect(screen.getByText('Open file in preview')).toBeInTheDocument();
});
- it('renders interaction hints', () => {
- render( );
+ it('displays search shortcut', () => {
+ render( );
+ expect(screen.getByText('⌘F')).toBeInTheDocument();
+ expect(screen.getByText('Focus search')).toBeInTheDocument();
+ });
+
+ it('displays escape shortcut', () => {
+ render( );
+
+ expect(screen.getByText('Esc')).toBeInTheDocument();
+ expect(screen.getByText('Close panel / modal')).toBeInTheDocument();
+ });
+ });
+
+ describe('Mouse Actions Section', () => {
+ it('shows mouse actions section', () => {
+ render( );
+
+ expect(screen.getByText('Mouse Actions')).toBeInTheDocument();
+ });
+
+ it('displays click action', () => {
+ render( );
+
+ expect(screen.getByText('Click')).toBeInTheDocument();
+ expect(screen.getByText('Select node')).toBeInTheDocument();
+ });
+
+ it('displays double-click action', () => {
+ render( );
+
+ expect(screen.getByText('Double-click')).toBeInTheDocument();
expect(screen.getByText('Recenter view')).toBeInTheDocument();
+ });
+
+ it('displays right-click action', () => {
+ render( );
+
+ expect(screen.getByText('Right-click')).toBeInTheDocument();
expect(screen.getByText('Context menu')).toBeInTheDocument();
+ });
+
+ it('displays drag action', () => {
+ render( );
+
+ expect(screen.getByText('Drag')).toBeInTheDocument();
+ expect(screen.getByText('Reposition node')).toBeInTheDocument();
+ });
+
+ it('displays scroll action', () => {
+ render( );
+
+ expect(screen.getByText('Scroll')).toBeInTheDocument();
expect(screen.getByText('Zoom in/out')).toBeInTheDocument();
});
-
- it('renders status indicators section', () => {
- render( );
-
- 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( );
- // 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( );
-
- // 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( );
- 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( );
-
- // 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( );
-
- const button = screen.getByRole('button', { name: /legend/i });
- expect(button).toHaveAttribute('aria-expanded', 'false');
- });
-
- it('aria-expanded updates when toggled', () => {
- render( );
-
- 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( );
-
- const button = screen.getByRole('button', { name: /legend/i });
- expect(button).toHaveAttribute('aria-controls', 'legend-content');
- });
-
- it('content container has matching id', () => {
- render( );
-
- expect(document.getElementById('legend-content')).toBeInTheDocument();
- });
-
- it('node previews have aria-label', () => {
- render( );
-
- // 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( );
-
- // 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( );
-
- expect(screen.getByRole('img', { name: /broken links warning indicator/i })).toBeInTheDocument();
- });
- });
-
- describe('Theme Styling', () => {
- it('applies theme background color to container', () => {
- const { container } = render( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity });
- });
-
- it('applies theme border color to container', () => {
- const { container } = render( );
-
- 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( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveStyle({ backgroundColor: lightTheme.colors.bgActivity });
- });
-
- it('applies accent color to header background', () => {
- render( );
-
- const button = screen.getByRole('button', { name: /legend/i });
- expect(button).toHaveStyle({ backgroundColor: `${mockTheme.colors.accent}10` });
- });
-
- it('section headers use dim text color', () => {
- render( );
-
- // 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( );
-
- const documentLabel = screen.getByText('Document');
- expect(documentLabel).toHaveStyle({ color: mockTheme.colors.textMain });
- });
-
- it('item descriptions use dim text color with opacity', () => {
- render( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- 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( );
-
- expect(container.querySelector('.graph-legend')).toBeInTheDocument();
- });
-
- it('is positioned absolutely at bottom center', () => {
- const { container } = render( );
-
- 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( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveStyle({ maxWidth: '300px' });
- });
-
- it('has z-index for stacking above graph', () => {
- const { container } = render( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveStyle({ zIndex: '10' });
- });
-
- it('has rounded corners', () => {
- const { container } = render( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveClass('rounded-lg');
- });
-
- it('has shadow for elevation', () => {
- const { container } = render( );
-
- const legend = container.querySelector('.graph-legend');
- expect(legend).toHaveClass('shadow-lg');
- });
- });
-
- describe('Content Descriptions', () => {
- it('document node has correct description', () => {
- render( );
-
- expect(screen.getByText('Card with title and description')).toBeInTheDocument();
- });
-
- it('external node has correct description', () => {
- render( );
-
- expect(screen.getByText('Pill showing domain name')).toBeInTheDocument();
- });
-
- it('internal edge has correct description', () => {
- render( );
-
- expect(screen.getByText('Connection between markdown files')).toBeInTheDocument();
- });
-
- it('external edge has correct description', () => {
- render( );
-
- expect(screen.getByText('Connection to external domain')).toBeInTheDocument();
- });
-
- it('selected node has correct description', () => {
- render( );
-
+ expect(screen.getByText('Selected Node')).toBeInTheDocument();
expect(screen.getByText('Click or navigate to select')).toBeInTheDocument();
});
- it('connected edge has correct description', () => {
- render( );
+ it('displays connected edge info', () => {
+ render( );
+ 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( );
+ describe('Status Indicators Section', () => {
+ it('shows status indicators section', () => {
+ render( );
- // 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( );
+ it('displays broken links indicator', () => {
+ render( );
- 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( );
+
+ 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( );
+
+ 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( );
+
+ 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( );
+
+ expect(screen.queryByRole('img', { name: /external link node pill/i })).not.toBeInTheDocument();
+ });
+
+ it('renders selected document node preview', () => {
+ render( );
+
+ // 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( );
+
+ 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( );
+
+ 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( );
+
+ expect(screen.queryByRole('img', { name: /external link edge/i })).not.toBeInTheDocument();
+ });
+
+ it('renders highlighted edge preview for connected edges', () => {
+ render( );
+
+ // 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( );
+
+ const panel = container.querySelector('.graph-legend');
+ expect(panel).toHaveStyle({ backgroundColor: mockTheme.colors.bgActivity });
+ });
+
+ it('applies theme border color', () => {
+ const { container } = render( );
+
+ const panel = container.querySelector('.graph-legend');
+ expect(panel).toHaveStyle({ borderRight: `1px solid ${mockTheme.colors.border}` });
+ });
+
+ it('applies theme text color to heading', () => {
+ render( );
+
+ const heading = screen.getByText('Help');
+ expect(heading).toHaveStyle({ color: mockTheme.colors.textMain });
+ });
+
+ it('applies theme dim text color to section headers', () => {
+ render( );
+
+ const nodeTypesHeader = screen.getByText('Node Types');
+ expect(nodeTypesHeader).toHaveStyle({ color: mockTheme.colors.textDim });
+ });
+
+ it('works with light theme', () => {
+ render( );
+
+ const heading = screen.getByText('Help');
+ expect(heading).toHaveStyle({ color: lightTheme.colors.textMain });
});
});
describe('Dynamic Content', () => {
it('updates when showExternalLinks prop changes', () => {
- const { rerender } = render( );
+ const { rerender } = render( );
// Initially showing external links
expect(screen.getAllByText('External Link').length).toBe(2);
// Rerender with external links disabled
- rerender( );
+ rerender( );
// External Link should no longer appear
expect(screen.queryByText('External Link')).not.toBeInTheDocument();
});
+ });
- it('applies theme changes dynamically', () => {
- const { container, rerender } = render( );
+ describe('Panel Layout', () => {
+ it('has correct width', () => {
+ const { container } = render( );
- 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( );
+ it('has correct z-index', () => {
+ const { container } = render( );
- 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( );
+
+ const panel = container.querySelector('.graph-legend');
+ expect(panel).toHaveClass('top-0', 'left-0');
+ });
+
+ it('has animation class for slide-in effect', () => {
+ const { container } = render( );
+
+ 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( );
+
+ 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',
+ ]);
+ });
+
+ });
});
diff --git a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx
index db164df2..c901c1a4 100644
--- a/src/__tests__/renderer/components/FileExplorerPanel.test.tsx
+++ b/src/__tests__/renderer/components/FileExplorerPanel.test.tsx
@@ -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 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(
);
- // 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(
);
- // 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');
diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx
index acfe9da1..a9c7d218 100644
--- a/src/__tests__/renderer/components/FilePreview.test.tsx
+++ b/src/__tests__/renderer/components/FilePreview.test.tsx
@@ -1,3351 +1,223 @@
-/**
- * @file FilePreview.test.tsx
- * @description Tests for the FilePreview component
- *
- * FilePreview is a comprehensive file viewer supporting:
- * - Syntax-highlighted code files
- * - Rendered/raw markdown with image support
- * - Image file display
- * - File stats (size, tokens, dates)
- * - Search with match navigation
- * - Keyboard shortcuts
- * - Copy to clipboard (path and content)
- */
-
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { FilePreview } from '../../../renderer/components/FilePreview';
-// Mock dependencies before importing component
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ FileCode: () => FileCode ,
+ X: () => X ,
+ Eye: () => Eye ,
+ ChevronUp: () => ChevronUp ,
+ ChevronDown: () => ChevronDown ,
+ ChevronLeft: () => ChevronLeft ,
+ ChevronRight: () => ChevronRight ,
+ Clipboard: () => Clipboard ,
+ Loader2: () => Loader2 ,
+ Image: () => Image ,
+ Globe: () => Globe ,
+ Save: () => Save ,
+ Edit: () => Edit ,
+ FolderOpen: () => FolderOpen ,
+ AlertTriangle: () => AlertTriangle ,
+ Share2: () => Share2 ,
+ GitGraph: () => GitGraph ,
+}));
+
+// Mock react-markdown
vi.mock('react-markdown', () => ({
- default: ({ children }: { children: string }) => (
-
{children}
- ),
+ default: ({ children }: { children: string }) => {children}
,
}));
-vi.mock('remark-gfm', () => ({
- default: () => () => {},
-}));
+// Mock remark/rehype plugins
+vi.mock('remark-gfm', () => ({ default: () => {} }));
+vi.mock('rehype-raw', () => ({ default: () => {} }));
+vi.mock('rehype-slug', () => ({ default: () => {} }));
+vi.mock('remark-frontmatter', () => ({ default: () => {} }));
+// Mock syntax highlighter
vi.mock('react-syntax-highlighter', () => ({
- Prism: ({ children, language, showLineNumbers }: any) => (
-
- {children}
-
- ),
+ Prism: ({ children }: { children: string }) => {children} ,
}));
-
vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
}));
+// Mock unist-util-visit
vi.mock('unist-util-visit', () => ({
visit: vi.fn(),
}));
-vi.mock('../../../renderer/components/MermaidRenderer', () => ({
- MermaidRenderer: ({ chart }: { chart: string }) => (
- {chart}
- ),
-}));
-
-vi.mock('../../../renderer/components/ui/Modal', () => ({
- Modal: ({ children, title, footer, onClose }: any) => (
-
-
{children}
-
{footer}
-
- ),
- ModalFooter: ({ onCancel, onConfirm, cancelLabel, confirmLabel }: any) => (
- <>
- {cancelLabel}
- {confirmLabel}
- >
- ),
-}));
-
-vi.mock('js-tiktoken', () => ({
- getEncoding: vi.fn(() => ({
- encode: vi.fn((text: string) => new Array(Math.ceil(text.length / 4))),
- })),
-}));
-
-vi.mock('../../../renderer/utils/shortcutFormatter', () => ({
- formatShortcutKeys: vi.fn((keys: string[]) => keys.join('+')),
-}));
-
// Mock LayerStackContext
-const mockRegisterLayer = vi.fn(() => 'layer-123');
-const mockUnregisterLayer = vi.fn();
-const mockUpdateLayerHandler = vi.fn();
-
vi.mock('../../../renderer/contexts/LayerStackContext', () => ({
useLayerStack: () => ({
- registerLayer: mockRegisterLayer,
- unregisterLayer: mockUnregisterLayer,
- updateLayerHandler: mockUpdateLayerHandler,
+ registerLayer: vi.fn(() => 'layer-123'),
+ unregisterLayer: vi.fn(),
+ updateLayerHandler: vi.fn(),
}),
}));
-// Import component after mocks
-import { FilePreview } from '../../../renderer/components/FilePreview';
-
-// Helper to create mock theme
-const createMockTheme = () => ({
- colors: {
- bgMain: '#1e1e1e',
- bgSidebar: '#252526',
- bgActivity: '#333333',
- textMain: '#ffffff',
- textDim: '#888888',
- accent: '#007acc',
- accentForeground: '#ffffff',
- border: '#404040',
- error: '#f44336',
- success: '#4caf50',
- warning: '#ff9800',
+// Mock MODAL_PRIORITIES
+vi.mock('../../../renderer/constants/modalPriorities', () => ({
+ MODAL_PRIORITIES: {
+ FILE_PREVIEW: 100,
},
-});
+}));
-// Helper to create mock file
-const createMockFile = (overrides: Partial<{ name: string; content: string; path: string }> = {}) => ({
- name: 'test.ts',
- content: 'const x = 1;',
- path: '/project/src/test.ts',
- ...overrides,
-});
+// Mock MermaidRenderer
+vi.mock('../../../renderer/components/MermaidRenderer', () => ({
+ MermaidRenderer: () => Mermaid
,
+}));
-// Helper to create mock shortcuts
-const createMockShortcuts = () => ({
- copyFilePath: { keys: ['Meta', 'Shift', 'C'] },
- toggleMarkdownMode: { keys: ['Meta', 'M'] },
-});
+// Mock token counter - getEncoder must return a Promise
+vi.mock('../../../renderer/utils/tokenCounter', () => ({
+ getEncoder: vi.fn(() => Promise.resolve({ encode: () => [1, 2, 3] })),
+ formatTokenCount: vi.fn((count: number) => `${count} tokens`),
+}));
+
+// Mock shortcut formatter
+vi.mock('../../../renderer/utils/shortcutFormatter', () => ({
+ formatShortcutKeys: vi.fn((keys: string) => keys),
+}));
+
+// Mock remarkFileLinks
+vi.mock('../../../renderer/utils/remarkFileLinks', () => ({
+ remarkFileLinks: vi.fn(() => () => {}),
+}));
+
+// Mock remarkFrontmatterTable
+vi.mock('../../../renderer/utils/remarkFrontmatterTable', () => ({
+ remarkFrontmatterTable: vi.fn(() => () => {}),
+}));
+
+// Mock gitUtils
+vi.mock('../../../shared/gitUtils', () => ({
+ isImageFile: (filename: string) => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(filename),
+}));
+
+const mockTheme = {
+ colors: {
+ bgMain: '#1a1a2e',
+ bgActivity: '#16213e',
+ textMain: '#eee',
+ textDim: '#888',
+ border: '#333',
+ accent: '#4a9eff',
+ success: '#22c55e',
+ },
+};
+
+const defaultProps = {
+ file: { name: 'test.md', content: '# Hello World', path: '/test/test.md' },
+ onClose: vi.fn(),
+ theme: mockTheme,
+ markdownEditMode: false,
+ setMarkdownEditMode: vi.fn(),
+ shortcuts: {},
+};
describe('FilePreview', () => {
- let mockSetMarkdownRawMode: ReturnType;
-
beforeEach(() => {
vi.clearAllMocks();
- mockSetMarkdownRawMode = vi.fn();
-
- // Reset window.maestro mocks
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 1024,
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-15T12:30:00.000Z',
- });
- vi.mocked(window.maestro.fs.readFile).mockResolvedValue('data:image/png;base64,abc123');
-
- // Mock clipboard
- Object.assign(navigator, {
- clipboard: {
- writeText: vi.fn().mockResolvedValue(undefined),
- write: vi.fn().mockResolvedValue(undefined),
- },
- });
-
- // Mock scrollIntoView
- Element.prototype.scrollIntoView = vi.fn();
});
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- // =============================================================================
- // NULL FILE HANDLING
- // =============================================================================
-
- describe('null file handling', () => {
- it('returns null when file is null', () => {
- const { container } = render(
+ describe('Document Graph button', () => {
+ it('shows Document Graph button for markdown files when onOpenInGraph is provided', () => {
+ const onOpenInGraph = vi.fn();
+ render(
);
+
+ const graphButton = screen.getByTitle('View in Document Graph (⌘⇧G)');
+ expect(graphButton).toBeInTheDocument();
+ expect(screen.getByTestId('gitgraph-icon')).toBeInTheDocument();
+ });
+
+ it('calls onOpenInGraph when Document Graph button is clicked', () => {
+ const onOpenInGraph = vi.fn();
+ render(
+
+ );
+
+ const graphButton = screen.getByTitle('View in Document Graph (⌘⇧G)');
+ fireEvent.click(graphButton);
+
+ expect(onOpenInGraph).toHaveBeenCalledOnce();
+ });
+
+ it('does not show Document Graph button when onOpenInGraph is not provided', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTitle('View in Document Graph (⌘⇧G)')).not.toBeInTheDocument();
+ });
+
+ it('does not show Document Graph button for non-markdown files', () => {
+ const onOpenInGraph = vi.fn();
+ render(
+
+ );
+
+ expect(screen.queryByTitle('View in Document Graph (⌘⇧G)')).not.toBeInTheDocument();
+ });
+
+ it('shows Document Graph button for uppercase .MD extension', () => {
+ const onOpenInGraph = vi.fn();
+ render(
+
+ );
+
+ expect(screen.getByTitle('View in Document Graph (⌘⇧G)')).toBeInTheDocument();
+ });
+ });
+
+ describe('basic rendering', () => {
+ it('renders file preview with file name', () => {
+ render( );
+
+ expect(screen.getByText('test.md')).toBeInTheDocument();
+ });
+
+ it('renders close button', () => {
+ render( );
+
+ expect(screen.getByTestId('x-icon')).toBeInTheDocument();
+ });
+
+ it('calls onClose when close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+
+ const closeButton = screen.getByTestId('x-icon').parentElement;
+ fireEvent.click(closeButton!);
+
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('renders nothing when file is null', () => {
+ const { container } = render( );
+
expect(container.firstChild).toBeNull();
});
});
-
- // =============================================================================
- // BASIC RENDERING
- // =============================================================================
-
- describe('basic rendering', () => {
- it('renders with a TypeScript file', () => {
- render(
-
- );
-
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- expect(screen.getByText('/project/src')).toBeInTheDocument();
- });
-
- it('renders file content in syntax highlighter for code files', () => {
- render(
-
- );
-
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toBeInTheDocument();
- expect(highlighter).toHaveAttribute('data-language', 'typescript');
- });
-
- it('applies theme colors to container', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- expect(container).toHaveStyle({ backgroundColor: '#1e1e1e' });
- });
-
- it('registers layer on mount', () => {
- render(
-
- );
-
- expect(mockRegisterLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'overlay',
- blocksLowerLayers: true,
- capturesFocus: true,
- ariaLabel: 'File Preview',
- })
- );
- });
-
- it('unregisters layer on unmount', () => {
- const { unmount } = render(
-
- );
-
- unmount();
- expect(mockUnregisterLayer).toHaveBeenCalledWith('layer-123');
- });
- });
-
- // =============================================================================
- // FILE STATS
- // =============================================================================
-
- describe('file stats', () => {
- it('displays file size when stats are available', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('1 KB')).toBeInTheDocument();
- });
- });
-
- it('displays token count', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Tokens:')).toBeInTheDocument();
- });
- });
-
- it('displays modified and created dates', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Modified:')).toBeInTheDocument();
- expect(screen.getByText('Created:')).toBeInTheDocument();
- });
- });
-
- it('handles file stats error gracefully', async () => {
- vi.mocked(window.maestro.fs.stat).mockRejectedValue(new Error('File not found'));
-
- render(
-
- );
-
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // COPY FUNCTIONALITY
- // =============================================================================
-
- describe('copy functionality', () => {
- it('copies file path to clipboard', async () => {
- render(
-
- );
-
- const copyPathBtn = screen.getByTitle('Copy full path to clipboard');
- fireEvent.click(copyPathBtn);
-
- await waitFor(() => {
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/project/src/test.ts');
- });
- });
-
- it('shows copy notification after copying path', async () => {
- render(
-
- );
-
- const copyPathBtn = screen.getByTitle('Copy full path to clipboard');
- fireEvent.click(copyPathBtn);
-
- await waitFor(() => {
- expect(screen.getByText('File Path Copied to Clipboard')).toBeInTheDocument();
- });
- });
-
- it('copies content to clipboard for text files', async () => {
- render(
-
- );
-
- const copyContentBtn = screen.getByTitle('Copy content to clipboard');
- fireEvent.click(copyContentBtn);
-
- await waitFor(() => {
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('const x = 42;');
- });
- });
-
- it('shows content copy notification', async () => {
- render(
-
- );
-
- const copyContentBtn = screen.getByTitle('Copy content to clipboard');
- fireEvent.click(copyContentBtn);
-
- await waitFor(() => {
- expect(screen.getByText('Content Copied to Clipboard')).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // CLOSE BUTTON
- // =============================================================================
-
- describe('close button', () => {
- it('calls onClose when close button is clicked', async () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- const closeIcon = screen.getAllByTestId('x-icon')[0];
- const closeBtn = closeIcon.closest('button');
- expect(closeBtn).toBeTruthy();
- fireEvent.click(closeBtn!);
-
- expect(onClose).toHaveBeenCalled();
- });
- });
-
- // =============================================================================
- // MARKDOWN FILES
- // =============================================================================
-
- describe('markdown files', () => {
- const markdownFile = createMockFile({
- name: 'README.md',
- content: '# Hello World\n\nThis is a test.',
- path: '/project/README.md',
- });
-
- it('shows markdown toggle button for .md files', () => {
- render(
-
- );
-
- const toggleBtn = screen.getByTitle(/Edit file|Show preview/);
- expect(toggleBtn).toBeInTheDocument();
- });
-
- it('renders markdown content when not in raw mode', () => {
- render(
-
- );
-
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
-
- it('renders textarea editor when markdownEditMode is true', () => {
- render(
-
- );
-
- expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument();
- // In edit mode, we show a textarea instead of raw text
- const textarea = screen.getByRole('textbox');
- expect(textarea).toBeInTheDocument();
- expect(textarea).toHaveValue('# Hello World\n\nThis is a test.');
- });
-
- it('toggles markdown mode when button is clicked', async () => {
- render(
-
- );
-
- const toggleBtn = screen.getByTitle(/Edit file/);
- fireEvent.click(toggleBtn);
-
- expect(mockSetMarkdownRawMode).toHaveBeenCalledWith(true);
- });
-
- it('does not show markdown toggle for non-markdown files', () => {
- render(
-
- );
-
- expect(screen.queryByTitle(/Edit file/)).not.toBeInTheDocument();
- expect(screen.queryByTitle(/Show rendered markdown/)).not.toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // IMAGE FILES
- // =============================================================================
-
- describe('image files', () => {
- const imageFile = createMockFile({
- name: 'logo.png',
- content: 'data:image/png;base64,abc123',
- path: '/project/assets/logo.png',
- });
-
- it('renders image tag for image files', () => {
- render(
-
- );
-
- const img = screen.getByRole('img');
- expect(img).toBeInTheDocument();
- expect(img).toHaveAttribute('src', 'data:image/png;base64,abc123');
- });
-
- it('does not show syntax highlighter for images', () => {
- render(
-
- );
-
- expect(screen.queryByTestId('syntax-highlighter')).not.toBeInTheDocument();
- });
-
- it('shows copy image button for image files', () => {
- render(
-
- );
-
- expect(screen.getByTitle('Copy image to clipboard')).toBeInTheDocument();
- });
-
- it('recognizes various image extensions', () => {
- const extensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
-
- extensions.forEach(ext => {
- const { unmount } = render(
-
- );
-
- expect(screen.getByRole('img')).toBeInTheDocument();
- unmount();
- });
- });
- });
-
- // =============================================================================
- // LANGUAGE DETECTION
- // =============================================================================
-
- describe('language detection', () => {
- const testCases = [
- { ext: 'ts', lang: 'typescript' },
- { ext: 'tsx', lang: 'tsx' },
- { ext: 'js', lang: 'javascript' },
- { ext: 'jsx', lang: 'jsx' },
- { ext: 'json', lang: 'json' },
- { ext: 'py', lang: 'python' },
- { ext: 'rb', lang: 'ruby' },
- { ext: 'go', lang: 'go' },
- { ext: 'rs', lang: 'rust' },
- { ext: 'java', lang: 'java' },
- { ext: 'c', lang: 'c' },
- { ext: 'cpp', lang: 'cpp' },
- { ext: 'cs', lang: 'csharp' },
- { ext: 'php', lang: 'php' },
- { ext: 'html', lang: 'html' },
- { ext: 'css', lang: 'css' },
- { ext: 'scss', lang: 'scss' },
- { ext: 'sql', lang: 'sql' },
- { ext: 'sh', lang: 'bash' },
- { ext: 'yaml', lang: 'yaml' },
- { ext: 'yml', lang: 'yaml' },
- { ext: 'toml', lang: 'toml' },
- { ext: 'xml', lang: 'xml' },
- ];
-
- testCases.forEach(({ ext, lang }) => {
- it(`detects ${lang} for .${ext} files`, () => {
- render(
-
- );
-
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toHaveAttribute('data-language', lang);
- });
- });
-
- it('defaults to text for unknown extensions', () => {
- render(
-
- );
-
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toHaveAttribute('data-language', 'text');
- });
- });
-
- // =============================================================================
- // SEARCH FUNCTIONALITY
- // =============================================================================
-
- describe('search functionality', () => {
- it('opens search with Cmd+F', async () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
- });
-
- it('focuses search input when opened', async () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- expect(searchInput).toBeInTheDocument();
- });
- });
-
- it('closes search with Escape', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Close with Escape
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.keyDown(searchInput, { key: 'Escape' });
-
- await waitFor(() => {
- expect(screen.queryByPlaceholderText(/Search in file/)).not.toBeInTheDocument();
- });
- });
-
- it('shows no matches message when no results', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Type a search query that won't match
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'xyz123notfound' } });
-
- await waitFor(() => {
- expect(screen.getByText('No matches')).toBeInTheDocument();
- });
- });
-
- it('displays match count when matches found', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Type a search query
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByText(/1\/3|2\/3|3\/3/)).toBeInTheDocument();
- });
- });
-
- it('shows navigation buttons when search has results', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Type a search query
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByTitle(/Previous match/)).toBeInTheDocument();
- expect(screen.getByTitle(/Next match/)).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // KEYBOARD NAVIGATION
- // =============================================================================
-
- describe('keyboard navigation', () => {
- it('scrolls up with ArrowUp', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowUp' });
-
- expect(container).toBeInTheDocument();
- });
-
- it('scrolls down with ArrowDown', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowDown' });
-
- expect(container).toBeInTheDocument();
- });
-
- it('does not open search with Cmd+/', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: '/', metaKey: true });
-
- expect(screen.queryByPlaceholderText(/Search in file/)).not.toBeInTheDocument();
- });
-
- it('triggers copy path with keyboard shortcut', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'c', metaKey: true, shiftKey: true });
-
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/project/src/test.ts');
- });
-
- it('toggles markdown mode with keyboard shortcut', async () => {
- render(
-
- );
-
- const container = screen.getByText('README.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'm', metaKey: true });
-
- expect(mockSetMarkdownRawMode).toHaveBeenCalledWith(true);
- });
- });
-
- // =============================================================================
- // LAYER ESCAPE HANDLING
- // =============================================================================
-
- describe('layer escape handling', () => {
- it('passes escape handler to layer registration', () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- expect(mockRegisterLayer).toHaveBeenCalledWith(
- expect.objectContaining({
- onEscape: expect.any(Function),
- })
- );
- });
-
- it('updates layer handler when search opens', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(mockUpdateLayerHandler).toHaveBeenCalled();
- });
- });
- });
-
- // =============================================================================
- // FILE SIZE FORMATTING
- // =============================================================================
-
- describe('file size formatting', () => {
- it('formats bytes correctly', async () => {
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 512,
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-01T00:00:00.000Z',
- });
-
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('512 B')).toBeInTheDocument();
- });
- });
-
- it('formats kilobytes correctly', async () => {
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 2048,
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-01T00:00:00.000Z',
- });
-
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('2 KB')).toBeInTheDocument();
- });
- });
-
- it('formats megabytes correctly', async () => {
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 1048576 * 5.5, // 5.5 MB
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-01T00:00:00.000Z',
- });
-
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('5.5 MB')).toBeInTheDocument();
- });
- });
-
- it('formats zero bytes correctly', async () => {
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 0,
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-01T00:00:00.000Z',
- });
-
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('0 B')).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // TOKEN COUNT FORMATTING
- // =============================================================================
-
- describe('token count formatting', () => {
- it('displays token count for text files', async () => {
- // The mock at the top generates tokens based on content.length / 4
- render(
-
- );
-
- // Should show Tokens label with some count
- await waitFor(() => {
- expect(screen.getByText('Tokens:')).toBeInTheDocument();
- });
- });
-
- it('shows token count section in stats bar', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Tokens:')).toBeInTheDocument();
- });
- });
-
- it('does not show tokens section for image files', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Size:')).toBeInTheDocument();
- });
-
- // Token count should not appear for images
- expect(screen.queryByText('Tokens:')).not.toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // DIRECTORY PATH DISPLAY
- // =============================================================================
-
- describe('directory path display', () => {
- it('displays directory without filename', () => {
- render(
-
- );
-
- expect(screen.getByText('index.ts')).toBeInTheDocument();
- expect(screen.getByText('/Users/dev/project/src/components')).toBeInTheDocument();
- });
-
- it('handles root-level files', () => {
- render(
-
- );
-
- expect(screen.getByText('package.json')).toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // STATS BAR VISIBILITY
- // =============================================================================
-
- describe('stats bar visibility', () => {
- it('shows stats bar initially', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('Size:')).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // EDGE CASES
- // =============================================================================
-
- describe('edge cases', () => {
- it('handles files with no extension', () => {
- render(
-
- );
-
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toHaveAttribute('data-language', 'text');
- });
-
- it('handles empty file content', () => {
- render(
-
- );
-
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
-
- it('handles very long filenames', () => {
- const longName = 'a'.repeat(200) + '.ts';
-
- render(
-
- );
-
- expect(screen.getByText(longName)).toBeInTheDocument();
- });
-
- it('handles files with special characters in name', () => {
- render(
-
- );
-
- expect(screen.getByText('test file (1).ts')).toBeInTheDocument();
- });
-
- it('handles unicode content', () => {
- render(
-
- );
-
- expect(screen.getByText(/Hello 世界/)).toBeInTheDocument();
- });
-
- it('handles missing shortcuts gracefully', () => {
- render(
-
- );
-
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // IMAGE COPY
- // =============================================================================
-
- describe('image copy', () => {
- it('shows notification when copying image', async () => {
- // The clipboard API may fall back in test environment
- render(
-
- );
-
- const copyBtn = screen.getByTitle('Copy image to clipboard');
- fireEvent.click(copyBtn);
-
- // Should show some copy notification (either Image or URL copied)
- await waitFor(() => {
- expect(screen.getByText(/Copied to Clipboard/)).toBeInTheDocument();
- });
- });
-
- it('calls fetch when copying image', async () => {
- global.fetch = vi.fn().mockResolvedValue({
- blob: () => Promise.resolve(new Blob(['image data'], { type: 'image/png' })),
- });
-
- render(
-
- );
-
- const copyBtn = screen.getByTitle('Copy image to clipboard');
- fireEvent.click(copyBtn);
-
- // The component fetches the data URL to create a blob for clipboard
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('data:image/png;base64,abc123');
- });
- });
-
- it('falls back to copying data URL if image copy fails', async () => {
- global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
-
- render(
-
- );
-
- const copyBtn = screen.getByTitle('Copy image to clipboard');
- fireEvent.click(copyBtn);
-
- await waitFor(() => {
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('data:image/png;base64,abc123');
- });
- });
- });
-
- // =============================================================================
- // SEARCH NAVIGATION BUTTONS
- // =============================================================================
-
- describe('search navigation buttons', () => {
- it('previous button navigates to previous match', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByTitle(/Previous match/)).toBeInTheDocument();
- });
-
- const prevBtn = screen.getByTitle(/Previous match/);
- fireEvent.click(prevBtn);
-
- await waitFor(() => {
- expect(screen.getByText('3/3')).toBeInTheDocument();
- });
- });
-
- it('next button navigates to next match', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByTitle(/Next match/)).toBeInTheDocument();
- });
-
- const nextBtn = screen.getByTitle(/Next match/);
- fireEvent.click(nextBtn);
-
- await waitFor(() => {
- expect(screen.getByText('2/3')).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // SEARCH KEYBOARD SHORTCUTS
- // =============================================================================
-
- describe('search keyboard shortcuts', () => {
- it('Enter navigates to next match', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByText('1/3')).toBeInTheDocument();
- });
-
- // Press Enter to go to next
- fireEvent.keyDown(searchInput, { key: 'Enter' });
-
- await waitFor(() => {
- expect(screen.getByText('2/3')).toBeInTheDocument();
- });
- });
-
- it('Shift+Enter navigates to previous match', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- await waitFor(() => {
- expect(screen.getByText('1/3')).toBeInTheDocument();
- });
-
- // Press Shift+Enter to go to previous (wraps to last)
- fireEvent.keyDown(searchInput, { key: 'Enter', shiftKey: true });
-
- await waitFor(() => {
- expect(screen.getByText('3/3')).toBeInTheDocument();
- });
- });
- });
-
- // =============================================================================
- // MODIFIER KEY SCROLLING
- // =============================================================================
-
- describe('modifier key scrolling', () => {
- it('Cmd+ArrowUp jumps to top', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowUp', metaKey: true });
-
- expect(container).toBeInTheDocument();
- });
-
- it('Cmd+ArrowDown jumps to bottom', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowDown', metaKey: true });
-
- expect(container).toBeInTheDocument();
- });
-
- it('Alt+ArrowUp pages up', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowUp', altKey: true });
-
- expect(container).toBeInTheDocument();
- });
-
- it('Alt+ArrowDown pages down', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowDown', altKey: true });
-
- expect(container).toBeInTheDocument();
- });
-
- it('Ctrl+ArrowUp also jumps to top', () => {
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowUp', ctrlKey: true });
-
- expect(container).toBeInTheDocument();
- });
- });
-
- // =============================================================================
- // LINE NUMBERS
- // =============================================================================
-
- describe('line numbers', () => {
- it('shows line numbers in syntax highlighter', () => {
- render(
-
- );
-
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toHaveAttribute('data-line-numbers', 'true');
- });
- });
-});
-
-// =============================================================================
-// UTILITY FUNCTION UNIT TESTS
-// =============================================================================
-
-describe('FilePreview utility functions', () => {
- describe('getLanguageFromFilename coverage', () => {
- const testFiles = [
- { file: 'test.ts', expected: 'typescript' },
- { file: 'test.tsx', expected: 'tsx' },
- { file: 'test.js', expected: 'javascript' },
- { file: 'test.jsx', expected: 'jsx' },
- { file: 'test.json', expected: 'json' },
- { file: 'test.py', expected: 'python' },
- { file: 'test.rb', expected: 'ruby' },
- { file: 'test.go', expected: 'go' },
- { file: 'test.rs', expected: 'rust' },
- { file: 'test.java', expected: 'java' },
- { file: 'test.c', expected: 'c' },
- { file: 'test.cpp', expected: 'cpp' },
- { file: 'test.cs', expected: 'csharp' },
- { file: 'test.php', expected: 'php' },
- { file: 'test.html', expected: 'html' },
- { file: 'test.css', expected: 'css' },
- { file: 'test.scss', expected: 'scss' },
- { file: 'test.sql', expected: 'sql' },
- { file: 'test.sh', expected: 'bash' },
- { file: 'test.yaml', expected: 'yaml' },
- { file: 'test.yml', expected: 'yaml' },
- { file: 'test.toml', expected: 'toml' },
- { file: 'test.xml', expected: 'xml' },
- { file: 'test.unknown', expected: 'text' },
- { file: 'noextension', expected: 'text' },
- ];
-
- testFiles.forEach(({ file, expected }) => {
- it(`correctly identifies ${file} as ${expected}`, () => {
- render(
-
- );
-
- if (file.endsWith('.md')) {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- } else if (!['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'].some(ext => file.endsWith(`.${ext}`))) {
- const highlighter = screen.getByTestId('syntax-highlighter');
- expect(highlighter).toHaveAttribute('data-language', expected);
- }
- });
- });
- });
-
- describe('isImageFile coverage', () => {
- const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
- const nonImageExtensions = ['ts', 'js', 'txt', 'html'];
-
- imageExtensions.forEach(ext => {
- it(`recognizes .${ext} as image file`, () => {
- render(
-
- );
-
- expect(screen.getByRole('img')).toBeInTheDocument();
- });
- });
-
- nonImageExtensions.forEach(ext => {
- it(`does not recognize .${ext} as image file`, () => {
- render(
-
- );
-
- expect(screen.queryByRole('img')).not.toBeInTheDocument();
- });
- });
- });
-});
-
-// =============================================================================
-// MARKDOWN IMAGE COMPONENT TESTS
-// =============================================================================
-
-describe('MarkdownImage component', () => {
- // Note: ReactMarkdown is mocked with a simple component that doesn't render custom img tags
- // These tests verify that markdown files with image syntax render without errors
- describe('markdown files with images', () => {
- it('renders markdown file containing image syntax', async () => {
- render(
-
- );
-
- // The mocked ReactMarkdown renders the content as plain text
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-
- it('renders markdown file with data URL image', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-
- it('renders markdown file with HTTP URL image', async () => {
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
- });
-});
-
-// =============================================================================
-// MARKDOWN LINK INTERACTIONS
-// =============================================================================
-
-describe('Markdown link interactions', () => {
- // Note: ReactMarkdown is mocked, so we can't test actual link interactions
- // The tests verify that markdown files render without errors
- it('renders markdown files with link syntax', async () => {
- render(
-
- );
-
- // ReactMarkdown is mocked, verify content renders
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// STATS BAR SCROLL BEHAVIOR
-// =============================================================================
-
-describe('Stats bar scroll behavior', () => {
- it('hides stats bar when scrolled down', async () => {
- render(
-
- );
-
- // Wait for stats to load
- await waitFor(() => {
- expect(screen.getByText('Size:')).toBeInTheDocument();
- });
-
- // Find the content scroll container and simulate scrolling
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
- const scrollContainer = container?.querySelector('.overflow-y-auto');
- expect(scrollContainer).toBeTruthy();
-
- // Simulate scroll down
- if (scrollContainer) {
- Object.defineProperty(scrollContainer, 'scrollTop', { value: 100, writable: true });
- fireEvent.scroll(scrollContainer);
- }
-
- // The stats bar should be hidden (note: component uses showStatsBar state)
- // We can't easily test DOM hiding without proper scroll simulation
- // but we verify the component doesn't crash and still renders
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
-});
-
-// =============================================================================
-// HIGHLIGHT SYNTAX (==text==) IN MARKDOWN
-// =============================================================================
-
-describe('Markdown highlight syntax', () => {
- it('renders highlighted text with ==syntax==', async () => {
- render(
-
- );
-
- // The component uses ReactMarkdown which is mocked
- // The actual highlight processing is in remarkHighlight plugin
- // We verify the content renders without errors
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// MARKDOWN CODE BLOCKS WITH LANGUAGE
-// =============================================================================
-
-describe('Markdown code blocks', () => {
- it('renders inline code differently from block code', async () => {
- render(
-
- );
-
- // ReactMarkdown is mocked, so we just verify it renders
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-
- it('renders mermaid code blocks with MermaidRenderer', async () => {
- render(
- B\n```',
- path: '/project/test.md',
- })}
- onClose={vi.fn()}
- theme={createMockTheme()}
- markdownEditMode={false}
- setMarkdownEditMode={vi.fn()}
- shortcuts={createMockShortcuts()}
- />
- );
-
- // MermaidRenderer is mocked
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// FORMAT TOKEN COUNT UTILITY
-// =============================================================================
-
-describe('Token count formatting', () => {
- // Note: js-tiktoken is mocked at the top with a fixed implementation
- // These tests verify the component handles token counting without crashing
- it('displays tokens section for text files', async () => {
- render(
-
- );
-
- // Should show Tokens: label with the token count
- await waitFor(() => {
- expect(screen.getByText('Tokens:')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// SEARCH IN MARKDOWN RAW MODE
-// =============================================================================
-
-describe('Search in markdown with highlighting', () => {
- it('highlights matches in raw markdown mode', async () => {
- render(
-
- );
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Type search
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- // Verify match count
- await waitFor(() => {
- expect(screen.getByText('1/3')).toBeInTheDocument();
- });
- });
-
- it('keeps rendered markdown when searching in preview mode', async () => {
- render(
-
- );
-
- // Verify we start in preview mode (ReactMarkdown is rendered)
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
-
- // Open search
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'f', metaKey: true });
-
- await waitFor(() => {
- expect(screen.getByPlaceholderText(/Search in file/)).toBeInTheDocument();
- });
-
- // Type search
- const searchInput = screen.getByPlaceholderText(/Search in file/);
- fireEvent.change(searchInput, { target: { value: 'test' } });
-
- // Verify we stay in preview mode (ReactMarkdown is still rendered)
- // The search highlights are applied via DOM manipulation, not by switching to raw mode
- await waitFor(() => {
- expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// GIGABYTE FILE SIZE FORMATTING
-// =============================================================================
-
-describe('Gigabyte file size formatting', () => {
- it('formats gigabytes correctly', async () => {
- vi.mocked(window.maestro.fs.stat).mockResolvedValue({
- size: 1073741824 * 2.3, // 2.3 GB
- createdAt: '2024-01-01T00:00:00.000Z',
- modifiedAt: '2024-01-01T00:00:00.000Z',
- });
-
- render(
-
- );
-
- await waitFor(() => {
- expect(screen.getByText('2.3 GB')).toBeInTheDocument();
- });
- });
-});
-
-// =============================================================================
-// UNSAVED CHANGES CONFIRMATION MODAL
-// =============================================================================
-
-describe('unsaved changes confirmation modal', () => {
- const markdownFile = {
- name: 'test.md',
- content: '# Original Content',
- path: '/project/test.md',
- };
-
- it('shows confirmation modal when pressing Escape with unsaved changes', async () => {
- render(
-
- );
-
- // Modify the content in the textarea
- const textarea = screen.getByRole('textbox');
- fireEvent.change(textarea, { target: { value: '# Modified Content' } });
-
- // The layer's onEscape handler should show the modal when there are changes
- // We need to simulate the escape handler being called
- // The mockRegisterLayer captures the onEscape callback
- const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1];
- const layerConfig = registerCall[0];
-
- // Call the onEscape handler
- layerConfig.onEscape();
-
- await waitFor(() => {
- expect(screen.getByTestId('modal')).toBeInTheDocument();
- expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument();
- });
- });
-
- it('closes without modal when no changes have been made', async () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- // Don't modify the content - just get the escape handler
- const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1];
- const layerConfig = registerCall[0];
-
- // Call the onEscape handler - should close directly since no changes
- layerConfig.onEscape();
-
- // Should not show modal
- expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
- expect(onClose).toHaveBeenCalled();
- });
-
- it('stays open when clicking "No, Stay" in confirmation modal', async () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- // Modify the content
- const textarea = screen.getByRole('textbox');
- fireEvent.change(textarea, { target: { value: '# Modified Content' } });
-
- // Get and call the escape handler
- const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1];
- const layerConfig = registerCall[0];
- layerConfig.onEscape();
-
- await waitFor(() => {
- expect(screen.getByTestId('modal')).toBeInTheDocument();
- });
-
- // Click "No, Stay"
- const cancelButton = screen.getByTestId('modal-cancel');
- fireEvent.click(cancelButton);
-
- await waitFor(() => {
- expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
- });
-
- // onClose should NOT have been called
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it('closes when clicking "Yes, Discard" in confirmation modal', async () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- // Modify the content
- const textarea = screen.getByRole('textbox');
- fireEvent.change(textarea, { target: { value: '# Modified Content' } });
-
- // Get and call the escape handler
- const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1];
- const layerConfig = registerCall[0];
- layerConfig.onEscape();
-
- await waitFor(() => {
- expect(screen.getByTestId('modal')).toBeInTheDocument();
- });
-
- // Click "Yes, Discard"
- const confirmButton = screen.getByTestId('modal-confirm');
- fireEvent.click(confirmButton);
-
- await waitFor(() => {
- expect(onClose).toHaveBeenCalled();
- });
- });
-
- it('does not show modal in preview mode (not edit mode)', async () => {
- const onClose = vi.fn();
-
- render(
-
- );
-
- // Get and call the escape handler
- const registerCall = mockRegisterLayer.mock.calls[mockRegisterLayer.mock.calls.length - 1];
- const layerConfig = registerCall[0];
- layerConfig.onEscape();
-
- // Should close directly without modal
- expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
- expect(onClose).toHaveBeenCalled();
- });
-});
-
-// =============================================================================
-// NAVIGATION DISABLED IN EDIT MODE
-// =============================================================================
-
-describe('navigation disabled in edit mode', () => {
- const markdownFile = {
- name: 'test.md',
- content: '# Test',
- path: '/project/test.md',
- };
-
- it('hides navigation buttons in edit mode', () => {
- render(
-
- );
-
- // Navigation buttons should be hidden in edit mode
- expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument();
- expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument();
- });
-
- it('shows navigation buttons in preview mode', () => {
- render(
-
- );
-
- // Navigation buttons should be visible in preview mode
- expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument();
- expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument();
- });
-
- it('does not navigate with Cmd+Left in edit mode', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
-
- expect(onNavigateBack).not.toHaveBeenCalled();
- });
-
- it('does not navigate with Cmd+Right in edit mode', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
-
- expect(onNavigateForward).not.toHaveBeenCalled();
- });
-
- it('navigates with Cmd+Left in preview mode', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
-
- expect(onNavigateBack).toHaveBeenCalled();
- });
-
- it('navigates with Cmd+Right in preview mode', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('test.md').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
-
- expect(onNavigateForward).toHaveBeenCalled();
- });
-});
-
-// =============================================================================
-// FILE PREVIEW NAVIGATION - COMPREHENSIVE TESTS FOR REGRESSION CHECKLIST
-// =============================================================================
-
-describe('file preview navigation - back/forward buttons', () => {
- const testFile = {
- name: 'current.ts',
- content: 'const x = 1;',
- path: '/project/current.ts',
- };
-
- it('renders back button when canGoBack is true', () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- expect(backBtn).toBeInTheDocument();
- });
-
- it('renders forward button when canGoForward is true', () => {
- render(
-
- );
-
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- expect(forwardBtn).toBeInTheDocument();
- });
-
- it('does not render back button when canGoBack is false', () => {
- render(
-
- );
-
- expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument();
- });
-
- it('does not render forward button when canGoForward is false', () => {
- render(
-
- );
-
- expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument();
- });
-
- it('calls onNavigateBack when back button is clicked', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- fireEvent.click(backBtn);
-
- expect(onNavigateBack).toHaveBeenCalled();
- });
-
- it('calls onNavigateForward when forward button is clicked', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- fireEvent.click(forwardBtn);
-
- expect(onNavigateForward).toHaveBeenCalled();
- });
-
- it('does not call onNavigateBack with Cmd+Left when canGoBack is false', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('current.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
-
- expect(onNavigateBack).not.toHaveBeenCalled();
- });
-
- it('does not call onNavigateForward with Cmd+Right when canGoForward is false', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('current.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
-
- expect(onNavigateForward).not.toHaveBeenCalled();
- });
-
- it('does not call onNavigateBack with Ctrl+Left when canGoBack is false', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('current.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', ctrlKey: true });
-
- expect(onNavigateBack).not.toHaveBeenCalled();
- });
-
- it('navigates back with Ctrl+Left keyboard shortcut', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('current.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', ctrlKey: true });
-
- expect(onNavigateBack).toHaveBeenCalled();
- });
-
- it('navigates forward with Ctrl+Right keyboard shortcut', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('current.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowRight', ctrlKey: true });
-
- expect(onNavigateForward).toHaveBeenCalled();
- });
-});
-
-describe('file preview navigation - history popup', () => {
- const testFile = {
- name: 'current.ts',
- content: 'const x = 1;',
- path: '/project/current.ts',
- };
-
- const backHistory = [
- { name: 'first.ts', content: 'const a = 1;', path: '/project/first.ts' },
- { name: 'second.ts', content: 'const b = 2;', path: '/project/second.ts' },
- ];
-
- const forwardHistory = [
- { name: 'future1.ts', content: 'const c = 3;', path: '/project/future1.ts' },
- { name: 'future2.ts', content: 'const d = 4;', path: '/project/future2.ts' },
- ];
-
- it('shows back history popup on hover', async () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- fireEvent.mouseEnter(backBtn);
-
- await waitFor(() => {
- // Should show the back history items
- expect(screen.getByText('second.ts')).toBeInTheDocument();
- expect(screen.getByText('first.ts')).toBeInTheDocument();
- });
- });
-
- it('shows forward history popup on hover', async () => {
- render(
-
- );
-
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- fireEvent.mouseEnter(forwardBtn);
-
- await waitFor(() => {
- // Should show the forward history items
- expect(screen.getByText('future1.ts')).toBeInTheDocument();
- expect(screen.getByText('future2.ts')).toBeInTheDocument();
- });
- });
-
- it('hides back history popup on mouse leave', async () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
-
- // Show popup
- fireEvent.mouseEnter(backBtn);
- await waitFor(() => {
- expect(screen.getByText('second.ts')).toBeInTheDocument();
- });
-
- // Hide popup
- fireEvent.mouseLeave(backBtn);
- await waitFor(() => {
- expect(screen.queryByText('second.ts')).not.toBeInTheDocument();
- });
- });
-
- it('calls onNavigateToIndex when clicking a back history item', async () => {
- const onNavigateToIndex = vi.fn();
-
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- fireEvent.mouseEnter(backBtn);
-
- await waitFor(() => {
- expect(screen.getByText('first.ts')).toBeInTheDocument();
- });
-
- // Click the first item (index 0 in original history)
- const firstItem = screen.getByText('first.ts');
- fireEvent.click(firstItem);
-
- expect(onNavigateToIndex).toHaveBeenCalledWith(0);
- });
-
- it('calls onNavigateToIndex when clicking a forward history item', async () => {
- const onNavigateToIndex = vi.fn();
-
- render(
-
- );
-
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- fireEvent.mouseEnter(forwardBtn);
-
- await waitFor(() => {
- expect(screen.getByText('future1.ts')).toBeInTheDocument();
- });
-
- // Click the first forward item (index 1 in original history, since current is at 0)
- const future1Item = screen.getByText('future1.ts');
- fireEvent.click(future1Item);
-
- expect(onNavigateToIndex).toHaveBeenCalledWith(1);
- });
-
- it('shows numbered entries in back history popup', async () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- fireEvent.mouseEnter(backBtn);
-
- await waitFor(() => {
- // History items should be shown with numbering
- // The back history is shown in reverse order (newest first)
- // With 2 items, actualIndex for first displayed = length - 1 - 0 = 1, so shows "2."
- // actualIndex for second displayed = length - 1 - 1 = 0, so shows "1."
- expect(screen.getByText('2.')).toBeInTheDocument();
- expect(screen.getByText('1.')).toBeInTheDocument();
- });
- });
-
- it('shows numbered entries in forward history popup', async () => {
- render(
-
- );
-
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- fireEvent.mouseEnter(forwardBtn);
-
- await waitFor(() => {
- // Forward history numbering: actualIndex = currentHistoryIndex + 1 + idx
- // For idx=0: actualIndex = 0 + 1 + 0 = 1, shows "2."
- // For idx=1: actualIndex = 0 + 1 + 1 = 2, shows "3."
- expect(screen.getByText('2.')).toBeInTheDocument();
- expect(screen.getByText('3.')).toBeInTheDocument();
- });
- });
-});
-
-describe('file preview navigation - both buttons together', () => {
- const testFile = {
- name: 'middle.ts',
- content: 'const x = 1;',
- path: '/project/middle.ts',
- };
-
- it('renders both back and forward buttons when both are available', () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- expect(backBtn).toBeInTheDocument();
- expect(forwardBtn).toBeInTheDocument();
- expect(backBtn).not.toBeDisabled();
- expect(forwardBtn).not.toBeDisabled();
- });
-
- it('disables forward button when only back is available', () => {
- // Both buttons render but forward is disabled
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- expect(backBtn).not.toBeDisabled();
- expect(forwardBtn).toBeDisabled();
- });
-
- it('disables back button when only forward is available', () => {
- // Both buttons render but back is disabled
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- const forwardBtn = screen.getByTitle('Go forward (⌘→)');
- expect(backBtn).toBeDisabled();
- expect(forwardBtn).not.toBeDisabled();
- });
-
- it('renders neither button when neither is available', () => {
- render(
-
- );
-
- // When both are false, the navigation container doesn't render at all
- expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument();
- expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument();
- });
-});
-
-describe('file preview navigation - non-markdown files', () => {
- const tsFile = {
- name: 'code.ts',
- content: 'const x = 1;',
- path: '/project/code.ts',
- };
-
- it('shows navigation buttons for TypeScript files', () => {
- render(
-
- );
-
- expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument();
- expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument();
- });
-
- it('navigates back with keyboard in TypeScript file', () => {
- const onNavigateBack = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('code.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
-
- expect(onNavigateBack).toHaveBeenCalled();
- });
-
- it('navigates forward with keyboard in TypeScript file', () => {
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('code.ts').closest('[tabindex="0"]');
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
-
- expect(onNavigateForward).toHaveBeenCalled();
- });
-});
-
-describe('file preview navigation - image files', () => {
- const imageFile = {
- name: 'logo.png',
- content: 'data:image/png;base64,abc123',
- path: '/project/assets/logo.png',
- };
-
- it('shows navigation buttons for image files', () => {
- render(
-
- );
-
- expect(screen.getByTitle('Go back (⌘←)')).toBeInTheDocument();
- expect(screen.getByTitle('Go forward (⌘→)')).toBeInTheDocument();
- });
-
- it('navigates with keyboard from image preview', () => {
- const onNavigateBack = vi.fn();
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('logo.png').closest('[tabindex="0"]');
-
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
- expect(onNavigateBack).toHaveBeenCalled();
-
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
- expect(onNavigateForward).toHaveBeenCalled();
- });
-});
-
-describe('file preview navigation - edge cases', () => {
- const testFile = {
- name: 'test.ts',
- content: 'const x = 1;',
- path: '/project/test.ts',
- };
-
- it('does not crash when navigation callbacks are undefined', () => {
- render(
-
- );
-
- // Should render without crashing
- expect(screen.getByText('test.ts')).toBeInTheDocument();
-
- // Buttons might not appear without callbacks, or might be non-functional
- // The important thing is no crash
- });
-
- it('handles empty history arrays', async () => {
- render(
-
- );
-
- const backBtn = screen.getByTitle('Go back (⌘←)');
- fireEvent.mouseEnter(backBtn);
-
- // Should not crash, popup might be empty or not show
- await waitFor(() => {
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
- });
-
- it('handles missing currentHistoryIndex gracefully', async () => {
- const backHistory = [
- { name: 'first.ts', content: 'const a = 1;', path: '/project/first.ts' },
- ];
-
- render(
-
- );
-
- // Should render without crashing
- expect(screen.getByText('test.ts')).toBeInTheDocument();
- });
-
- it('handles navigation correctly when props are provided', () => {
- const onNavigateBack = vi.fn();
- const onNavigateForward = vi.fn();
-
- render(
-
- );
-
- const container = screen.getByText('test.ts').closest('[tabindex="0"]');
-
- // Back navigation
- fireEvent.keyDown(container!, { key: 'ArrowLeft', metaKey: true });
- expect(onNavigateBack).toHaveBeenCalledTimes(1);
-
- // Forward navigation
- fireEvent.keyDown(container!, { key: 'ArrowRight', metaKey: true });
- expect(onNavigateForward).toHaveBeenCalledTimes(1);
- });
-
- it('handles single file with no history gracefully', () => {
- render(
-
- );
-
- // Should render without crashing
- expect(screen.getByText('test.ts')).toBeInTheDocument();
-
- // No navigation buttons should appear
- expect(screen.queryByTitle('Go back (⌘←)')).not.toBeInTheDocument();
- expect(screen.queryByTitle('Go forward (⌘→)')).not.toBeInTheDocument();
- });
});
diff --git a/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts b/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts
index f0ba9006..c6d6bc8a 100644
--- a/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts
+++ b/src/__tests__/renderer/hooks/useAutoRunHandlers.test.ts
@@ -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]);
diff --git a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts
index 7fefe018..47766c28 100644
--- a/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts
+++ b/src/__tests__/renderer/hooks/useFileTreeManagement.test.ts
@@ -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');
diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx
index 8438850a..050f1c8e 100644
--- a/src/renderer/components/NewInstanceModal.tsx
+++ b/src/renderer/components/NewInstanceModal.tsx
@@ -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>({});
const [_customModel, setCustomModel] = useState('');
const [refreshingAgent, setRefreshingAgent] = useState(false);
+ const [copiedId, setCopiedId] = useState(false);
// SSH Remote configuration
const [sshRemotes, setSshRemotes] = useState([]);
const [sshRemoteConfig, setSshRemoteConfig] = useState(undefined);
const nameInputRef = useRef(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={
+
+
+ Edit Agent: {session.name}
+
+
+
+ {copiedId ? : }
+ {session.id.slice(0, 8)}
+
+
+
+
+
+
+ }
footer={
(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.'
diff --git a/src/renderer/hooks/batch/useBatchProcessor.ts b/src/renderer/hooks/batch/useBatchProcessor.ts
index fd9eb139..b5129709 100644
--- a/src/renderer/hooks/batch/useBatchProcessor.ts
+++ b/src/renderer/hooks/batch/useBatchProcessor.ts
@@ -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 });
diff --git a/src/renderer/hooks/git/useFileTreeManagement.ts b/src/renderer/hooks/git/useFileTreeManagement.ts
index c62a2e5c..9dd6ea36 100644
--- a/src/renderer/hooks/git/useFileTreeManagement.ts
+++ b/src/renderer/hooks/git/useFileTreeManagement.ts
@@ -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,
};
}
diff --git a/src/renderer/hooks/git/useGitStatusPolling.ts b/src/renderer/hooks/git/useGitStatusPolling.ts
index 28014dca..c68c882a 100644
--- a/src/renderer/hooks/git/useGitStatusPolling.ts
+++ b/src/renderer/hooks/git/useGitStatusPolling.ts
@@ -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) {