Skip to content

feat: add split panes#2022

Merged
Davidknp merged 13 commits into
mainfrom
david/eng-1181-add-split-panes
May 15, 2026
Merged

feat: add split panes#2022
Davidknp merged 13 commits into
mainfrom
david/eng-1181-add-split-panes

Conversation

@Davidknp
Copy link
Copy Markdown
Collaborator

No description provided.

@Davidknp Davidknp changed the title David/eng 1181 add split panes feat: add split panes May 14, 2026
@Davidknp Davidknp marked this pull request as ready for review May 15, 2026 12:43
@Davidknp Davidknp merged commit 2cc686a into main May 15, 2026
1 check passed
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 15, 2026

Greptile Summary

This PR adds split-pane support to the task view, introducing a TabGroupManagerStore that owns multiple per-pane TabManagerStore instances, a TabGroupContext for scoping editors and tab bars per pane, drag-and-drop tab reordering across panes, and a keyboard shortcut (Mod+\) to split the active pane. It also refactors the Monaco editor from a pool-lease model to a directly-created per-pane instance, redesigns the home view with animated logo and arrow-key navigation, and introduces new icon-md/icon-xs button size variants.

  • Core split-pane infra: TabGroupManagerStoreSplitPaneLayoutTabGroupProviderPaneContent; snapshot format extended to TabGroupsSnapshot with a backward-compatible migration for the old single-pane tabManager field.
  • Editor refactor: each pane now owns its own Monaco instance created at mount and disposed on unmount, removing the shared pool-lease pattern.
  • Home view refresh: logo shimmer extracted to a reusable SVG component; project actions gain descriptions and arrow-key / Enter keyboard navigation.

Confidence Score: 3/5

Safe to review but has three issues that should be fixed before merging: a broken CSS concatenation in the PR filter button, a resource leak in snapshot restore, and an overbroad keyboard handler that can swallow input inside modals.

The FilterButton in pr-view.tsx silently breaks active state styling due to a missing space in a string concatenation. The restoreSnapshot path in TabGroupManagerStore skips disposing the initial group's tab manager and leaves all prior auto-close reactions running. The global window keydown listener in use-arrow-key-navigation fires preventDefault on arrow keys even when an input inside a modal has focus. The rest of the split-pane architecture, migration path, and editor refactor are well-structured.

pr-view.tsx (broken active class), tab-group-manager-store.ts (restoreSnapshot cleanup), use-arrow-key-navigation.ts (missing input focus guard), and workspace-view-model.tsx (DiffTabLifecycleStore wired to initial group only).

Important Files Changed

Filename Overview
src/renderer/features/projects/components/pr-view/pr-view.tsx Trailing space removed from FilterButton className concatenation, causing malformed CSS class name when active=true.
src/renderer/features/tasks/tabs/tab-group-manager-store.ts New store owning all per-pane TabManagerStore instances; restoreSnapshot leaks the initial group's tabManager and all auto-close reactions.
src/renderer/features/tasks/stores/workspace-view-model.tsx Replaces TabManagerStore with TabGroupManagerStore; DiffTabLifecycleStore is wired to a single snapshot of focusedGroup rather than reacting to pane changes.
src/renderer/lib/hooks/use-arrow-key-navigation.ts New hook for arrow-key list navigation; global window listener fires without checking if focus is inside an input, which can suppress cursor movement in modal inputs.
src/renderer/features/tasks/view/task-main-column.tsx New file: extracts TaskMainColumn and introduces SplitPaneLayout with DnD support; DraggableResizeHandle moved here from main-panel.tsx.
src/renderer/features/tasks/view/pane-content.tsx New file: per-pane renderer area with tab bar, drop zone overlay, and Activity-based panel switching; correctly scoped to TabGroupContext.
src/renderer/features/tasks/tabs/tab-group-context.tsx New React context providing per-pane groupId and TabManagerStore; clean implementation with guard-throw hook.
src/renderer/features/tasks/editor/editor-provider.tsx Switches from pool-lease pattern to direct Monaco editor creation per pane; cleanup on unmount looks correct.
src/shared/view-state.ts Adds TabGroupsSnapshot; backward-compatible migration via deprecated tabManager field is well-structured.
src/renderer/lib/ui/button.tsx Adds icon-md size (32px); shrinks icon-sm from 32px to 28px — many call sites updated accordingly.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    WVM[WorkspaceViewModel] --> TGM[TabGroupManagerStore]
    TGM --> G1[TabGroupEntry #1]
    TGM --> G2[TabGroupEntry #2]
    TGM -->|splitRight| G2
    WVM --> SPL[SplitPaneLayout]
    SPL --> TGP1[TabGroupProvider #1]
    SPL --> TGP2[TabGroupProvider #2]
    TGP1 --> PC1[PaneContent]
    TGP2 --> PC2[PaneContent]
    TGM -->|focusedGroup| FMLS[FileModelLifecycleStore]
    TGM -->|focusedGroup snapshot| DTLS[DiffTabLifecycleStore]
Loading

Comments Outside Diff (2)

  1. src/renderer/features/projects/components/pr-view/pr-view.tsx, line 208-211 (link)

    P1 Missing space causes malformed CSS class names

    The trailing space was intentionally present to separate the static classes from the conditionally-appended ones. After this change, the concatenated string becomes ...disabled:cursor-not-allowedtext-foreground font-medium (no space between cursor-not-allowed and text-foreground) when active is true, producing an invalid CSS class that Tailwind will not recognise. The filter button active styling will silently break.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/renderer/features/projects/components/pr-view/pr-view.tsx
    Line: 208-211
    
    Comment:
    **Missing space causes malformed CSS class names**
    
    The trailing space was intentionally present to separate the static classes from the conditionally-appended ones. After this change, the concatenated string becomes `...disabled:cursor-not-allowedtext-foreground font-medium` (no space between `cursor-not-allowed` and `text-foreground`) when `active` is true, producing an invalid CSS class that Tailwind will not recognise. The filter button active styling will silently break.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/renderer/features/tasks/tabs/tab-group-manager-store.ts, line 1737-1759 (link)

    P1 restoreSnapshot leaks the initial group and all auto-close reactions

    When restoreSnapshot is called it disposes groups [1..n] but silently skips groups[0] — its TabManagerStore is never disposed. More importantly, none of the entries in _autoCloseDisposers for the existing groups are cancelled before the groups array is wiped, leaving dangling MobX reactions that still hold a reference to the closing TabManagerStore. The dispose() method shows the correct pattern: call disposers first, then dispose tab managers. restoreSnapshot should mirror that cleanup before rebuilding the group list.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/renderer/features/tasks/tabs/tab-group-manager-store.ts
    Line: 1737-1759
    
    Comment:
    **`restoreSnapshot` leaks the initial group and all auto-close reactions**
    
    When `restoreSnapshot` is called it disposes groups `[1..n]` but silently skips `groups[0]` — its `TabManagerStore` is never disposed. More importantly, none of the entries in `_autoCloseDisposers` for the existing groups are cancelled before the groups array is wiped, leaving dangling MobX reactions that still hold a reference to the closing TabManagerStore. The `dispose()` method shows the correct pattern: call disposers first, then dispose tab managers. `restoreSnapshot` should mirror that cleanup before rebuilding the group list.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
src/renderer/features/projects/components/pr-view/pr-view.tsx:208-211
**Missing space causes malformed CSS class names**

The trailing space was intentionally present to separate the static classes from the conditionally-appended ones. After this change, the concatenated string becomes `...disabled:cursor-not-allowedtext-foreground font-medium` (no space between `cursor-not-allowed` and `text-foreground`) when `active` is true, producing an invalid CSS class that Tailwind will not recognise. The filter button active styling will silently break.

### Issue 2 of 5
src/renderer/features/tasks/tabs/tab-group-manager-store.ts:1737-1759
**`restoreSnapshot` leaks the initial group and all auto-close reactions**

When `restoreSnapshot` is called it disposes groups `[1..n]` but silently skips `groups[0]` — its `TabManagerStore` is never disposed. More importantly, none of the entries in `_autoCloseDisposers` for the existing groups are cancelled before the groups array is wiped, leaving dangling MobX reactions that still hold a reference to the closing TabManagerStore. The `dispose()` method shows the correct pattern: call disposers first, then dispose tab managers. `restoreSnapshot` should mirror that cleanup before rebuilding the group list.

### Issue 3 of 5
src/renderer/lib/hooks/use-arrow-key-navigation.ts:1-33
**Global `window` keydown listener fires without focus guard**

The hook attaches directly to `window` without checking `document.activeElement`. When a modal (e.g., `addProjectModal` or `createConversationModal`) is open and the user presses ArrowUp/ArrowDown inside an `<input>` within it, `e.preventDefault()` fires from this handler, suppressing the cursor movement inside the input field. A guard such as `if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;` would prevent this interference.

### Issue 4 of 5
src/renderer/features/tasks/stores/workspace-view-model.tsx:280-285
**`DiffTabLifecycleStore` is wired to the initial focused group only**

`this.tabGroupManager.focusedGroup` is evaluated once at `initialize()` time and the resulting `TabManagerStore` reference is passed into `DiffTabLifecycleStore`. When the user creates a second pane via `splitRight()`, the new pane's `TabManagerStore` is never observed by `DiffTabLifecycleStore`, so auto-opened diff tabs will only land in the original pane.

### Issue 5 of 5
src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx:60
**Tooltip copy: "New Conversations" should be singular**

The tooltip reads "New Conversations" (plural) but the action opens a single new conversation.

```suggestion
          New Conversation <ShortcutHint settingsKey="newConversation" />
```

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +1 to +33
import { useEffect, useRef, useState } from 'react';

export function useArrowKeyNavigation(count: number, onEnter: (index: number) => void) {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedIndexRef = useRef(0);
const onEnterRef = useRef(onEnter);

useEffect(() => {
selectedIndexRef.current = selectedIndex;
}, [selectedIndex]);

useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);

useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % count);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => (i - 1 + count) % count);
} else if (e.key === 'Enter') {
onEnterRef.current(selectedIndexRef.current);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [count]);

return { selectedIndex, setSelectedIndex };
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Global window keydown listener fires without focus guard

The hook attaches directly to window without checking document.activeElement. When a modal (e.g., addProjectModal or createConversationModal) is open and the user presses ArrowUp/ArrowDown inside an <input> within it, e.preventDefault() fires from this handler, suppressing the cursor movement inside the input field. A guard such as if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; would prevent this interference.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/lib/hooks/use-arrow-key-navigation.ts
Line: 1-33

Comment:
**Global `window` keydown listener fires without focus guard**

The hook attaches directly to `window` without checking `document.activeElement`. When a modal (e.g., `addProjectModal` or `createConversationModal`) is open and the user presses ArrowUp/ArrowDown inside an `<input>` within it, `e.preventDefault()` fires from this handler, suppressing the cursor movement inside the input field. A guard such as `if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;` would prevent this interference.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 280 to 285
}

this._diffTabLifecycle = new DiffTabLifecycleStore(
this.tabManager,
this.tabGroupManager.focusedGroup,
workspace.git,
this.prStore,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 DiffTabLifecycleStore is wired to the initial focused group only

this.tabGroupManager.focusedGroup is evaluated once at initialize() time and the resulting TabManagerStore reference is passed into DiffTabLifecycleStore. When the user creates a second pane via splitRight(), the new pane's TabManagerStore is never observed by DiffTabLifecycleStore, so auto-opened diff tabs will only land in the original pane.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/features/tasks/stores/workspace-view-model.tsx
Line: 280-285

Comment:
**`DiffTabLifecycleStore` is wired to the initial focused group only**

`this.tabGroupManager.focusedGroup` is evaluated once at `initialize()` time and the resulting `TabManagerStore` reference is passed into `DiffTabLifecycleStore`. When the user creates a second pane via `splitRight()`, the new pane's `TabManagerStore` is never observed by `DiffTabLifecycleStore`, so auto-opened diff tabs will only land in the original pane.

How can I resolve this? If you propose a fix, please make it concise.

</Button>
</TooltipTrigger>
<TooltipContent>
New Conversations <ShortcutHint settingsKey="newConversation" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tooltip copy: "New Conversations" should be singular

The tooltip reads "New Conversations" (plural) but the action opens a single new conversation.

Suggested change
New Conversations <ShortcutHint settingsKey="newConversation" />
New Conversation <ShortcutHint settingsKey="newConversation" />
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/renderer/features/tasks/view/tab-bar/tab-bar-actions.tsx
Line: 60

Comment:
**Tooltip copy: "New Conversations" should be singular**

The tooltip reads "New Conversations" (plural) but the action opens a single new conversation.

```suggestion
          New Conversation <ShortcutHint settingsKey="newConversation" />
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant