diff --git a/apps/emdash-desktop/src/renderer/features/tabs/core/task-tab-context.ts b/apps/emdash-desktop/src/renderer/features/tabs/core/task-tab-context.ts index 718b7b81b3..d27edc2207 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/core/task-tab-context.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/core/task-tab-context.ts @@ -19,4 +19,6 @@ export interface TaskTabContext extends TabViewContext { workspacePath?: string; /** Workspace-scoped prefix for Monaco model URIs: `workspace:`. */ modelRootPath: string; + /** Current remote connection for terminal/file-drop helpers, when this task is remote. */ + getRemoteConnectionId?: () => string | undefined; } diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-content.tsx b/apps/emdash-desktop/src/renderer/features/tabs/pane-content.tsx index 8c8290a967..13db0c75d1 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-content.tsx +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-content.tsx @@ -49,7 +49,14 @@ export const PaneContent = observer(function PaneContent({ const activeKind = pane.resolvedTabs.find((t) => t.isActive)?.kind ?? null; if (!hasAnyTab) { - return emptyState ?? null; + return ( +
+ {isOverContent && ( +
+ )} + {emptyState} +
+ ); } return ( diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.test.ts b/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.test.ts index d4248fff04..afae2cc6f7 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.test.ts @@ -1,5 +1,5 @@ -import { intercept, runInAction } from 'mobx'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { intercept, makeObservable, observable, runInAction } from 'mobx'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@renderer/lib/ipc', () => ({ events: { @@ -65,7 +65,12 @@ vi.mock('@renderer/utils/logger', () => ({ })); import { browserSessionStore } from '@renderer/features/browser/browser-session-store'; +import { terminalRegistry } from '@renderer/features/tasks/stores/terminal-registry'; import { taskTabView } from '@renderer/features/tasks/task-tab-registry'; +import type { + TerminalManagerStore, + TerminalStore, +} from '@renderer/features/tasks/terminals/terminal-manager'; import { PaneLayoutStore } from './pane-layout-store'; const testCtx = { @@ -80,6 +85,47 @@ function createLayout(opts?: { onActiveTabChange?: (tabId: string | undefined) = return new PaneLayoutStore(taskTabView.registry, testCtx, undefined, opts); } +class FakeTerminalManagerStore { + terminals = observable.map(); + sessions = observable.map(); + isLoaded: boolean; + dispose = vi.fn(); + + constructor({ terminalIds, isLoaded }: { terminalIds: string[]; isLoaded: boolean }) { + this.isLoaded = isLoaded; + for (const id of terminalIds) { + this.terminals.set(id, { + data: { + id, + projectId: 'project-1', + taskId: 'task-1', + shellId: 'system', + name: 'Terminal 1', + }, + } as TerminalStore); + } + makeObservable(this, { + terminals: observable, + sessions: observable, + isLoaded: observable, + }); + } +} + +function terminalRegistryEntries(): { + set(taskId: string, manager: TerminalManagerStore): void; + delete(taskId: string): boolean; +} { + return ( + terminalRegistry as unknown as { + entries: { + set(taskId: string, manager: TerminalManagerStore): void; + delete(taskId: string): boolean; + }; + } + ).entries; +} + describe('PaneLayoutStore: isViewActive and onActivate', () => { beforeEach(() => { vi.clearAllMocks(); @@ -207,6 +253,54 @@ describe('PaneLayoutStore: isViewActive and onActivate', () => { }); }); +describe('PaneLayoutStore: single-mount explicit target opens', () => { + beforeEach(() => { + vi.clearAllMocks(); + browserSessionStore.clear(); + terminalRegistryEntries().set( + 'task-1', + new FakeTerminalManagerStore({ + terminalIds: ['terminal-1'], + isLoaded: true, + }) as unknown as TerminalManagerStore + ); + }); + + afterEach(() => { + terminalRegistry.release('task-1'); + terminalRegistryEntries().delete('task-1'); + }); + + it('moves an existing single-mount tab to an explicit target pane', () => { + const layout = createLayout(); + + layout.open('terminal', { terminalId: 'terminal-1' }); + const sourcePaneId = layout.activePaneId; + const sourcePane = layout.focusedPane; + const terminalTab = sourcePane.resolvedTabs[0]!; + const terminalResource = terminalTab.resource; + + layout.open('browser', {}); + layout.splitRight(); + const targetPaneId = layout.activePaneId; + const targetPane = layout.focusedPane; + + expect(targetPaneId).not.toBe(sourcePaneId); + expect(sourcePane.resolvedTabs.some((tab) => tab.kind === 'terminal')).toBe(true); + expect(targetPane.resolvedTabs.some((tab) => tab.kind === 'terminal')).toBe(false); + + layout.open('terminal', { terminalId: 'terminal-1' }, { target: { paneId: targetPaneId } }); + + expect(sourcePane.resolvedTabs.some((tab) => tab.kind === 'terminal')).toBe(false); + const movedTerminalTab = targetPane.resolvedTabs.find((tab) => tab.kind === 'terminal'); + expect(movedTerminalTab?.tabId).toBe(terminalTab.tabId); + expect(movedTerminalTab?.resource).toBe(terminalResource); + expect(targetPane.resolvedActiveTabId).toBe(terminalTab.tabId); + + layout.dispose(); + }); +}); + describe('PaneLayoutStore: onActiveTabChange callback', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.ts b/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.ts index 154f1c106e..3c8fc81acd 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-layout-store.ts @@ -216,6 +216,9 @@ export class PaneLayoutStore { * * For single-mount providers, scans all panes for an existing dedupKey match * and focuses it (with optional state override) instead of opening a new tab. + * When callers provide an explicit pane target, the existing tab is moved + * there so drag/drop placement intent is preserved without reinitializing + * the resource. * * For multi providers, routes directly to the target pane. */ @@ -226,6 +229,10 @@ export class PaneLayoutStore { const previewFlag = !!config?.preview; const overrideStateFlag = !!config?.overrideState; const resolvedTarget: OpenTarget = config?.target ?? 'active'; + const explicitTargetPaneId = + typeof resolvedTarget === 'object' && 'paneId' in resolvedTarget + ? resolvedTarget.paneId + : undefined; if (!this._registry.has(kind)) { console.warn(`[PaneLayoutStore] Unknown tab kind: ${kind}`); @@ -245,6 +252,16 @@ export class PaneLayoutStore { for (const g of this.groups) { const existing = g.pane.findSingleMountEntry(kind, key); if (existing) { + const explicitTargetGroup = explicitTargetPaneId + ? this.groups.find((group) => group.paneId === explicitTargetPaneId) + : undefined; + if (explicitTargetGroup && explicitTargetGroup.paneId !== g.paneId) { + if (!previewFlag) existing.isPreview = false; + if (overrideStateFlag) existing.state = initialState; + this.moveTab(existing.tabId, g.paneId, explicitTargetGroup.paneId); + return; + } + this.setActiveGroup(g.paneId); if (!previewFlag) existing.isPreview = false; if (overrideStateFlag) existing.state = initialState; diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts index 0e4436a8fc..e798d85e8d 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeObservable, observable, runInAction } from 'mobx'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { browserDiagnosticsStore } from '@renderer/features/browser/browser-diagnostics-store'; import { browserSessionStore } from '@renderer/features/browser/browser-session-store'; import { events } from '@renderer/lib/ipc'; @@ -87,7 +88,12 @@ vi.mock('@renderer/features/conversations/acp/acp-chat-panel', () => ({ })); import type { BrowserTabResource } from '@renderer/features/browser/browser-tab-resource'; +import { terminalRegistry } from '@renderer/features/tasks/stores/terminal-registry'; import { taskTabView } from '@renderer/features/tasks/task-tab-registry'; +import type { + TerminalManagerStore, + TerminalStore, +} from '@renderer/features/tasks/terminals/terminal-manager'; import type { ResolvedTab } from './core/tab-provider'; import { PaneStore } from './pane-store'; @@ -107,6 +113,47 @@ function browserResource(tab: ResolvedTab | undefined): BrowserTabResource | und return tab?.resource as BrowserTabResource | undefined; } +class FakeTerminalManagerStore { + terminals = observable.map(); + sessions = observable.map(); + isLoaded: boolean; + dispose = vi.fn(); + + constructor({ terminalIds, isLoaded }: { terminalIds: string[]; isLoaded: boolean }) { + this.isLoaded = isLoaded; + for (const id of terminalIds) { + this.terminals.set(id, { + data: { + id, + projectId: 'project-1', + taskId: 'task-1', + shellId: 'system', + name: 'Terminal 1', + }, + } as TerminalStore); + } + makeObservable(this, { + terminals: observable, + sessions: observable, + isLoaded: observable, + }); + } +} + +function terminalRegistryEntries(): { + set(taskId: string, manager: TerminalManagerStore): void; + delete(taskId: string): boolean; +} { + return ( + terminalRegistry as unknown as { + entries: { + set(taskId: string, manager: TerminalManagerStore): void; + delete(taskId: string): boolean; + }; + } + ).entries; +} + describe('PaneStore browser tabs', () => { beforeEach(() => { vi.clearAllMocks(); @@ -114,6 +161,11 @@ describe('PaneStore browser tabs', () => { browserSessionStore.clear(); }); + afterEach(() => { + terminalRegistry.release('task-1'); + terminalRegistryEntries().delete('task-1'); + }); + it('opens browser tabs backed by the default browser profile session', () => { const manager = createTabManager(); @@ -235,3 +287,109 @@ describe('PaneStore browser tabs', () => { ); }); }); + +describe('PaneStore terminal tabs', () => { + beforeEach(() => { + vi.clearAllMocks(); + terminalRegistryEntries().set( + 'task-1', + new FakeTerminalManagerStore({ + terminalIds: ['terminal-1'], + isLoaded: true, + }) as unknown as TerminalManagerStore + ); + }); + + afterEach(() => { + terminalRegistry.release('task-1'); + terminalRegistryEntries().delete('task-1'); + }); + + it('opens and snapshots an existing terminal as a task tab', () => { + const manager = createTabManager(); + + manager.open('terminal', { terminalId: 'terminal-1' }); + + expect(manager.resolvedTabs[0]).toMatchObject({ + kind: 'terminal', + isActive: true, + }); + expect(manager.snapshot.tabs[0]).toMatchObject({ + kind: 'terminal', + terminalId: 'terminal-1', + isPreview: false, + }); + }); + + it('restores terminal tab descriptors without creating a new terminal runtime', () => { + const restored = createTabManager(); + + restored.restoreSnapshot({ + activeTabId: 'tab-terminal-1', + tabs: [ + { + kind: 'terminal', + tabId: 'tab-terminal-1', + terminalId: 'terminal-1', + isPreview: false, + }, + ], + }); + + expect(restored.resolvedTabs[0]).toMatchObject({ + kind: 'terminal', + tabId: 'tab-terminal-1', + isActive: true, + }); + expect(terminalRegistry.get('task-1')?.terminals.has('terminal-1')).toBe(true); + }); + + it('closing a terminal task tab only closes the tab view', () => { + const manager = createTabManager(); + manager.open('terminal', { terminalId: 'terminal-1' }); + + manager.closeTab(manager.resolvedTabs[0]?.tabId ?? ''); + + expect(manager.resolvedTabs).toEqual([]); + expect(terminalRegistry.get('task-1')?.terminals.has('terminal-1')).toBe(true); + }); + + it('stale-closes terminal tabs when the drawer-owned terminal is removed', async () => { + const manager = createTabManager(); + manager.open('terminal', { terminalId: 'terminal-1' }); + + runInAction(() => { + terminalRegistry.get('task-1')?.terminals.delete('terminal-1'); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(manager.resolvedTabs).toEqual([]); + }); + + it('stale-closes restored terminal tabs after initialization finishes', async () => { + const terminals = terminalRegistry.get('task-1') as unknown as FakeTerminalManagerStore; + runInAction(() => { + terminals.terminals.clear(); + terminals.isLoaded = true; + }); + + const restored = createTabManager(); + restored.restoreSnapshot({ + activeTabId: 'tab-terminal-1', + tabs: [ + { + kind: 'terminal', + tabId: 'tab-terminal-1', + terminalId: 'terminal-1', + isPreview: false, + }, + ], + }); + + expect(restored.resolvedTabs).toHaveLength(1); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(restored.resolvedTabs).toEqual([]); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts index cbc8ce0b89..3c32b599bf 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts @@ -318,6 +318,7 @@ describe('createTaskCommandProvider', () => { expect(taskView.openNewTerminal).toHaveBeenCalledTimes(1); expect(taskView.openNewTerminal).toHaveBeenCalledWith(); + expect(taskView.paneLayout.open).not.toHaveBeenCalled(); expect(taskView.setTerminalDrawerOpen).not.toHaveBeenCalled(); }); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx index 456c2f6c5d..71e93ce81f 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.tsx @@ -27,7 +27,14 @@ import { terminalRegistry } from './terminal-registry'; import { resolveWorkspacePath } from './workspace-path'; import { workspaceRegistry } from './workspace-registry'; -export type RendererKind = 'monaco' | 'markdown' | 'diff' | 'agents' | 'browser' | 'other-file'; +export type RendererKind = + | 'monaco' + | 'markdown' + | 'diff' + | 'agents' + | 'browser' + | 'terminal' + | 'other-file'; export class WorkspaceViewModel implements ILifecycle { sidebarTab: SidebarTab; @@ -95,6 +102,7 @@ export class WorkspaceViewModel implements ILifecycle { return workspaceRegistry.get(projectId, workspaceId)?.path; }, modelRootPath: `workspace:${workspaceId}`, + getRemoteConnectionId: () => this._workspace?.sshConnectionId, }; this.paneLayout = taskTabView.createPaneLayoutStore(taskCtx, { onActiveTabChange: (tabId) => { @@ -148,6 +156,7 @@ export class WorkspaceViewModel implements ILifecycle { const desc = this.activePane.activeEntry; if (desc?.kind === 'diff') return 'diff'; if (desc?.kind === 'browser') return 'browser'; + if (desc?.kind === 'terminal') return 'terminal'; const resource = this.activePane.activeResourceOfKind('file'); if (!resource) return 'agents'; if (resource.contentType === 'markdown' && resource.viewMode === 'preview') return 'markdown'; @@ -347,7 +356,7 @@ export class WorkspaceViewModel implements ILifecycle { // Actions // ------------------------------------------------------------------------- - activateLastTabOfKind(kind: 'conversation' | 'file' | 'diff' | 'browser'): void { + activateLastTabOfKind(kind: 'conversation' | 'file' | 'diff' | 'browser' | 'terminal'): void { const tabId = [...this.activePane.tabOrder] .reverse() .find((id) => this.activePane.entries.get(id)?.kind === kind); @@ -359,7 +368,9 @@ export class WorkspaceViewModel implements ILifecycle { ? 'editor' : kind === 'diff' ? 'diff' - : 'browser'; + : kind === 'browser' + ? 'browser' + : 'terminal'; focusTracker.transition({ mainPanel: panelView }, 'panel_switch'); this.activePane.setActiveTab(tabId); } diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-tab-registry.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-tab-registry.tsx index 24a998c686..e7adbb90ad 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/task-tab-registry.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/task-tab-registry.tsx @@ -23,6 +23,7 @@ import type { TaskTabContext } from '../tabs/core/task-tab-context'; import { diffTabProvider } from './diff-view/diff-tab-provider'; import { fileTabProvider } from './editor/file-tab-provider'; import { TaskTabViewPersistor } from './stores/task-tab-view-persistor'; +import { terminalTabProvider } from './terminals/terminal-tab-provider'; export const taskTabView = createTabView( [ @@ -31,6 +32,7 @@ export const taskTabView = createTabView( fileTabProvider, diffTabProvider, browserTabProvider, + terminalTabProvider, ] as const, { makePersistor: (ctx) => new TaskTabViewPersistor(ctx as TaskTabContext) } ); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drag.ts b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drag.ts new file mode 100644 index 0000000000..79f79d7d7a --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drag.ts @@ -0,0 +1,17 @@ +export const TERMINAL_DRAWER_DRAG_TYPE = 'task-terminal-drawer-terminal'; + +export interface TerminalDrawerDragData { + type: typeof TERMINAL_DRAWER_DRAG_TYPE; + terminalId: string; + label: string; +} + +export function isTerminalDrawerDragData(value: unknown): value is TerminalDrawerDragData { + if (!value || typeof value !== 'object') return false; + const data = value as Partial; + return ( + data.type === TERMINAL_DRAWER_DRAG_TYPE && + typeof data.terminalId === 'string' && + typeof data.label === 'string' + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drawer-sidebar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drawer-sidebar.tsx index 64dc523eef..e92a1bd877 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drawer-sidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-drawer-sidebar.tsx @@ -1,3 +1,4 @@ +import { useDraggable } from '@dnd-kit/core'; import { ChevronDown, Pause, Play, Plus, Settings, Terminal, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useEffect, useRef, useState, type ReactNode } from 'react'; @@ -21,6 +22,7 @@ import type { TerminalShellAvailability, TerminalShellId, } from '@shared/core/terminals/terminal-settings'; +import { TERMINAL_DRAWER_DRAG_TYPE, type TerminalDrawerDragData } from './terminal-drag'; import { scriptIcon } from './terminal-tabs'; interface TerminalDrawerSidebarProps { @@ -120,6 +122,12 @@ export const TerminalDrawerSidebar = observer(function TerminalDrawerSidebar({ icon={} label={terminal.data.name} isActive={activeTerminalId === terminal.data.id} + dragId={`terminal-drawer-${terminal.data.id}`} + dragData={{ + type: TERMINAL_DRAWER_DRAG_TYPE, + terminalId: terminal.data.id, + label: terminal.data.name, + }} onSelect={() => onSelectTerminal(terminal.data.id)} onRename={(name) => onRenameTerminal(terminal.data.id, name)} onHover={onHoverTerminal ? () => onHoverTerminal(terminal.data.id) : undefined} @@ -128,6 +136,7 @@ export const TerminalDrawerSidebar = observer(function TerminalDrawerSidebar({ + activeTerminalIsOpenInMain ? undefined : ( + + ) } /> ); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-pty-content.tsx b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-pty-content.tsx index 3b1b1262ae..9d945c66e7 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-pty-content.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-pty-content.tsx @@ -101,7 +101,7 @@ export const TerminalPtyContent = observer(function TerminalPtyContent({ > - {!hasSessions ? ( + {!hasSessions || !activeSession ? ( emptyState ) : (
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx new file mode 100644 index 0000000000..09a433417c --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx @@ -0,0 +1,204 @@ +import { Terminal } from 'lucide-react'; +import { computed, makeObservable, reaction } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import type { + ResolvedTab, + TabBarItemProps, + TabContentProps, + TabEntry, + TabHandle, + TabProvider, + TabResource, + TabViewContext, +} from '@renderer/features/tabs/core/tab-provider'; +import { createTabProvider } from '@renderer/features/tabs/core/tab-provider-registry'; +import type { TaskTabContext } from '@renderer/features/tabs/core/task-tab-context'; +import { + GenericTabDragPreview, + GenericTabItem, +} from '@renderer/features/tabs/tab-bar/generic-tab-item'; +import type { PtySession } from '@renderer/lib/pty/pty-session'; +import { EmptyState } from '@renderer/lib/ui/empty-state'; +import { terminalRegistry } from '../stores/terminal-registry'; +import type { TerminalManagerStore, TerminalStore } from './terminal-manager'; +import { TerminalPtyContent } from './terminal-pty-content'; + +export interface TerminalTabState { + terminalId: string; +} + +export interface TerminalTabOpenArgs { + terminalId: string; +} + +interface TerminalTabResourceView extends TabResource { + readonly terminalId: string; + readonly terminal: TerminalStore | undefined; + readonly session: PtySession | null; +} + +class TerminalTabResource implements TerminalTabResourceView { + private readonly disposeStaleReaction: () => void; + private isDisposed = false; + + constructor( + readonly terminalId: string, + private readonly terminalManager: TerminalManagerStore, + private readonly handle: TabHandle + ) { + makeObservable(this, { + terminal: computed, + }); + + 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 } + ); + } + + get terminal(): TerminalStore | undefined { + return this.terminalManager.terminals.get(this.terminalId); + } + + get session() { + return this.terminalManager.sessions.get(this.terminalId) ?? null; + } + + dispose(): void { + if (this.isDisposed) return; + this.isDisposed = true; + this.disposeStaleReaction(); + } + + onActivateIntent(): void { + const session = this.session; + if (session?.status === 'disconnected') void session.connect(); + } +} + +class MissingTerminalTabResource implements TerminalTabResourceView { + constructor(readonly terminalId: string) {} + + get terminal(): TerminalStore | undefined { + return undefined; + } + + get session() { + return null; + } + + dispose(): void {} + + onActivateIntent(): void {} +} + +function TerminalIcon() { + return ; +} + +const TerminalTabBarItem = observer(function TerminalTabBarItem({ + tab, + host, + ctx, +}: TabBarItemProps) { + const terminal = tab.resource.terminal; + const label = terminal?.data.name ?? 'Terminal'; + + return ( + } /> + ); +}); + +const TerminalTabBarItemDragPreview = observer(function TerminalTabBarItemDragPreview({ + tab, +}: { + tab: ResolvedTab; +}) { + const label = tab.resource.terminal?.data.name ?? 'Terminal'; + return } label={label} />; +}); + +const TerminalTabContent = observer(function TerminalTabContent({ host, ctx }: TabContentProps) { + const taskCtx = ctx as TaskTabContext; + const terminalManager = terminalRegistry.get(taskCtx.taskId); + const terminalTabs = host.resolvedTabs.filter( + (tab): tab is ResolvedTab => tab.kind === 'terminal' + ); + const activeTab = host.resolvedTabs.find((tab) => tab.isActive); + const activeTerminal = + activeTab?.kind === 'terminal' ? (activeTab.resource as TerminalTabResourceView) : null; + const activeSession = activeTerminal?.session ?? null; + const allSessionIds = terminalTabs + .map((tab) => tab.resource.session?.sessionId) + .filter((id): id is string => Boolean(id)); + + return ( + } + label={terminalManager?.isLoaded ? 'Terminal unavailable' : 'Loading terminal'} + description={ + terminalManager?.isLoaded + ? 'This terminal was removed from the drawer.' + : 'Restoring terminal session.' + } + /> + } + remoteConnectionId={taskCtx.getRemoteConnectionId?.()} + workspaceId={taskCtx.workspaceId} + /> + ); +}); + +export const terminalTabProvider: TabProvider< + 'terminal', + TerminalTabState, + TerminalTabResourceView, + TerminalTabOpenArgs +> = createTabProvider({ + kind: 'terminal', + mount: 'single', + resourceKey: (state: TerminalTabState) => state.terminalId, + + onBeforeOpen(args: TerminalTabOpenArgs, ctx: TabViewContext): TerminalTabState | null { + const taskCtx = ctx as TaskTabContext; + const terminalManager = terminalRegistry.get(taskCtx.taskId); + if (!terminalManager) return null; + return { terminalId: args.terminalId }; + }, + + initialize( + entry: TabEntry, + handle: TabHandle, + ctx: TabViewContext + ): TerminalTabResourceView { + const taskCtx = ctx as TaskTabContext; + const terminalManager = terminalRegistry.get(taskCtx.taskId); + if (!terminalManager) { + setTimeout(() => void handle.close({ force: true }), 0); + return new MissingTerminalTabResource(entry.state.terminalId); + } + return new TerminalTabResource(entry.state.terminalId, terminalManager, handle); + }, + + dispose(_entry: TabEntry, resource: TerminalTabResourceView): void { + resource.dispose(); + }, + + TabBarItem: TerminalTabBarItem, + TabBarItemDragPreview: TerminalTabBarItemDragPreview, + TabContent: TerminalTabContent, +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/task-main-column.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/task-main-column.tsx index 5fae503add..5c2fe5e9da 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/task-main-column.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/task-main-column.tsx @@ -1,6 +1,8 @@ import { DndContext, + type DragEndEvent, DragOverlay, + type DragStartEvent, PointerSensor, pointerWithin, useSensor, @@ -17,11 +19,19 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@renderer/ import { PaneEmptyState } from '../pane-empty-state'; import { TabBarActions } from '../tab-bar-actions'; import { useWorkspaceViewModel } from '../task-view-context'; +import { isTerminalDrawerDragData, type TerminalDrawerDragData } from '../terminals/terminal-drag'; import { TerminalsPanel } from '../terminals/terminal-panel'; +type ActiveDrag = + | { kind: 'tab'; tabId: string } + | { kind: 'terminal'; terminal: TerminalDrawerDragData }; + export const TaskMainColumn = observer(function TaskMainColumn() { const taskView = useWorkspaceViewModel(); + const { paneLayout } = taskView; const bottomPanelRef = usePanelRef(); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const [activeDrag, setActiveDrag] = useState(null); useEffect(() => { if (taskView.isTerminalDrawerOpen) { @@ -31,27 +41,71 @@ export const TaskMainColumn = observer(function TaskMainColumn() { } }, [taskView.isTerminalDrawerOpen, bottomPanelRef]); + const handleDragStart = (event: DragStartEvent) => { + const terminalDragData = event.active.data.current; + if (isTerminalDrawerDragData(terminalDragData)) { + setActiveDrag({ kind: 'terminal', terminal: terminalDragData }); + return; + } + setActiveDrag({ kind: 'tab', tabId: event.active.id as string }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDrag(null); + if (!event.over) return; + + const terminalDragData = event.active.data.current; + if (isTerminalDrawerDragData(terminalDragData)) { + const paneId = resolveDropPaneId(String(event.over.id), paneLayout); + if (!paneId) return; + paneLayout.setActiveGroup(paneId); + paneLayout.open( + 'terminal', + { terminalId: terminalDragData.terminalId }, + { target: { paneId } } + ); + return; + } + + paneLayout.handleDragEnd(event.active.id as string, event.over.id as string); + }; + return ( - - - - - - { - if (prevPanelSize === undefined) return; - taskView.setTerminalDrawerOpen(!bottomPanelRef.current?.isCollapsed()); - }} - > - - - + setActiveDrag(null)} + > + + + + + + { + if (prevPanelSize === undefined) return; + taskView.setTerminalDrawerOpen(!bottomPanelRef.current?.isCollapsed()); + }} + > + + + + + {activeDrag?.kind === 'tab' ? ( + + ) : activeDrag?.kind === 'terminal' ? ( + + ) : null} + + ); }); @@ -93,39 +147,35 @@ const SplitPaneLayout = observer(function SplitPaneLayout() { const taskView = useWorkspaceViewModel(); const { paneLayout } = taskView; - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); - const [activeDragId, setActiveDragId] = useState(null); - return ( - setActiveDragId(active.id as string)} - onDragEnd={(event) => { - setActiveDragId(null); - if (event.over) { - paneLayout.handleDragEnd(event.active.id as string, event.over.id as string); - } - }} - onDragCancel={() => setActiveDragId(null)} - > - - {paneLayout.groups.map((group, i) => ( - paneLayout.setActiveGroup(group.paneId)} - defaultSizePct={paneLayout.paneSizes[i] ?? Math.floor(100 / paneLayout.groups.length)} - /> - ))} - - - {activeDragId ? : null} - - + + {paneLayout.groups.map((group, i) => ( + paneLayout.setActiveGroup(group.paneId)} + defaultSizePct={paneLayout.paneSizes[i] ?? Math.floor(100 / paneLayout.groups.length)} + /> + ))} + ); }); + +function resolveDropPaneId( + overId: string, + paneLayout: ReturnType['paneLayout'] +): string | undefined { + if (overId.startsWith('pane-drop-')) return overId.slice('pane-drop-'.length); + if (overId.startsWith('pane-content-')) return overId.slice('pane-content-'.length); + return paneLayout.groups.find((group) => group.pane.entries.has(overId))?.paneId; +} + +function TerminalDragPreview({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/lib/pty/use-pty.ts b/apps/emdash-desktop/src/renderer/lib/pty/use-pty.ts index fd223c8326..6cf6d9cec9 100644 --- a/apps/emdash-desktop/src/renderer/lib/pty/use-pty.ts +++ b/apps/emdash-desktop/src/renderer/lib/pty/use-pty.ts @@ -657,8 +657,13 @@ export function usePty( fn(); } catch {} } - // Return terminal's ownedContainer to the off-screen host. - pty.unmount(); + // Return terminal's ownedContainer to the off-screen host only if this + // React host still owns it. A terminal session can be reparented into a + // different surface before this cleanup runs; in that case, unmounting + // here would steal the xterm DOM back from the new owner. + if (container.contains(pty.ownedContainer)) { + pty.unmount(); + } termRef.current = null; ptyStartedRef.current = false; firstMessageSentRef.current = false; diff --git a/apps/emdash-desktop/src/shared/telemetry.ts b/apps/emdash-desktop/src/shared/telemetry.ts index d1a138da41..ecda7b7a55 100644 --- a/apps/emdash-desktop/src/shared/telemetry.ts +++ b/apps/emdash-desktop/src/shared/telemetry.ts @@ -18,7 +18,7 @@ export type FocusView = | 'skills' | 'mcp' | 'automations'; -export type FocusMainPanel = 'agents' | 'editor' | 'diff' | 'browser'; +export type FocusMainPanel = 'agents' | 'editor' | 'diff' | 'browser' | 'terminal'; export type FocusedRegion = 'main' | 'bottom'; export type FocusTrigger = 'navigation' | 'panel_switch' | 'region_switch'; diff --git a/apps/emdash-desktop/src/shared/view-state.ts b/apps/emdash-desktop/src/shared/view-state.ts index 3764df2690..b72aa39e40 100644 --- a/apps/emdash-desktop/src/shared/view-state.ts +++ b/apps/emdash-desktop/src/shared/view-state.ts @@ -17,6 +17,7 @@ export type TabDescriptor = session: BrowserSessionSnapshot; isPreview: boolean; } + | { kind: 'terminal'; tabId: string; terminalId: string; isPreview: boolean } | { kind: 'diff'; tabId: string;