From 1fa72e6c076dc8dc34c1c52945a51b69e41e707a Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:45:42 +0200
Subject: [PATCH 1/5] feat(previews): add stop all controls
---
.../main/core/preview-servers/controller.ts | 3 +
.../preview-servers/preview-server-format.ts | 17 +++-
.../preview-server-menu-items.tsx | 82 +++++++++++++++++
.../preview-servers/preview-server-pill.tsx | 85 ++----------------
.../preview-servers/preview-server-pills.tsx | 9 +-
.../preview-servers/preview-servers-badge.tsx | 89 +++++++++++++++++++
.../tasks/stores/preview-server-store.test.ts | 27 ++++++
.../tasks/stores/preview-server-store.ts | 8 ++
8 files changed, 237 insertions(+), 83 deletions(-)
create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx
create mode 100644 apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
diff --git a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
index ddb58198f0..bc687eaad9 100644
--- a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
+++ b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
@@ -11,5 +11,8 @@ export const previewServersController = createRPCController({
stop: async (id: string) => previewServerService.stop(id),
+ stopForWorkspace: async (args: { projectId: string; workspaceId: string }) =>
+ previewServerService.stopForWorkspace(args.projectId, args.workspaceId),
+
restart: async (id: string) => previewServerService.restart(id),
});
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts
index 6857368330..f5fc4d287c 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-format.ts
@@ -1,4 +1,4 @@
-import type { PreviewServer } from '@shared/core/preview-servers/types';
+import type { PreviewServer, PreviewServerStatus } from '@shared/core/preview-servers/types';
export function formatPreviewUrl(url: string): string {
try {
@@ -32,7 +32,11 @@ export function previewServerStatusLabel(server: PreviewServer): string {
}
export function previewServerStatusClasses(server: PreviewServer): string {
- switch (server.status.kind) {
+ return previewServerStatusKindClasses(server.status.kind);
+}
+
+export function previewServerStatusKindClasses(kind: PreviewServerStatus['kind']): string {
+ switch (kind) {
case 'ready':
return 'bg-background-info text-foreground-info hover:bg-background-info-hover';
case 'starting':
@@ -42,3 +46,12 @@ export function previewServerStatusClasses(server: PreviewServer): string {
return 'bg-background-destructive text-foreground-destructive hover:bg-destructive/20';
}
}
+
+export function previewServersSummaryStatusKind(
+ servers: PreviewServer[]
+): PreviewServerStatus['kind'] {
+ if (servers.some((server) => server.status.kind === 'failed')) return 'failed';
+ if (servers.some((server) => server.status.kind === 'reconnecting')) return 'reconnecting';
+ if (servers.some((server) => server.status.kind === 'starting')) return 'starting';
+ return 'ready';
+}
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx
new file mode 100644
index 0000000000..b6010ad87c
--- /dev/null
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-menu-items.tsx
@@ -0,0 +1,82 @@
+import { Clipboard, ExternalLink, Globe, RefreshCcw, Square } from 'lucide-react';
+import { observer } from 'mobx-react-lite';
+import {
+ usePreviewServers,
+ useWorkspaceViewModel,
+} from '@renderer/features/tasks/task-view-context';
+import { rpc } from '@renderer/lib/ipc';
+import { DropdownMenuItem, DropdownMenuSeparator } from '@renderer/lib/ui/dropdown-menu';
+import { MicroLabel } from '@renderer/lib/ui/label';
+import type { PreviewServer } from '@shared/core/preview-servers/types';
+import { previewServerUrl } from '@shared/core/preview-servers/types';
+
+export const PreviewServerMenuItems = observer(function PreviewServerMenuItems({
+ server,
+}: {
+ server: PreviewServer;
+}) {
+ const previews = usePreviewServers();
+ const taskView = useWorkspaceViewModel();
+ const url = previewServerUrl(server);
+ const hasUrl = url !== null;
+ const canOpen = server.status.kind === 'ready' && hasUrl;
+
+ return (
+ <>
+
+
Preview
+
+ {url ?? 'No local URL'}
+
+ {server.kind === 'forwarded' ? (
+
+ {server.localPort === undefined
+ ? `Remote ${server.remotePort}`
+ : `Remote ${server.remotePort} to local ${server.localPort}`}
+
+ ) : null}
+ {server.status.kind === 'failed' ? (
+
{server.status.message}
+ ) : null}
+
+
+ {
+ if (canOpen && url) {
+ taskView.paneLayout.open('browser', { initialUrl: url });
+ taskView.setFocusedRegion('main');
+ }
+ }}
+ >
+
+ Open in Emdash Browser
+
+ canOpen && url && void rpc.app.openExternal(url)}
+ >
+
+ Open in System Browser
+
+ url && void rpc.app.clipboardWriteText(url)}
+ >
+
+ Copy URL
+
+
+ {server.kind === 'forwarded' ? (
+ void previews.restart(server.id)}>
+
+ Restart Forward
+
+ ) : null}
+ void previews.stop(server.id)}>
+
+ Stop
+
+ >
+ );
+});
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx
index 45422abeea..12629e1352 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pill.tsx
@@ -1,26 +1,10 @@
-import {
- ChevronDown,
- Clipboard,
- ExternalLink,
- Globe,
- Loader2,
- RefreshCcw,
- Square,
-} from 'lucide-react';
+import { ChevronDown, Globe, Loader2 } from 'lucide-react';
import { observer } from 'mobx-react-lite';
-import {
- usePreviewServers,
- useWorkspaceViewModel,
-} from '@renderer/features/tasks/task-view-context';
-import { rpc } from '@renderer/lib/ipc';
import {
DropdownMenu,
DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@renderer/lib/ui/dropdown-menu';
-import { MicroLabel } from '@renderer/lib/ui/label';
import { cn } from '@renderer/utils/utils';
import type { PreviewServer } from '@shared/core/preview-servers/types';
import { previewServerUrl } from '@shared/core/preview-servers/types';
@@ -29,20 +13,18 @@ import {
previewServerStatusClasses,
previewServerStatusLabel,
} from './preview-server-format';
+import { PreviewServerMenuItems } from './preview-server-menu-items';
export const PreviewServerPill = observer(function PreviewServerPill({
server,
}: {
server: PreviewServer;
}) {
- const previews = usePreviewServers();
- const taskView = useWorkspaceViewModel();
const url = previewServerUrl(server);
- const hasUrl = url !== null;
- const canOpen = server.status.kind === 'ready' && hasUrl;
- const title = hasUrl
- ? `${previewServerStatusLabel(server)} at ${url}`
- : `${previewServerStatusLabel(server)} for ${formatPreviewServerLabel(server)}`;
+ const title =
+ url !== null
+ ? `${previewServerStatusLabel(server)} at ${url}`
+ : `${previewServerStatusLabel(server)} for ${formatPreviewServerLabel(server)}`;
return (
@@ -68,60 +50,7 @@ export const PreviewServerPill = observer(function PreviewServerPill({
-
-
Preview
-
- {url ?? 'No local URL'}
-
- {server.kind === 'forwarded' ? (
-
- {server.localPort === undefined
- ? `Remote ${server.remotePort}`
- : `Remote ${server.remotePort} to local ${server.localPort}`}
-
- ) : null}
- {server.status.kind === 'failed' ? (
-
{server.status.message}
- ) : null}
-
-
- {
- if (canOpen && url) {
- taskView.paneLayout.open('browser', { initialUrl: url });
- taskView.setFocusedRegion('main');
- }
- }}
- >
-
- Open in Emdash Browser
-
- canOpen && url && void rpc.app.openExternal(url)}
- >
-
- Open in System Browser
-
- url && void rpc.app.clipboardWriteText(url)}
- >
-
- Copy URL
-
-
- {server.kind === 'forwarded' ? (
- void previews.restart(server.id)}>
-
- Restart Forward
-
- ) : null}
- void previews.stop(server.id)}>
-
- Stop
-
+
);
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx
index 3e977031bb..58a7cfbb7e 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-server-pills.tsx
@@ -2,6 +2,7 @@ import { observer } from 'mobx-react-lite';
import { usePreviewServers, useWorkspace } from '@renderer/features/tasks/task-view-context';
import { ManualForwardButton } from './manual-forward-button';
import { PreviewServerPill } from './preview-server-pill';
+import { PreviewServersBadge } from './preview-servers-badge';
export const PreviewServerPills = observer(function PreviewServerPills() {
const previews = usePreviewServers();
@@ -13,9 +14,11 @@ export const PreviewServerPills = observer(function PreviewServerPills() {
return (
<>
- {servers.map((server) => (
-
- ))}
+ {servers.length > 1 ? (
+
+ ) : (
+ servers.map((server) => )
+ )}
{isRemoteWorkspace ? : null}
>
);
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
new file mode 100644
index 0000000000..8474b6446f
--- /dev/null
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
@@ -0,0 +1,89 @@
+import { ChevronDown, Globe, Loader2, Square } from 'lucide-react';
+import { observer } from 'mobx-react-lite';
+import { usePreviewServers } from '@renderer/features/tasks/task-view-context';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from '@renderer/lib/ui/dropdown-menu';
+import { MicroLabel } from '@renderer/lib/ui/label';
+import { cn } from '@renderer/utils/utils';
+import type { PreviewServer } from '@shared/core/preview-servers/types';
+import {
+ formatPreviewServerLabel,
+ previewServersSummaryStatusKind,
+ previewServerStatusKindClasses,
+} from './preview-server-format';
+import { PreviewServerMenuItems } from './preview-server-menu-items';
+
+export const PreviewServersBadge = observer(function PreviewServersBadge({
+ servers,
+}: {
+ servers: PreviewServer[];
+}) {
+ const previews = usePreviewServers();
+ const summaryKind = previewServersSummaryStatusKind(servers);
+ const isBusy = summaryKind === 'starting' || summaryKind === 'reconnecting';
+
+ return (
+
+
+ }
+ >
+ {isBusy ? (
+
+ ) : (
+
+ )}
+ {servers.length} servers
+
+
+
+
+ Preview Servers
+
+
+ {servers.map((server) => (
+
+
+ {server.status.kind === 'starting' || server.status.kind === 'reconnecting' ? (
+
+ ) : (
+
+ )}
+ {formatPreviewServerLabel(server)}
+
+
+
+
+
+ ))}
+
+ void previews.stopAll()}>
+
+ Stop All Servers
+
+
+
+ );
+});
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts
index 78376875d2..dc55469435 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts
+++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.test.ts
@@ -14,6 +14,7 @@ const rpcMocks = vi.hoisted(() => ({
forwardManual: vi.fn(),
restart: vi.fn(),
stop: vi.fn(),
+ stopForWorkspace: vi.fn(),
}));
vi.mock('@renderer/lib/ipc', () => ({
@@ -70,6 +71,7 @@ describe('PreviewServerStore', () => {
rpcMocks.forwardManual.mockReset();
rpcMocks.restart.mockReset();
rpcMocks.stop.mockReset();
+ rpcMocks.stopForWorkspace.mockReset();
});
it('loads preview servers for a workspace and exposes addressable URLs', async () => {
@@ -181,6 +183,31 @@ describe('PreviewServerStore', () => {
store.dispose();
});
+ it('stops all servers for the workspace and clears local state', async () => {
+ rpcMocks.listForWorkspace.mockResolvedValueOnce([
+ directServer(),
+ forwardedServer({ id: 'forwarded-1' }),
+ ]);
+ rpcMocks.stopForWorkspace.mockResolvedValueOnce(undefined);
+
+ const store = new PreviewServerStore({
+ projectId: 'project-1',
+ workspaceId: 'workspace-1',
+ });
+ await store.serversResource.load();
+ expect(store.servers).toHaveLength(2);
+
+ await store.stopAll();
+
+ expect(rpcMocks.stopForWorkspace).toHaveBeenCalledWith({
+ projectId: 'project-1',
+ workspaceId: 'workspace-1',
+ });
+ expect(store.servers).toEqual([]);
+
+ store.dispose();
+ });
+
it('does not upsert manual forwards that return an error result', async () => {
rpcMocks.forwardManual.mockResolvedValueOnce({
success: false,
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts
index 8f55201f23..d18a6784f9 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts
+++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/preview-server-store.ts
@@ -114,6 +114,14 @@ export class PreviewServerStore implements IDisposable {
this.serversResource.setValue(next);
}
+ async stopAll(): Promise {
+ await rpc.previewServers.stopForWorkspace({
+ projectId: this.projectId,
+ workspaceId: this.workspaceId,
+ });
+ this.serversResource.setValue(new Map());
+ }
+
dispose(): void {
this.serversResource.dispose();
}
From 8ab159d720372c3e82e78a87f05239ec0b56f75a Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Sun, 5 Jul 2026 11:51:59 +0200
Subject: [PATCH 2/5] feat(preview): add global stop APIs
---
.../main/core/preview-servers/controller.ts | 4 +++
.../preview-server-service.test.ts | 36 +++++++++++++++++++
.../preview-servers/preview-server-service.ts | 9 +++++
3 files changed, 49 insertions(+)
diff --git a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
index bc687eaad9..6fe1969d76 100644
--- a/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
+++ b/apps/emdash-desktop/src/main/core/preview-servers/controller.ts
@@ -6,6 +6,8 @@ export const previewServersController = createRPCController({
listForWorkspace: async (args: { projectId: string; workspaceId: string }) =>
previewServerService.listForWorkspace(args),
+ listAll: async () => previewServerService.listAll(),
+
forwardManual: async (request: ManualPreviewServerRequest) =>
previewServerService.forwardManual(request),
@@ -14,5 +16,7 @@ export const previewServersController = createRPCController({
stopForWorkspace: async (args: { projectId: string; workspaceId: string }) =>
previewServerService.stopForWorkspace(args.projectId, args.workspaceId),
+ stopAll: async () => previewServerService.stopAll(),
+
restart: async (id: string) => previewServerService.restart(id),
});
diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts
index 281534bbd6..23704f02cc 100644
--- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts
+++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.test.ts
@@ -590,4 +590,40 @@ describe('PreviewServerService', () => {
'preview:ssh:auto:project-1:workspace-1:connection-1:5173',
]);
});
+
+ it('lists and stops previews across all projects and workspaces', async () => {
+ const context = createService();
+ const local = await context.service.registerDetectedTarget({
+ projectId: 'project-1',
+ workspaceId: 'workspace-1',
+ transport: 'local',
+ source: { kind: 'terminal-output', terminalId: 'terminal-1' },
+ protocol: 'http:',
+ host: 'localhost',
+ port: 5173,
+ urlPath: '/',
+ });
+ const forwarded = await context.service.registerDetectedTarget({
+ projectId: 'project-2',
+ workspaceId: 'workspace-2',
+ connectionId: 'connection-1',
+ transport: 'ssh',
+ proxy: fakeProxy(),
+ source: { kind: 'terminal-output', terminalId: 'terminal-2' },
+ protocol: 'http:',
+ port: 5174,
+ urlPath: '/',
+ });
+
+ expect(context.service.listAll()).toEqual([local, forwarded]);
+
+ await context.service.stopAll();
+
+ expect(context.service.listAll()).toEqual([]);
+ expect(context.events).toContainEqual({ type: 'remove', id: local.id });
+ expect(context.events).toContainEqual({ type: 'remove', id: forwarded.id });
+ expect(context.closedTunnelIds).toEqual([
+ 'preview:ssh:auto:project-2:workspace-2:connection-1:5174',
+ ]);
+ });
});
diff --git a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts
index 25e8359d67..23af920e18 100644
--- a/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts
+++ b/apps/emdash-desktop/src/main/core/preview-servers/preview-server-service.ts
@@ -182,6 +182,10 @@ export class PreviewServerService {
);
}
+ listAll(): PreviewServer[] {
+ return Array.from(this.servers.values());
+ }
+
async handleTerminalSourceClosed(input: TerminalSourceClosedInput): Promise {
if (input.transport === 'local') {
await this.stopForTerminal(input);
@@ -370,6 +374,11 @@ export class PreviewServerService {
await Promise.all(ids.map((id) => this.stop(id)));
}
+ async stopAll(): Promise {
+ const ids = Array.from(this.servers.keys());
+ await Promise.all(ids.map((id) => this.stop(id)));
+ }
+
private registerLocalTarget(
target: Extract
): DirectPreviewServer {
From b768a83270adc88fcc08c53a7aac05c6a874b4a7 Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Sun, 5 Jul 2026 11:52:08 +0200
Subject: [PATCH 3/5] feat(preview): add global stop controls
---
.../components/PreviewServersSettingsCard.tsx | 225 ++++++++++++++++++
.../settings/components/SettingsPage.tsx | 4 +-
.../src/renderer/lib/commands/app-commands.ts | 24 ++
apps/emdash-desktop/src/shared/commands.ts | 8 +
4 files changed, 260 insertions(+), 1 deletion(-)
create mode 100644 apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
new file mode 100644
index 0000000000..5df3bce3ab
--- /dev/null
+++ b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
@@ -0,0 +1,225 @@
+import { ExternalLinkIcon, GlobeIcon, SquareIcon } from 'lucide-react';
+import { observer } from 'mobx-react-lite';
+import type { ReactNode } from 'react';
+import { useEffect, useState } from 'react';
+import {
+ getProjectStore,
+ projectDisplayName,
+} from '@renderer/features/projects/stores/project-selectors';
+import {
+ formatPreviewServerLabel,
+ previewServerStatusLabel,
+} from '@renderer/features/tasks/components/preview-servers/preview-server-format';
+import {
+ asProvisioned,
+ getTaskManagerStore,
+ taskDisplayName,
+} from '@renderer/features/tasks/stores/task-selectors';
+import { events, rpc } from '@renderer/lib/ipc';
+import { Badge } from '@renderer/lib/ui/badge';
+import { Button } from '@renderer/lib/ui/button';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip';
+import { cn } from '@renderer/utils/utils';
+import { previewServerEventChannel } from '@shared/core/preview-servers/events';
+import type { PreviewServer } from '@shared/core/preview-servers/types';
+import { previewServerUrl } from '@shared/core/preview-servers/types';
+
+export const PreviewServersSettingsCard = observer(function PreviewServersSettingsCard() {
+ const [servers, setServers] = useState([]);
+
+ useEffect(() => {
+ let disposed = false;
+ void rpc.previewServers.listAll().then((list) => {
+ if (!disposed) setServers(sortServers(list));
+ });
+ const unsubscribe = events.on(previewServerEventChannel, (event) => {
+ setServers((current) => {
+ const next = new Map(current.map((server) => [server.id, server]));
+ if (event.type === 'upsert') {
+ next.set(event.server.id, event.server);
+ } else {
+ next.delete(event.id);
+ }
+ return sortServers(Array.from(next.values()));
+ });
+ });
+ return () => {
+ disposed = true;
+ unsubscribe();
+ };
+ }, []);
+
+ return (
+
+
+
+
Preview servers
+
+ Detected servers and port forwards across all projects and tasks.
+
+
+ {servers.length > 0 ? (
+
+ ) : null}
+
+
+ {servers.length === 0 ? (
+
+
+
No preview servers running
+
+ Servers detected in task terminals and SSH port forwards show up here.
+
+
+ ) : (
+
+ {servers.map((server) => (
+
+ ))}
+
+ )}
+
+ );
+});
+
+function ServerActionButton({
+ label,
+ children,
+ disabled,
+ className,
+ onClick,
+}: {
+ label: string;
+ children: ReactNode;
+ disabled?: boolean;
+ className?: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+ {children}
+
+ }
+ />
+ {label}
+
+
+ );
+}
+
+function PreviewServerStatusBadge({ server }: { server: PreviewServer }) {
+ const kind = server.status.kind;
+ const isBusy = kind === 'starting' || kind === 'reconnecting';
+
+ return (
+
+
+ {previewServerStatusLabel(server)}
+
+ );
+}
+
+const PreviewServerSettingsRow = observer(function PreviewServerSettingsRow({
+ server,
+}: {
+ server: PreviewServer;
+}) {
+ const url = previewServerUrl(server);
+ const canOpen = server.status.kind === 'ready' && url !== null;
+ const projectName = projectDisplayName(getProjectStore(server.projectId));
+ const taskName = taskNameForWorkspace(server.projectId, server.workspaceId);
+ const usedBy = [projectName, taskName].filter(Boolean).join(' ยท ');
+ const label = formatPreviewServerLabel(server);
+
+ return (
+
+
+
+
+
+
+
+
{url ?? 'No local URL'}
+ {usedBy !== '' ?
Used by: {usedBy}
: null}
+ {server.status.kind === 'failed' ? (
+
{server.status.message}
+ ) : null}
+
+
+
+ {
+ if (canOpen) void rpc.app.openExternal(url);
+ }}
+ >
+
+
+ void rpc.previewServers.stop(server.id)}
+ >
+
+
+
+
+ );
+});
+
+function taskNameForWorkspace(projectId: string, workspaceId: string): string | undefined {
+ const manager = getTaskManagerStore(projectId);
+ if (!manager) return undefined;
+ for (const store of manager.tasks.values()) {
+ if (asProvisioned(store)?.workspaceId === workspaceId) return taskDisplayName(store);
+ }
+ return undefined;
+}
+
+function sortServers(servers: PreviewServer[]): PreviewServer[] {
+ return [...servers].sort((a, b) => {
+ if (a.projectId !== b.projectId) return a.projectId.localeCompare(b.projectId);
+ const aPort = a.kind === 'forwarded' ? a.remotePort : a.port;
+ const bPort = b.kind === 'forwarded' ? b.remotePort : b.port;
+ if (aPort !== bPort) return aPort - bPort;
+ return a.id.localeCompare(b.id);
+ });
+}
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx
index 965e2ce02e..9aca3490e2 100644
--- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx
+++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx
@@ -10,6 +10,7 @@ import IntegrationsCard from './IntegrationsCard';
import InterfaceSettingsCard from './InterfaceSettingsCard';
import KeyboardSettingsCard from './KeyboardSettingsCard';
import NotificationSettingsCard from './NotificationSettingsCard';
+import { PreviewServersSettingsCard } from './PreviewServersSettingsCard';
import RepositorySettingsCard from './RepositorySettingsCard';
import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard';
import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard';
@@ -89,9 +90,10 @@ function ConnectionsSettingsPage() {
+
);
}
diff --git a/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts b/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts
index 242fdb9fe8..9eca775d8d 100644
--- a/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts
+++ b/apps/emdash-desktop/src/renderer/lib/commands/app-commands.ts
@@ -1,5 +1,6 @@
import { applyHistoryEntry } from '@renderer/lib/components/nav-buttons';
import { toast } from '@renderer/lib/hooks/use-toast';
+import { rpc } from '@renderer/lib/ipc';
import { toggleSettingsView } from '@renderer/lib/layout/settings-toggle';
import { showModal } from '@renderer/lib/modal/modal-provider';
import { appState } from '@renderer/lib/stores/app-state';
@@ -90,6 +91,29 @@ function createAppCommandProvider(): CommandProvider {
},
});
+ const stopAllPreviewServersDef = appDef('app.stopAllPreviewServers');
+ commands.push({
+ id: stopAllPreviewServersDef.id,
+ label: stopAllPreviewServersDef.label,
+ description: stopAllPreviewServersDef.description,
+ shortcutKey: stopAllPreviewServersDef.shortcutKey,
+ group: stopAllPreviewServersDef.group,
+ execute() {
+ void rpc.previewServers
+ .stopAll()
+ .then(() => {
+ toast({ title: 'Stopped all preview servers' });
+ })
+ .catch((error: unknown) => {
+ toast({
+ title: 'Failed to stop preview servers',
+ description: String(error),
+ variant: 'destructive',
+ });
+ });
+ },
+ });
+
commands.push({
id: toggleThemeDef.id,
label: toggleThemeDef.label,
diff --git a/apps/emdash-desktop/src/shared/commands.ts b/apps/emdash-desktop/src/shared/commands.ts
index d8a93b1591..1ed03bd601 100644
--- a/apps/emdash-desktop/src/shared/commands.ts
+++ b/apps/emdash-desktop/src/shared/commands.ts
@@ -64,6 +64,14 @@ export const APP_COMMAND_DEFS = defineCommandDefs([
group: 'App',
iconKey: 'message-square-share',
},
+ {
+ id: 'app.stopAllPreviewServers',
+ label: 'Stop All Preview Servers',
+ description: 'Stop preview servers and port forwards across all projects and tasks',
+ scope: 'app',
+ group: 'App',
+ iconKey: 'globe',
+ },
{
id: 'app.toggleTheme',
label: 'Toggle Theme',
From 263cd86a352f8e0309145fc7c6e8d0149d082f46 Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Sun, 5 Jul 2026 12:14:47 +0200
Subject: [PATCH 4/5] fix(preview): confirm stopping all servers
---
.../components/PreviewServersSettingsCard.tsx | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
index 5df3bce3ab..e758308a49 100644
--- a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
+++ b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
@@ -16,6 +16,7 @@ import {
taskDisplayName,
} from '@renderer/features/tasks/stores/task-selectors';
import { events, rpc } from '@renderer/lib/ipc';
+import { useShowModal } from '@renderer/lib/modal/modal-provider';
import { Badge } from '@renderer/lib/ui/badge';
import { Button } from '@renderer/lib/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip';
@@ -26,6 +27,7 @@ import { previewServerUrl } from '@shared/core/preview-servers/types';
export const PreviewServersSettingsCard = observer(function PreviewServersSettingsCard() {
const [servers, setServers] = useState([]);
+ const showConfirm = useShowModal('confirmActionModal');
useEffect(() => {
let disposed = false;
@@ -63,7 +65,18 @@ export const PreviewServersSettingsCard = observer(function PreviewServersSettin
type="button"
variant="ghost"
className="hover:bg-destructive/10 text-foreground-destructive hover:text-foreground-destructive"
- onClick={() => void rpc.previewServers.stopAll()}
+ onClick={() => {
+ const count = servers.length;
+ showConfirm({
+ title: 'Stop preview servers',
+ description: `This will stop ${count === 1 ? 'the running preview server' : `all ${count} running preview servers`}.`,
+ confirmLabel: 'Stop',
+ variant: 'destructive',
+ onSuccess: () => {
+ void rpc.previewServers.stopAll();
+ },
+ });
+ }}
>
Stop all
From 281d4dd137d4eb21c1720e8c62bc1e362338ab6d Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Sun, 5 Jul 2026 12:43:02 +0200
Subject: [PATCH 5/5] fix(preview): confirm stopping workspace servers
---
.../components/PreviewServersSettingsCard.tsx | 10 ++++++-
.../preview-servers/preview-servers-badge.tsx | 26 ++++++++++++++++++-
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
index e758308a49..cbb555be16 100644
--- a/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
+++ b/apps/emdash-desktop/src/renderer/features/settings/components/PreviewServersSettingsCard.tsx
@@ -15,6 +15,7 @@ import {
getTaskManagerStore,
taskDisplayName,
} from '@renderer/features/tasks/stores/task-selectors';
+import { useToast } from '@renderer/lib/hooks/use-toast';
import { events, rpc } from '@renderer/lib/ipc';
import { useShowModal } from '@renderer/lib/modal/modal-provider';
import { Badge } from '@renderer/lib/ui/badge';
@@ -28,6 +29,7 @@ import { previewServerUrl } from '@shared/core/preview-servers/types';
export const PreviewServersSettingsCard = observer(function PreviewServersSettingsCard() {
const [servers, setServers] = useState([]);
const showConfirm = useShowModal('confirmActionModal');
+ const { toast } = useToast();
useEffect(() => {
let disposed = false;
@@ -73,7 +75,13 @@ export const PreviewServersSettingsCard = observer(function PreviewServersSettin
confirmLabel: 'Stop',
variant: 'destructive',
onSuccess: () => {
- void rpc.previewServers.stopAll();
+ void rpc.previewServers.stopAll().catch((error: unknown) => {
+ toast({
+ title: 'Failed to stop preview servers',
+ description: String(error),
+ variant: 'destructive',
+ });
+ });
},
});
}}
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
index 8474b6446f..66569c238b 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/preview-servers/preview-servers-badge.tsx
@@ -1,6 +1,8 @@
import { ChevronDown, Globe, Loader2, Square } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import { usePreviewServers } from '@renderer/features/tasks/task-view-context';
+import { useToast } from '@renderer/lib/hooks/use-toast';
+import { useShowModal } from '@renderer/lib/modal/modal-provider';
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,6 +29,8 @@ export const PreviewServersBadge = observer(function PreviewServersBadge({
servers: PreviewServer[];
}) {
const previews = usePreviewServers();
+ const showConfirm = useShowModal('confirmActionModal');
+ const { toast } = useToast();
const summaryKind = previewServersSummaryStatusKind(servers);
const isBusy = summaryKind === 'starting' || summaryKind === 'reconnecting';
@@ -79,7 +83,27 @@ export const PreviewServersBadge = observer(function PreviewServersBadge({
))}
- void previews.stopAll()}>
+ {
+ const count = servers.length;
+ showConfirm({
+ title: 'Stop workspace preview servers?',
+ description: `This will stop ${count === 1 ? 'the running preview server' : `all ${count} running preview servers`} in this workspace.`,
+ confirmLabel: 'Stop All',
+ variant: 'destructive',
+ onSuccess: () => {
+ void previews.stopAll().catch((error: unknown) => {
+ toast({
+ title: 'Failed to stop preview servers',
+ description: String(error),
+ variant: 'destructive',
+ });
+ });
+ },
+ });
+ }}
+ >
Stop All Servers