Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div ref={setContentRef} className="relative h-full">
{isOverContent && (
<div className="pointer-events-none absolute inset-0 z-20 bg-foreground/10" />
)}
{emptyState}
</div>
);
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -8,6 +8,11 @@ vi.mock('@renderer/lib/ipc', () => ({
rpc: {
app: { readUserFile: vi.fn() },
browser: { unregisterSession: vi.fn() },
ssh: {
getConnections: vi.fn(async () => []),
getConnectionState: vi.fn(async () => ({})),
getHealthStates: vi.fn(async () => ({})),
},
},
}));

Expand Down Expand Up @@ -61,7 +66,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 = {
Expand All @@ -76,6 +86,47 @@ function createLayout(opts?: { onActiveTabChange?: (tabId: string | undefined) =
return new PaneLayoutStore(taskTabView.registry, testCtx, undefined, opts);
}

class FakeTerminalManagerStore {
terminals = observable.map<string, TerminalStore>();
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();
Expand Down Expand Up @@ -203,6 +254,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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export class PaneLayoutStore<R extends TabRegistry = TabRegistry> {
*
* 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.
*/
Expand All @@ -226,6 +229,10 @@ export class PaneLayoutStore<R extends TabRegistry = TabRegistry> {
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}`);
Expand All @@ -245,6 +252,16 @@ export class PaneLayoutStore<R extends TabRegistry = TabRegistry> {
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;
Expand Down
160 changes: 159 additions & 1 deletion apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -87,7 +88,12 @@ vi.mock('@renderer/features/tasks/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';

Expand All @@ -107,13 +113,59 @@ function browserResource(tab: ResolvedTab | undefined): BrowserTabResource | und
return tab?.resource as BrowserTabResource | undefined;
}

class FakeTerminalManagerStore {
terminals = observable.map<string, TerminalStore>();
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();
browserDiagnosticsStore.clear();
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();

Expand Down Expand Up @@ -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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ export interface TaskTabContext extends TabViewContext {
workspacePath?: string;
/** Workspace-scoped prefix for Monaco model URIs: `workspace:<workspaceId>`. */
modelRootPath: string;
/** Current remote connection for terminal/file-drop helpers, when this task is remote. */
getRemoteConnectionId?: () => string | undefined;
}
Loading
Loading