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 1f06cb21a4..a57cec31e2 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,6 +1,8 @@ +import { observable, runInAction } from 'mobx'; import { 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 { terminalRegistry } from '@renderer/features/tasks/stores/terminal-registry'; import { events } from '@renderer/lib/ipc'; import { browserOpenInNewTabChannel } from '@shared/events/browserEvents'; @@ -15,6 +17,11 @@ vi.mock('@renderer/lib/ipc', () => ({ browser: { unregisterSession: vi.fn(), }, + ssh: { + getConnections: vi.fn(async () => []), + getConnectionState: vi.fn(async () => ({})), + getHealthStates: vi.fn(async () => ({})), + }, }, })); @@ -51,6 +58,10 @@ vi.mock('@renderer/features/tasks/diff-view/diff-tab-item', () => ({ DiffTabDragPreview: () => null, diffGroupSuffix: (group: string) => `(${group})`, })); +vi.mock('@renderer/features/tasks/terminals/terminal-tab-item', () => ({ + TerminalTabItem: () => null, + TerminalTabDragPreview: () => null, +})); vi.mock('@renderer/features/tasks/conversations/conversation-title-utils', () => ({ formatConversationTitleForDisplay: (_providerId: unknown, title: unknown) => (title as string) ?? 'Conversation', @@ -73,11 +84,43 @@ function createTabManager() { return new PaneStore(taskTabView.registry, testCtx); } +function terminalRegistryEntries(): Map { + return ( + terminalRegistry as unknown as { + entries: Map; + } + ).entries; +} + +function setTerminalRegistry(ids: string[], renameTerminal = vi.fn()) { + const terminals = observable.map( + ids.map((id) => [ + id, + { + data: { + id, + projectId: 'project-1', + taskId: 'task-1', + shellId: 'system', + name: id === 'terminal-1' ? 'Terminal 1' : 'Terminal 2', + }, + }, + ]) + ); + terminalRegistryEntries().set('task-1', { + terminals, + sessions: observable.map(), + renameTerminal, + }); + return { terminals, renameTerminal }; +} + describe('PaneStore browser tabs', () => { beforeEach(() => { vi.clearAllMocks(); browserDiagnosticsStore.clear(); browserSessionStore.clear(); + terminalRegistryEntries().delete('task-1'); }); it('opens browser tabs backed by the default browser profile session', () => { @@ -200,4 +243,63 @@ describe('PaneStore browser tabs', () => { (manager.resolvedTabs[1] as ResolvedTab | undefined)?.session.currentUrl ).toBe('https://target.example/path'); }); + + it('opens terminal tabs backed by task terminal records', () => { + setTerminalRegistry(['terminal-1']); + const manager = createTabManager(); + + manager.open('terminal', { terminalId: 'terminal-1' }); + + expect(manager.resolvedTabs[0]).toMatchObject({ + kind: 'terminal', + terminalId: 'terminal-1', + isActive: true, + }); + expect(manager.snapshot.tabs).toEqual([ + expect.objectContaining({ + kind: 'terminal', + terminalId: 'terminal-1', + isPreview: false, + }), + ]); + }); + + it('restores terminal tab descriptors through tab manager state', () => { + setTerminalRegistry(['terminal-1']); + const manager = createTabManager(); + + manager.restoreSnapshot({ + tabs: [ + { + kind: 'terminal', + tabId: 'tab-terminal-1', + terminalId: 'terminal-1', + isPreview: false, + }, + ], + activeTabId: 'tab-terminal-1', + }); + + expect(manager.resolvedTabs).toEqual([ + expect.objectContaining({ + kind: 'terminal', + tabId: 'tab-terminal-1', + terminalId: 'terminal-1', + isActive: true, + }), + ]); + }); + + it('closes terminal tabs when the backing terminal is deleted', () => { + const { terminals } = setTerminalRegistry(['terminal-1']); + const manager = createTabManager(); + manager.open('terminal', { terminalId: 'terminal-1' }); + + runInAction(() => { + terminals.delete('terminal-1'); + }); + + expect(manager.resolvedTabs).toEqual([]); + expect(manager.tabOrder).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 d1decd2ea4..314ca34494 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/commands.test.ts @@ -109,6 +109,7 @@ describe('createTaskCommandProvider', () => { isSidebarCollapsed: false, isTerminalDrawerOpen: false, openNewTerminal: vi.fn(), + openNewTerminalTab: vi.fn(), setFocusedRegion: vi.fn(), setSidebarCollapsed: vi.fn(), setSidebarTab: vi.fn(), @@ -181,12 +182,43 @@ describe('createTaskCommandProvider', () => { }); const modalOptions = mocks.showModal.mock.calls[0][1]; - modalOptions.onSuccess({ conversationId: 'conversation-1' }); + modalOptions.onSuccess({ + conversationId: 'conversation-1', + openBrowserTab: true, + openTerminalTab: false, + }); expect(taskView.paneLayout.openInRightSplit).toHaveBeenCalledWith('conversation', { conversationId: 'conversation-1', preview: false, }); + expect(taskView.paneLayout.open).toHaveBeenCalledWith('browser', {}); + expect(taskView.setFocusedRegion).toHaveBeenCalledWith('main'); + }); + + it('opens requested browser and terminal tabs from the create conversation command modal result', () => { + const provider = createTaskCommandProvider('project-1', 'task-1'); + + const command = provider + .getCommands() + .find((candidate) => candidate.id === 'task.newConversation'); + const taskView = mocks.getTaskView.mock.results.at(-1)?.value ?? mocks.getTaskView(); + + command?.execute(); + + const modalOptions = mocks.showModal.mock.calls[0][1]; + modalOptions.onSuccess({ + conversationId: 'conversation-1', + openBrowserTab: true, + openTerminalTab: true, + }); + + expect(taskView.paneLayout.open).toHaveBeenCalledWith('conversation', { + conversationId: 'conversation-1', + preview: false, + }); + expect(taskView.paneLayout.open).toHaveBeenCalledWith('browser', {}); + expect(taskView.openNewTerminalTab).toHaveBeenCalledTimes(1); expect(taskView.setFocusedRegion).toHaveBeenCalledWith('main'); }); @@ -286,6 +318,19 @@ describe('createTaskCommandProvider', () => { expect(taskView.setTerminalDrawerOpen).not.toHaveBeenCalled(); }); + it('creates a terminal task tab from the new terminal command', () => { + const provider = createTaskCommandProvider('project-1', 'task-1'); + + const command = provider.getCommands().find((candidate) => candidate.id === 'task.newTerminal'); + const taskView = mocks.getTaskView.mock.results.at(-1)?.value ?? mocks.getTaskView(); + + command?.execute(); + + expect(taskView.openNewTerminalTab).toHaveBeenCalledTimes(1); + expect(taskView.openNewTerminalTab).toHaveBeenCalledWith(); + expect(taskView.openNewTerminal).not.toHaveBeenCalled(); + }); + it('navigates to the next visible task across project boundaries', () => { mocks.visibleTaskEntries = [ { projectId: 'project-1', taskId: 'task-1' }, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/commands.ts b/apps/emdash-desktop/src/renderer/features/tasks/commands.ts index b6835b5ffc..2b003f19a1 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/commands.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/commands.ts @@ -89,8 +89,10 @@ export function createTaskCommandProvider(projectId: string, taskId: string): Co showModal('createConversationModal', { projectId, taskId, - onSuccess: ({ conversationId }) => { + onSuccess: ({ conversationId, openBrowserTab, openTerminalTab }) => { taskView?.paneLayout.open('conversation', { conversationId, preview: false }); + if (openBrowserTab) taskView?.paneLayout.open('browser', {}); + if (openTerminalTab) void taskView?.openNewTerminalTab(); taskView?.setFocusedRegion('main'); }, }); @@ -106,11 +108,13 @@ export function createTaskCommandProvider(projectId: string, taskId: string): Co showModal('createConversationModal', { projectId, taskId, - onSuccess: ({ conversationId }) => { + onSuccess: ({ conversationId, openBrowserTab, openTerminalTab }) => { taskView?.paneLayout.openInRightSplit('conversation', { conversationId, preview: false, }); + if (openBrowserTab) taskView?.paneLayout.open('browser', {}); + if (openTerminalTab) void taskView?.openNewTerminalTab(); taskView?.setFocusedRegion('main'); }, }); @@ -201,7 +205,7 @@ export function createTaskCommandProvider(projectId: string, taskId: string): Co shortcutKey: newTerminalDef.shortcutKey, group: newTerminalDef.group, execute() { - void taskView?.openNewTerminal(); + void taskView?.openNewTerminalTab(); }, }, { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx index d796c334cd..2350120ad6 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/create-conversation-modal.tsx @@ -1,12 +1,11 @@ import { observer } from 'mobx-react-lite'; import { useCallback, useState } from 'react'; -import { getProjectSshConnectionId } from '@renderer/features/projects/stores/project-selectors'; import { useTaskSettings } from '@renderer/features/tasks/hooks/useTaskSettings'; import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; -import { AgentSelector } from '@renderer/lib/components/agent-selector/agent-selector'; +import { getRegisteredTaskData } from '@renderer/features/tasks/stores/task-selectors'; import { type BaseModalProps } from '@renderer/lib/modal/modal-provider'; import { useCloseGuard } from '@renderer/lib/modal/use-close-guard'; -import { useAgents } from '@renderer/lib/stores/use-agents'; +import { Checkbox } from '@renderer/lib/ui/checkbox'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; import { DialogContentArea, @@ -14,63 +13,56 @@ import { DialogHeader, DialogTitle, } from '@renderer/lib/ui/dialog'; -import { Field, FieldGroup, FieldLabel } from '@renderer/lib/ui/field'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/lib/ui/select'; -import { Switch } from '@renderer/lib/ui/switch'; -import { providerSupportsAutoApprove } from '@shared/core/agents/agent-auto-approve'; +import { buildFinalPrompt } from '../create-task-modal/initial-conversation-text'; import { nextDefaultConversationTitle } from './conversation-title-utils'; -import { useEffectiveProvider } from './use-effective-provider'; +import { + InitialConversationField, + useInitialConversationState, +} from './initial-conversation-section'; + +export interface CreateConversationModalResult { + conversationId: string; + openBrowserTab: boolean; + openTerminalTab: boolean; +} export const CreateConversationModal = observer(function CreateConversationModal({ onSuccess, projectId, taskId, -}: BaseModalProps<{ conversationId: string }> & { +}: BaseModalProps & { projectId: string; taskId: string; }) { - const connectionId = getProjectSshConnectionId(projectId); - const { providerId, setProviderOverride, createDisabled } = useEffectiveProvider(connectionId); const conversationMgr = conversationRegistry.get(taskId); - const taskSettings = useTaskSettings(); + const task = getRegisteredTaskData(projectId, taskId); + const { autoApproveByDefault, includeIssueContextByDefault } = useTaskSettings(); + const initialConversation = useInitialConversationState( + projectId, + undefined, + autoApproveByDefault + ); const [isSubmitting, setIsSubmitting] = useState(false); + const [openBrowserTab, setOpenBrowserTab] = useState(false); + const [openTerminalTab, setOpenTerminalTab] = useState(false); const [error, setError] = useState(null); - const [autoApproveOverride, setAutoApproveOverride] = useState(null); - const [selectedModel, setSelectedModel] = useState(null); useCloseGuard(isSubmitting); - const { data: agents } = useAgents(); - const modelsCapability = agents?.find((a) => a.id === providerId)?.capabilities.models; - const modelOptions = - modelsCapability?.kind === 'selectable' ? modelsCapability.modelOptions : null; - - const showAutoApproveToggle = providerId ? providerSupportsAutoApprove(providerId) : false; - const skipPermissions = - showAutoApproveToggle && (autoApproveOverride ?? taskSettings.autoApproveByDefault); + const providerId = initialConversation.provider; + const createDisabled = initialConversation.createDisabled; const titleProviderId = providerId ?? 'claude'; const title = nextDefaultConversationTitle( titleProviderId, Array.from(conversationMgr?.conversations.values() ?? [], (conversation) => conversation.data) ); - // Reset model when the provider changes (model ids are provider-specific). - const handleProviderChange = useCallback( - (next: typeof providerId) => { - setProviderOverride(next); - setSelectedModel(null); - }, - [setProviderOverride] - ); - const handleCreateConversation = useCallback(async () => { if (createDisabled || isSubmitting || !conversationMgr || !providerId) return; const id = crypto.randomUUID(); + const initialPrompt = buildFinalPrompt( + initialConversation.issueContext, + initialConversation.prompt + ); setIsSubmitting(true); setError(null); try { @@ -78,13 +70,14 @@ export const CreateConversationModal = observer(function CreateConversationModal projectId, taskId, id, - autoApprove: skipPermissions, + autoApprove: initialConversation.autoApprove, provider: providerId, title, - model: selectedModel ?? undefined, + model: initialConversation.model ?? undefined, + initialPrompt, }); setIsSubmitting(false); - onSuccess({ conversationId: id }); + onSuccess({ conversationId: id, openBrowserTab, openTerminalTab }); } catch { setError('Failed to create conversation'); setIsSubmitting(false); @@ -92,14 +85,18 @@ export const CreateConversationModal = observer(function CreateConversationModal }, [ conversationMgr, createDisabled, + initialConversation.issueContext, + initialConversation.prompt, + initialConversation.autoApprove, + initialConversation.model, isSubmitting, + openBrowserTab, + openTerminalTab, providerId, title, onSuccess, projectId, taskId, - skipPermissions, - selectedModel, ]); return ( @@ -108,55 +105,28 @@ export const CreateConversationModal = observer(function CreateConversationModal Create Conversation - - - Agent - + + + - {modelOptions ? ( - - Model - - - ) : null} - {showAutoApproveToggle ? ( - -
- - Auto-approve permissions -
-
- ) : null} + Open terminal tab + {error &&

{error}

} -
+
void; + createDisabled: boolean; projectId?: string; prompt: string; setPrompt: Dispatch>; @@ -55,7 +56,10 @@ export function useInitialConversationState( ): InitialConversationState { const { resetPromptOnProjectChange = true } = options; const connectionId = projectId ? getProjectSshConnectionId(projectId) : undefined; - const { providerId, setProviderOverride } = useEffectiveProvider(connectionId, initialProvider); + const { providerId, setProviderOverride, createDisabled } = useEffectiveProvider( + connectionId, + initialProvider + ); const [prompt, setPrompt] = useState(''); const [issueContext, setIssueContext] = useState(null); const [autoApproveOverride, setAutoApproveOverride] = useState(null); @@ -86,6 +90,7 @@ export function useInitialConversationState( return { provider: providerId, setProvider: setProviderOverride, + createDisabled, projectId, prompt, setPrompt, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx index 8046f72b11..d3af813d6a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/conversations/sidebar-conversations-list.tsx @@ -187,8 +187,10 @@ export const SidebarConversationsList = observer(function SidebarConversationsLi showCreateConversationModal({ projectId, taskId, - onSuccess: ({ conversationId }) => { + onSuccess: ({ conversationId, openBrowserTab, openTerminalTab }) => { paneLayout.open('conversation', { conversationId, preview: false }); + if (openBrowserTab) paneLayout.open('browser', {}); + if (openTerminalTab) void taskView.openNewTerminalTab(); }, }); }; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/build-create-task-params.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/build-create-task-params.test.ts index b8d329455b..11ee2a715e 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/build-create-task-params.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/create-task-modal/build-create-task-params.test.ts @@ -10,6 +10,7 @@ function makeInitialConversationState( return { provider, setProvider: () => {}, + createDisabled: false, prompt: 'Check this', setPrompt: () => {}, issueContext: null, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/pane-empty-state.tsx b/apps/emdash-desktop/src/renderer/features/tasks/pane-empty-state.tsx index b21cc0032a..8f1380b02a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/pane-empty-state.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/pane-empty-state.tsx @@ -1,7 +1,10 @@ import { FileSearch, MessageSquare } from 'lucide-react'; import type { ReactNode } from 'react'; import { usePaneContext } from '@renderer/features/tabs/pane-context'; -import { useTaskViewContext } from '@renderer/features/tasks/task-view-context'; +import { + useTaskViewContext, + useWorkspaceViewModel, +} from '@renderer/features/tasks/task-view-context'; import { EmdashLogo } from '@renderer/lib/emdash-logo'; import { useArrowKeyNavigation } from '@renderer/lib/hooks/use-arrow-key-navigation'; import { useShowModal } from '@renderer/lib/modal/modal-provider'; @@ -11,6 +14,7 @@ import type { ShortcutSettingsKey } from '@shared/shortcuts'; export function PaneEmptyState() { const { projectId, taskId, workspaceId } = useTaskViewContext(); + const taskView = useWorkspaceViewModel(); const { pane } = usePaneContext(); const showCreateConversationModal = useShowModal('createConversationModal'); const showCommandPalette = useShowModal('commandPaletteModal'); @@ -20,8 +24,11 @@ export function PaneEmptyState() { showCreateConversationModal({ projectId, taskId, - onSuccess: ({ conversationId }) => - pane.open('conversation', { conversationId, preview: false }), + onSuccess: ({ conversationId, openBrowserTab, openTerminalTab }) => { + pane.open('conversation', { conversationId, preview: false }); + if (openBrowserTab) pane.open('browser', {}); + if (openTerminalTab) void taskView.openNewTerminalTab({ pane }); + }, }), () => showCommandPalette({ projectId, taskId, workspaceId: workspaceId ?? undefined }), ]; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts index 647a4788a9..146018b79a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/workspace-view-model.test.ts @@ -218,6 +218,23 @@ afterEach(() => { }); describe('WorkspaceViewModel terminal drawer snapshot', () => { + it('opens a new terminal task tab in the provided pane target', async () => { + const terminals = makeTerminalManager({ terminalIds: [], isLoaded: true }); + terminals.createDefaultTerminal.mockResolvedValue(makeTerminal('terminal-1').data); + terminalRegistryEntries().set('task-1', terminals); + const viewModel = makeViewModel(); + const pane = { openKind: vi.fn() }; + + await viewModel.openNewTerminalTab({ pane }); + + expect(terminals.createDefaultTerminal).toHaveBeenCalledWith(undefined); + expect(pane.openKind).toHaveBeenCalledWith('terminal', { + terminalId: 'terminal-1', + preview: false, + }); + expect(viewModel.activePane.resolvedTabs).toHaveLength(0); + }); + it('persists and restores the active terminal drawer item', () => { const source = makeViewModel(); source.setTerminalDrawerActiveItem({ kind: 'script', id: 'script-lifecycle-run' }); 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 4ba7f59a77..fc5f7ef787 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,23 @@ import type { TaskTabContext } from './task-tab-context'; import { terminalRegistry } from './terminal-registry'; 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'; + +interface TerminalTabPaneTarget { + openKind(kind: string, args: unknown): void; +} + +interface OpenNewTerminalTabOptions { + shell?: TerminalShellId; + pane?: TerminalTabPaneTarget; +} export class WorkspaceViewModel implements ILifecycle { sidebarTab: SidebarTab; @@ -183,6 +199,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 tab = getActiveFileEntry(this.activePane); if (!tab) return 'agents'; if (tab.contentType === 'markdown' && tab.viewMode === 'preview') return 'markdown'; @@ -365,7 +382,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); @@ -377,7 +394,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); } @@ -427,6 +446,19 @@ export class WorkspaceViewModel implements ILifecycle { return terminalId; } + /** Creates a new terminal session and opens it as a tab in the focused task pane. */ + async openNewTerminalTab(options: OpenNewTerminalTabOptions = {}): Promise { + this.setFocusedRegion('main'); + + const { shell, pane = this.paneLayout.focusedPane } = options; + const terminalId = await this._createDefaultTerminal(shell); + if (!terminalId) return undefined; + runInAction(() => { + pane.openKind('terminal', { terminalId, preview: false }); + }); + return terminalId; + } + private async _createDefaultTerminal(shell?: TerminalShellId): Promise { if (this._isCreatingTerminal) return undefined; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tab-bar-actions.tsx b/apps/emdash-desktop/src/renderer/features/tasks/tab-bar-actions.tsx index 1840c314e6..30de9aef84 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tab-bar-actions.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/tab-bar-actions.tsx @@ -49,8 +49,11 @@ export const TabBarActions = observer(function TabBarActions() { showCreateConversationModal({ projectId, taskId, - onSuccess: ({ conversationId }: { conversationId: string }) => - pane.open('conversation', { conversationId, preview: false }), + onSuccess: ({ conversationId, openBrowserTab, openTerminalTab }) => { + pane.open('conversation', { conversationId, preview: false }); + if (openBrowserTab) pane.open('browser', {}); + if (openTerminalTab) void taskView.openNewTerminalTab({ pane }); + }, }) } > 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 a9e2a6ed0d..6d5ed49b73 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 @@ -31,6 +31,7 @@ import { conversationTabProvider } from './conversations/conversation-tab-provid 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'; // ── Generic factory ─────────────────────────────────────────────────────────── @@ -76,6 +77,7 @@ export const taskTabView = createTabView([ conversationTabProvider, fileTabProvider, diffTabProvider, + terminalTabProvider, browserTabProvider, ] as const); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-entry.ts b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-entry.ts new file mode 100644 index 0000000000..a974b01b0c --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-entry.ts @@ -0,0 +1,27 @@ +import { action, makeObservable, observable } from 'mobx'; + +/** + * Observable entry for a task-pane terminal tab. + * The terminal runtime itself is owned by TerminalManagerStore; this entry only + * points at the task-scoped terminal record. + */ +export class TerminalTabEntry { + readonly kind = 'terminal' as const; + readonly tabId: string; + readonly terminalId: string; + isPreview: boolean; + + constructor(terminalId: string, isPreview: boolean, tabId?: string) { + this.tabId = tabId ?? crypto.randomUUID(); + this.terminalId = terminalId; + this.isPreview = isPreview; + makeObservable(this, { + isPreview: observable, + pin: action, + }); + } + + pin(): void { + this.isPreview = false; + } +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-item.tsx b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-item.tsx new file mode 100644 index 0000000000..fe179d4cca --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-item.tsx @@ -0,0 +1,47 @@ +import { Terminal } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import type { TabItemProps } from '@renderer/features/tabs/core/tab-provider'; +import { + GenericTabDragPreview, + GenericTabItem, +} from '@renderer/features/tabs/tab-bar/generic-tab-item'; +import type { TerminalResolvedData } from './terminal-tab-provider'; + +export const TerminalTabItem = observer(function TerminalTabItem({ + tab, + host, + ctx, +}: TabItemProps) { + return ( + } + kindCommands={[ + { + id: 'terminal:rename', + label: 'Rename', + group: 'edit', + shortcut: 'tabRename', + run: () => host.requestRename(tab.tabId), + }, + ]} + renameValue={tab.terminal.data.name} + /> + ); +}); + +export const TerminalTabDragPreview = observer(function TerminalTabDragPreview({ + tab, +}: { + tab: { terminal: TerminalResolvedData['terminal'] }; +}) { + return ( + } + label={tab.terminal.data.name} + /> + ); +}); 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..6c96217240 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/terminals/terminal-tab-provider.tsx @@ -0,0 +1,149 @@ +import { Terminal } from 'lucide-react'; +import { action, reaction } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import type { + ResolveContext, + ResolvedTab, + TabContentProps, + TabProvider, + TabViewContext, +} from '@renderer/features/tabs/core/tab-provider'; +import { EmptyState } from '@renderer/lib/ui/empty-state'; +import type { TabDescriptor } from '@shared/view-state'; +import type { TaskTabContext } from '../stores/task-tab-context'; +import { terminalRegistry } from '../stores/terminal-registry'; +import { workspaceRegistry } from '../stores/workspace-registry'; +import type { TerminalStore } from './terminal-manager'; +import { TerminalPtyContent } from './terminal-pty-content'; +import { TerminalTabEntry } from './terminal-tab-entry'; +import { TerminalTabDragPreview, TerminalTabItem } from './terminal-tab-item'; + +export interface TerminalOpenArgs { + terminalId: string; + /** When true, opens as a preview tab. Terminal command flows create stable tabs. */ + preview?: boolean; +} + +export interface TerminalResolvedData { + terminalId: string; + terminal: TerminalStore; +} + +type TerminalDescriptor = Extract; + +const TerminalTabContent = observer(function TerminalTabContent({ host, ctx }: TabContentProps) { + const taskCtx = ctx as TaskTabContext; + const terminalMgr = terminalRegistry.get(taskCtx.taskId); + const workspace = workspaceRegistry.get(taskCtx.projectId, taskCtx.workspaceId); + const terminalTabs = host.resolvedTabs.filter( + (t): t is ResolvedTab => t.kind === 'terminal' + ); + const activeTerminalTab = terminalTabs.find((tab) => tab.isActive) ?? null; + const activeSession = + activeTerminalTab !== null + ? (terminalMgr?.sessions.get(activeTerminalTab.terminalId) ?? null) + : null; + const allSessionIds = terminalTabs + .map((tab) => terminalMgr?.sessions.get(tab.terminalId)?.sessionId) + .filter((id): id is string => Boolean(id)); + + return ( + } + label="Terminal unavailable" + description="This terminal is no longer available." + /> + } + remoteConnectionId={workspace?.sshConnectionId} + workspaceId={taskCtx.workspaceId} + /> + ); +}); + +export const terminalTabProvider: TabProvider< + 'terminal', + TerminalTabEntry, + TerminalResolvedData, + TerminalDescriptor, + TerminalOpenArgs +> = { + kind: 'terminal', + + resolve(entry: TerminalTabEntry, ctx: ResolveContext): TerminalResolvedData | null { + const terminal = terminalRegistry + .get((ctx as unknown as TaskTabContext).taskId) + ?.terminals.get(entry.terminalId); + if (!terminal) return null; + return { terminalId: entry.terminalId, terminal }; + }, + + serialize(entry: TerminalTabEntry): TerminalDescriptor { + return { + kind: 'terminal', + tabId: entry.tabId, + terminalId: entry.terminalId, + isPreview: entry.isPreview, + }; + }, + + deserialize(data: TerminalDescriptor, _ctx: TabViewContext): TerminalTabEntry { + return new TerminalTabEntry(data.terminalId, data.isPreview, data.tabId); + }, + + TabItem: TerminalTabItem, + DragPreview: TerminalTabDragPreview, + Content: TerminalTabContent, + + title(tab: ResolvedTab): string { + return tab.terminal.data.name; + }, + + open(args: TerminalOpenArgs, host, _ctx): void { + const existing = host.findEntry( + (e): e is TerminalTabEntry => + (e as TerminalTabEntry).kind === 'terminal' && + (e as TerminalTabEntry).terminalId === args.terminalId + ); + + if (existing) { + existing.isPreview = false; + host.setActiveTab(existing.tabId); + return; + } + + host.attachEntry(new TerminalTabEntry(args.terminalId, Boolean(args.preview)), { + activate: true, + }); + }, + + mount(host, ctx): () => void { + const taskCtx = ctx as TaskTabContext; + return reaction( + () => Array.from(terminalRegistry.get(taskCtx.taskId)?.terminals.keys() ?? []), + action((ids) => { + const idSet = new Set(ids); + while (true) { + const stale = host.findEntry( + (e): e is TerminalTabEntry => + (e as TerminalTabEntry).kind === 'terminal' && + !idSet.has((e as TerminalTabEntry).terminalId) + ); + if (!stale) break; + host.closeTab(stale.tabId); + } + }) + ); + }, + + rename(entry: TerminalTabEntry, name: string, ctx: TabViewContext): void { + void terminalRegistry + .get((ctx as TaskTabContext).taskId) + ?.renameTerminal(entry.terminalId, name); + }, +}; 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 c4854d7e1c..5db944ebca 100644 --- a/apps/emdash-desktop/src/shared/view-state.ts +++ b/apps/emdash-desktop/src/shared/view-state.ts @@ -9,6 +9,7 @@ export type TabViewSnapshot = { export type TabDescriptor = | { kind: 'conversation'; tabId: string; conversationId: string; isPreview: boolean } | { kind: 'file'; tabId: string; path: string; isPreview: boolean; isExternal?: boolean } + | { kind: 'terminal'; tabId: string; terminalId: string; isPreview: boolean } | { kind: 'browser'; tabId: string;