Skip to content

feat(terminals): support dragging terminals into task tabs#2749

Open
arnestrickmann wants to merge 1 commit into
generalaction:mainfrom
arnestrickmann:emdash/terminal-drawer-tabs
Open

feat(terminals): support dragging terminals into task tabs#2749
arnestrickmann wants to merge 1 commit into
generalaction:mainfrom
arnestrickmann:emdash/terminal-drawer-tabs

Conversation

@arnestrickmann

Copy link
Copy Markdown
Contributor

Summary

Adds terminal tabs as a first-class task tab kind by letting users drag an existing terminal from the bottom terminal pane into the main task view. Dropping a terminal opens it as a large main-pane terminal tab while reusing the existing terminal session, so terminal creation and deletion remain owned by the terminal drawer.

The implementation preserves the existing drawer/full terminal workflow: the New Terminal command still creates terminals in the drawer, closing a main-pane terminal tab only closes that view, and deleting a terminal from the drawer stale-closes any matching main-pane terminal tab.

Reviewer notes

  • Verify that terminal tab state follows the existing provider-based task tab model: descriptors persist/restore through pane snapshots, terminal tabs are single-mounted by terminal ID, and split-pane/tab movement uses the existing pane layout APIs.
  • Stress-test PTY ownership by dragging Terminal 1 into the main pane, creating Terminal 2 in the drawer, then opening both as main tabs; each should keep separate output/history and the drawer should not mount a PTY that is already open in a main tab.
  • Confirm existing terminal workflows still behave the same: New Terminal opens in the drawer, drawer deletion owns terminal cleanup, and closing a main terminal tab does not delete the terminal session.

Validation

  • corepack pnpm --dir apps/emdash-desktop exec vitest run src/renderer/features/tabs/pane-store.test.ts src/renderer/features/tasks/commands.test.ts
  • corepack pnpm --dir apps/emdash-desktop run typecheck
  • corepack pnpm --dir apps/emdash-desktop run lint

@arnestrickmann arnestrickmann requested a review from Davidknp July 2, 2026 12:44
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces terminal tabs as a first-class task tab kind, enabling users to drag an existing terminal from the bottom drawer pane into the main split-pane view. The existing PTY session is reused so no new terminal process is spawned, and drawer deletion force-closes any corresponding main-pane tab.

  • Lifts the DndContext from SplitPaneLayout up to TaskMainColumn so terminal-drawer drag events and tab-reorder drag events share a single context, with resolveDropPaneId mapping drop-zone IDs (pane-drop-*, pane-content-*, or a tab ID) to the target pane.
  • Adds TerminalTabResource (backed by a MobX reaction) that watches for drawer-side terminal deletion and force-closes the corresponding main-pane tab; the drawer simultaneously excludes main-pane-open terminals from its allSessionIds to prevent double PTY mounting.
  • Guards pty.unmount() in use-pty.ts with a container.contains(pty.ownedContainer) check so a cleanup callback from the drawer cannot steal the xterm DOM back from the main pane after reparenting.

Confidence Score: 5/5

The change is safe to merge; the PTY ownership invariant is correctly maintained by the container-guard in use-pty.ts, and the MobX-reactive filtering in terminal-panel.tsx prevents double mounting.

The drag-to-main-pane flow, stale-close reaction, and xterm reparenting guard all interact correctly. The two inline suggestions address edge-case messaging and an early-exit guard in onBeforeOpen, neither of which causes incorrect data or session loss under normal usage.

terminal-tab-provider.tsx — the onBeforeOpen terminal-existence check and the fireImmediately reaction timing (covered by existing review threads) are the most nuanced parts of the implementation.

Important Files Changed

Filename Overview
apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx New file implementing TerminalTabResource with a MobX stale-close reaction (fireImmediately:true) and an initialize() path that casts a stub object as TerminalTabResource when the manager is absent — both noted in existing review threads.
apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-panel.tsx Computes terminalIdsOpenInMain reactively inside the observer, zeros out activeSession for in-main terminals, and filters them from allSessionIds to prevent double PTY mounting; also updates the empty-state messaging.
apps/emdash-desktop/src/renderer/features/tasks/view/task-main-column.tsx DndContext promoted to TaskMainColumn level; handleDragStart/End correctly routes terminal-drawer drags vs tab drags; resolveDropPaneId covers all three droppable ID formats (pane-drop-, pane-content-, tab-id).
apps/emdash-desktop/src/renderer/lib/pty/use-pty.ts Adds container.contains(pty.ownedContainer) guard before pty.unmount() in cleanup, correctly preventing the drawer from reclaiming the xterm DOM after it has been reparented to the main pane.
apps/emdash-desktop/src/renderer/features/tabs/pane-content.tsx Empty-tab state now wraps emptyState in a div with the pane-content droppable ref and an isOverContent highlight overlay, enabling drops into empty panes.
apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts Adds FakeTerminalManagerStore and four terminal-tab lifecycle tests (open, restore, close-view-only, stale-close); terminalRegistryEntries cleanup in afterEach prevents state leakage.
apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drag.ts New file defining the drag data type constant, interface, and type-guard for terminal-drawer drags; straightforward and correct.
apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx Adds 'terminal' to RendererKind, activateLastTabOfKind, and getRemoteConnectionId to the task context; straightforward extension of existing patterns.
apps/emdash-desktop/src/shared/view-state.ts Adds terminal TabDescriptor variant with tabId, terminalId, and isPreview — matches existing browser descriptor pattern.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    actor User
    participant Drawer as TerminalsPanel (Drawer)
    participant DnD as DndContext (TaskMainColumn)
    participant Pane as PaneStore (Main Pane)
    participant Resource as TerminalTabResource
    participant PTY as usePty (xterm DOM)

    User->>Drawer: Pointerdown on terminal row (drag start)
    Drawer->>DnD: "DragStartEvent {type: terminal-drawer, terminalId}"
    DnD->>DnD: "setActiveDrag {kind:terminal, ...}"
    DnD->>User: Show TerminalDragPreview overlay

    User->>DnD: "Drop on pane-content-{paneId}"
    DnD->>DnD: resolveDropPaneId → paneId
    DnD->>Pane: setActiveGroup(paneId)
    DnD->>Pane: "open('terminal', {terminalId}, {target:{paneId}})"
    Pane->>Pane: "onBeforeOpen → {terminalId}"
    Pane->>Resource: initialize() → new TerminalTabResource
    Resource->>Resource: "reaction(fireImmediately:true): isLoaded && hasTerminal → keep open"

    Pane-->>Drawer: MobX: terminalIdsOpenInMain updated
    Drawer->>Drawer: "activeSession = null (terminal in main)"
    Drawer->>PTY: PtyPane unmounts (activeSession null)
    PTY->>PTY: container.contains(ownedContainer)? → false → skip unmount

    Pane->>PTY: PtyPane mounts in main pane container

    Note over Drawer: Shows Terminal open in main pane

    User->>Drawer: Deletes terminal from drawer
    Drawer->>Pane: terminals.delete(terminalId) [MobX]
    Resource->>Resource: "reaction fires: isLoaded=true, hasTerminal=false"
    Resource->>Pane: "handle.close({force:true})"
    Pane->>Pane: Tab removed from main pane
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    actor User
    participant Drawer as TerminalsPanel (Drawer)
    participant DnD as DndContext (TaskMainColumn)
    participant Pane as PaneStore (Main Pane)
    participant Resource as TerminalTabResource
    participant PTY as usePty (xterm DOM)

    User->>Drawer: Pointerdown on terminal row (drag start)
    Drawer->>DnD: "DragStartEvent {type: terminal-drawer, terminalId}"
    DnD->>DnD: "setActiveDrag {kind:terminal, ...}"
    DnD->>User: Show TerminalDragPreview overlay

    User->>DnD: "Drop on pane-content-{paneId}"
    DnD->>DnD: resolveDropPaneId → paneId
    DnD->>Pane: setActiveGroup(paneId)
    DnD->>Pane: "open('terminal', {terminalId}, {target:{paneId}})"
    Pane->>Pane: "onBeforeOpen → {terminalId}"
    Pane->>Resource: initialize() → new TerminalTabResource
    Resource->>Resource: "reaction(fireImmediately:true): isLoaded && hasTerminal → keep open"

    Pane-->>Drawer: MobX: terminalIdsOpenInMain updated
    Drawer->>Drawer: "activeSession = null (terminal in main)"
    Drawer->>PTY: PtyPane unmounts (activeSession null)
    PTY->>PTY: container.contains(ownedContainer)? → false → skip unmount

    Pane->>PTY: PtyPane mounts in main pane container

    Note over Drawer: Shows Terminal open in main pane

    User->>Drawer: Deletes terminal from drawer
    Drawer->>Pane: terminals.delete(terminalId) [MobX]
    Resource->>Resource: "reaction fires: isLoaded=true, hasTerminal=false"
    Resource->>Pane: "handle.close({force:true})"
    Pane->>Pane: Tab removed from main pane
Loading

Reviews (2): Last reviewed commit: "feat(terminals): support dragging termin..." | Re-trigger Greptile

Comment on lines +46 to +55
this.disposeStaleReaction = reaction(
() => ({
isLoaded: this.terminalManager.isLoaded,
hasTerminal: this.terminalManager.terminals.has(this.terminalId),
}),
({ isLoaded, hasTerminal }) => {
if (isLoaded && !hasTerminal) void this.handle.close({ force: true });
},
{ fireImmediately: true }
);

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 The fireImmediately: true reaction may call handle.close({ force: true }) synchronously within the TerminalTabResource constructor, which itself is called inside initialize. The same initialize function defers the close via setTimeout for the missing-manager case to avoid calling handle.close during initialization. Without the same deferral here, if the terminal manager is already loaded but the terminal ID is absent at construction time (e.g., a snapshot restored after the terminal was deleted), the close fires within the constructor call stack before initialize has returned the resource to the tab store.

Suggested change
this.disposeStaleReaction = reaction(
() => ({
isLoaded: this.terminalManager.isLoaded,
hasTerminal: this.terminalManager.terminals.has(this.terminalId),
}),
({ isLoaded, hasTerminal }) => {
if (isLoaded && !hasTerminal) void this.handle.close({ force: true });
},
{ fireImmediately: true }
);
this.disposeStaleReaction = reaction(
() => ({
isLoaded: this.terminalManager.isLoaded,
hasTerminal: this.terminalManager.terminals.has(this.terminalId),
}),
({ isLoaded, hasTerminal }) => {
if (isLoaded && !hasTerminal) setTimeout(() => void this.handle.close({ force: true }), 0);
},
{ fireImmediately: true }
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx
Line: 46-55

Comment:
The `fireImmediately: true` reaction may call `handle.close({ force: true })` synchronously within the `TerminalTabResource` constructor, which itself is called inside `initialize`. The same `initialize` function defers the close via `setTimeout` for the missing-manager case to avoid calling `handle.close` during initialization. Without the same deferral here, if the terminal manager is already loaded but the terminal ID is absent at construction time (e.g., a snapshot restored after the terminal was deleted), the close fires within the constructor call stack before `initialize` has returned the resource to the tab store.

```suggestion
    this.disposeStaleReaction = reaction(
      () => ({
        isLoaded: this.terminalManager.isLoaded,
        hasTerminal: this.terminalManager.terminals.has(this.terminalId),
      }),
      ({ isLoaded, hasTerminal }) => {
        if (isLoaded && !hasTerminal) setTimeout(() => void this.handle.close({ force: true }), 0);
      },
      { fireImmediately: true }
    );
```

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

Comment on lines +158 to +170
initialize(
entry: TabEntry<TerminalTabState>,
handle: TabHandle,
ctx: TabViewContext
): TerminalTabResource {
const taskCtx = ctx as TaskTabContext;
const terminalManager = terminalRegistry.get(taskCtx.taskId);
if (!terminalManager) {
setTimeout(() => void handle.close({ force: true }), 0);
return {
dispose: () => {},
} as TerminalTabResource;
}

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 Type-unsafe fallback resource object

When terminalManager is null at initialize time, the function returns { dispose: () => {} } as TerminalTabResource. The as cast lies to TypeScript — if the tab infrastructure calls any other method on the resource before handle.close completes (e.g. onActivateIntent or the terminal / session getters), it will throw at runtime. Because the tab is being force-closed via setTimeout, this window is small but non-zero. Consider returning an actual TerminalTabResource constructed with a sentinel value, or using a shared null-object class that implements the interface with no-ops.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx
Line: 158-170

Comment:
**Type-unsafe fallback resource object**

When `terminalManager` is null at `initialize` time, the function returns `{ dispose: () => {} } as TerminalTabResource`. The `as` cast lies to TypeScript — if the tab infrastructure calls any other method on the resource before `handle.close` completes (e.g. `onActivateIntent` or the `terminal` / `session` getters), it will throw at runtime. Because the tab is being force-closed via `setTimeout`, this window is small but non-zero. Consider returning an actual `TerminalTabResource` constructed with a sentinel value, or using a shared null-object class that implements the interface with no-ops.

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

@arnestrickmann

Copy link
Copy Markdown
Contributor Author

@greptile

@arnestrickmann arnestrickmann force-pushed the emdash/terminal-drawer-tabs branch from 9da59e3 to d969e1d Compare July 2, 2026 15:09
@arnestrickmann

Copy link
Copy Markdown
Contributor Author

Addressed the terminal-tab review points in the latest push:

  • Deferred stale terminal tab close calls that can fire during provider initialization, so restored/deleted terminals close after initialization finishes.
  • Replaced the casted fallback resource with a real missing-terminal resource that safely implements the terminal tab resource surface while the tab is being closed.
  • Added generic single-mount behavior for explicit pane targets: dragging/opening an already-mounted terminal into another pane moves the existing tab there instead of duplicating it or silently focusing the old pane. Covered by pane-layout-store tests.

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