feat(terminals): support dragging terminals into task tabs#2749
feat(terminals): support dragging terminals into task tabs#2749arnestrickmann wants to merge 1 commit into
Conversation
Greptile SummaryThis 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.
Confidence Score: 5/5The 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.
|
| 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
%%{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
Reviews (2): Last reviewed commit: "feat(terminals): support dragging termin..." | Re-trigger Greptile
| 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 } | ||
| ); |
There was a problem hiding this 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.
| 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.| 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; | ||
| } |
There was a problem hiding this 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.
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.9da59e3 to
d969e1d
Compare
|
Addressed the terminal-tab review points in the latest push:
|
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
Validation