feat(app): redirect to next workspace after archiving

This commit is contained in:
Mohamed Boudra
2026-03-20 13:08:28 +07:00
parent 70fb5f5bfd
commit 77fb74c188
5 changed files with 427 additions and 15 deletions

192
docs/ELECTRON_MIGRATION.md Normal file
View File

@@ -0,0 +1,192 @@
# Electron Migration
## Why switch
Paseo Desktop is a webview that loads an Expo web build plus a Node daemon that manages AI coding agents. Tauri is designed for apps that need a thin native wrapper — it assumes your backend logic lives in Rust. Paseo's backend is entirely Node/TypeScript. The result is that we're reimplementing Electron's built-in Node bundling inside Tauri's resource system.
### Pain points with Tauri today
**Managed runtime bundling is the #1 source of complexity.** `build-managed-runtime.mjs` (590 lines) downloads a Node distribution, packs three workspace tarballs, installs them with the bundled npm, separately installs sherpa-onnx platform packages, prunes ONNX/node-pty/ripgrep binaries per platform, and writes a runtime manifest. This exists solely because Tauri doesn't ship Node.
**Linux is fragile.** We wrote a custom AppImage Wayland patch (`maybePatchLinuxAddonRunpath` in sherpa-onnx-node-loader, plus AppImage patching scripts) because Tauri's Linux webview has Wayland issues. Electron handles Wayland/X11 transparently via Chromium's `--ozone-platform-hint=auto`.
**Native notification callbacks required Objective-C.** We wrote `paseo_notifications.m` (UNUserNotificationCenter delegate bridge) and compiled it into the Tauri build just to get notification click callbacks on macOS. Linux and Windows notification callbacks are unimplemented. Electron's `Notification` API provides click/reply/action callbacks cross-platform with zero native code.
**Windows is undertested.** The Rust code has `#[cfg(windows)]` blocks for named pipes, PowerShell `Expand-Archive`, `CREATE_NO_WINDOW` flags, NSIS installer hooks — all written speculatively with limited testing. Electron's cross-platform abstractions cover all of this.
**IPC is boilerplate-heavy.** Every desktop command requires a Rust `#[tauri::command]` function, a `spawn_blocking` wrapper, serde types, registration in `generate_handler![]`, and a TypeScript `invokeDesktopCommand()` call. There are 22 commands today. In Electron, the main process is Node — these become direct function calls or simple `ipcMain.handle()` handlers in the same language as the rest of the codebase.
**Tauri plugin gaps.** Features like tray menus with dynamic updates, global shortcuts, deep links, system idle detection, auto-launch on login, and rich clipboard access all require Rust plugins (often per-platform). Electron provides these as JS API calls.
### What we gain
- **Delete `build-managed-runtime.mjs`** — Node comes with Electron
- **Delete the Wayland/AppImage patching** — Chromium handles it
- **Delete `paseo_notifications.m`** — `Notification` API works cross-platform
- **Delete `runtime_manager.rs`** (1080 lines) — daemon becomes a `child_process.fork()` call
- **Delete all Rust code** — `lib.rs` (683 lines), `desktop_notifications.rs`, `build.rs`, Cargo.toml dependencies
- **Delete sherpa rpath patching** — `electron-rebuild` handles native addon ABI matching
- **One language** for the entire desktop app (TypeScript)
- **Windows support** works out of the box
- **Mature auto-update** via electron-updater (GitHub Releases, S3, etc.)
- **Mature code signing** for macOS and Windows without custom scripts
### What we lose
- **App size increases** from ~20 MB to ~150-200 MB (ships Chromium)
- **Memory footprint increases** (Chromium process model)
- **Tauri's Rust escape hatch** — though we only use Rust because Tauri requires it, not because we need it
### ONNX / Sherpa native addons
`sherpa-onnx-node` and `onnxruntime-node` ship prebuilt `.node` addons per platform. In Electron, `@electron/rebuild` rebuilds or verifies native addons against Electron's Node ABI. This is standard practice — packages like `better-sqlite3`, `sharp`, and `node-pty` all work this way. The manual sherpa platform package installation and rpath patching goes away entirely.
## What can be collapsed
### Build infrastructure (delete entirely)
| File | Lines | Purpose |
|---|---|---|
| `packages/desktop/scripts/build-managed-runtime.mjs` | 590 | Bundle Node + workspaces into Tauri sidecar |
| `packages/desktop/scripts/validate-managed-runtime.mjs` | ~100 | Validate bundled runtime integrity |
| `packages/desktop/scripts/sign-managed-runtime-macos.mjs` | ~100 | Code-sign macOS sidecar bundles |
| `packages/desktop/scripts/managed-daemon-smoke.mjs` | ~50 | Smoke test for managed daemon |
Electron replacement: None. Node is part of Electron. Workspace code loads directly via `require`/`import`.
### Rust backend (delete entirely)
| File | Lines | Purpose |
|---|---|---|
| `src-tauri/src/lib.rs` | 683 | App setup, IPC commands, menu, zoom |
| `src-tauri/src/runtime_manager.rs` | 1080 | Managed runtime/daemon lifecycle |
| `src-tauri/src/desktop_notifications.rs` | ~80 | Notification bridge |
| `src-tauri/src/main.rs` | ~10 | Entry point |
| `src-tauri/build.rs` | ~30 | Build script (compiles ObjC) |
| `src-tauri/Cargo.toml` | ~30 | Rust dependencies |
| `src-tauri/tauri.conf.json` | ~100 | Tauri configuration |
Electron replacement: A single `main.ts` (~200-300 lines) covering window creation, menu, IPC handlers, and daemon lifecycle.
### Native platform code (delete entirely)
| File | Purpose |
|---|---|
| `src-tauri/macos/paseo_notifications.h` | ObjC notification bridge header |
| `src-tauri/macos/paseo_notifications.m` | ObjC UNUserNotificationCenter delegate |
| `src-tauri/Info.plist` | macOS bundle config |
| `src-tauri/Entitlements.plist` | macOS entitlements |
| `src-tauri/installer-hooks.nsh` | Windows NSIS post-install hooks |
Electron replacement: `Notification` API (2-3 lines per notification). macOS entitlements handled by `electron-builder` config.
### Daemon management (massive simplification)
**Tauri today:** Bundled Node binary → spawn as child process → pipe stdout/stderr → poll `daemon status --json` in a loop → parse JSON → return via Tauri IPC.
**Electron:** `child_process.fork('./packages/server/dist/scripts/daemon-runner.js')` — same Node runtime, same ABI, direct IPC via Node's built-in channel.
### Local daemon transport (simplification)
**Tauri today:** `runtime_manager.rs` implements a full WebSocket client in Rust (tokio-tungstenite) with session management, base64 binary encoding over Tauri events, and manual read/write task spawning. ~300 lines of Rust.
**Electron:** The renderer can connect to Unix sockets / named pipes directly via the main process using Node's `ws` package (already a dependency). Or use `ipcMain` to proxy — still TypeScript, still ~50 lines.
### Attachment file management (simplification)
**Tauri today:** 6 Rust commands (`write_attachment_base64`, `copy_attachment_file`, `read_file_base64`, `delete_attachment_file`, `garbage_collect_attachment_files`) with path validation, base64 encoding/decoding, and directory traversal protection. ~200 lines of Rust.
**Electron:** Same logic in TypeScript using `fs` and `app.getPath('userData')`. ~50 lines.
### Window management (simplification)
**Tauri today:** Custom `tauri-window.ts` with `plugin:window|start_dragging`, `plugin:window|toggle_maximize`, `plugin:window|is_fullscreen` invocations.
**Electron:** `BrowserWindow` API — `win.maximize()`, `win.isFullScreen()`, etc. Frameless window dragging via CSS `-webkit-app-region: drag`.
### App updates (simplification)
**Tauri today:** Rust `check_app_update` / `install_app_update` commands using `tauri_plugin_updater`. Custom update check UI in the renderer.
**Electron:** `electron-updater` with `autoUpdater.checkForUpdatesAndNotify()`. Built-in download progress, restart handling, differential updates.
## Migration plan
### Guiding principle
Electron is mature. We are not doing anything new. Every feature we need has official documentation and established patterns. Never use workarounds — if something doesn't work, find the official way.
### Phase 1: Scaffold in parallel
Create `packages/desktop-electron` alongside `packages/desktop` (Tauri). Both coexist.
- Initialize with `electron-forge` or `electron-builder`
- Configure `BrowserWindow` to load the Expo web build (same as Tauri's webview)
- Set up `electron-builder` for macOS, Linux, Windows packaging
- Get a window rendering the Expo web app with no desktop features
### Phase 2: Introduce `isElectron` branching
Add platform detection infrastructure in `packages/app`:
- Add `isElectron()` / `isElectronMac()` alongside existing `isTauri()` / `isTauriMac()`
- Introduce a `isDesktop()` helper that returns `isTauri() || isElectron()`
- Create `packages/app/src/desktop/electron/invoke-desktop-command.ts` — Electron's equivalent of the Tauri invoke bridge, using `ipcRenderer.invoke()`
- Update `invokeDesktopCommand()` to route to the correct backend based on platform
### Phase 3: Port features incrementally
Work through each Tauri feature, adding the Electron variant. Never break Tauri while doing this.
**Tier 1 — Core (must work for the app to be usable):**
1. **Daemon lifecycle**`child_process.fork()` the daemon runner from the main process. Expose `start/stop/restart/status` via `ipcMain.handle()`.
2. **Local daemon transport** — WebSocket client in the main process using `ws`, proxied to renderer via IPC.
3. **Window management** — Frameless window with overlay title bar, drag regions, zoom controls, fullscreen.
4. **Menu**`Menu.buildFromTemplate()` with zoom controls and macOS app menu.
**Tier 2 — Desktop features:**
5. **Notifications**`new Notification()` with click handlers.
6. **App updates**`electron-updater` with GitHub Releases.
7. **File dialogs**`dialog.showOpenDialog()` for directory picker.
8. **Confirm dialogs**`dialog.showMessageBox()`.
9. **Open external URLs**`shell.openExternal()`.
10. **Attachment file management**`fs` operations in main process via IPC.
**Tier 3 — Polish:**
11. **CLI symlink instructions** — Generate platform-specific instructions in Node.
12. **Logging**`electron-log` or simple file-based logging.
13. **Single instance**`app.requestSingleInstanceLock()`.
14. **Console forwarding** — No bridge needed, main process is already Node.
15. **NSIS installer hooks**`electron-builder` NSIS config for CLI symlink.
### Phase 4: Feature parity validation
- Test all features on macOS, Linux (X11 + Wayland), Windows
- Verify sherpa-onnx-node loads correctly via `@electron/rebuild`
- Verify daemon start/stop/restart lifecycle
- Verify auto-update flow
- Verify notification callbacks on all platforms
- Smoke test CLI passthrough mode
### Phase 5: Cut over
- Update CI to build Electron packages instead of Tauri
- Remove `packages/desktop` (Tauri)
- Remove all `isTauri()` branching, keep only `isElectron()` (rename to `isDesktop()`)
- Delete `src-tauri/` and all Rust code
- Delete `build-managed-runtime.mjs` and related scripts
- Delete sherpa rpath patching code
## Estimated code impact
| Category | Lines removed | Lines added |
|---|---|---|
| Rust backend | ~1,900 | 0 |
| ObjC native code | ~150 | 0 |
| Build scripts | ~850 | 0 |
| Tauri TypeScript bridge | ~300 | ~150 (Electron IPC) |
| Electron main process | 0 | ~300 |
| **Net** | **~3,200 removed** | **~450 added** |

View File

@@ -1,12 +1,11 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from '@playwright/test'
const baseURL = const baseURL =
process.env.E2E_BASE_URL ?? process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`
`http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: './e2e',
globalSetup: "./e2e/global-setup.ts", globalSetup: './e2e/global-setup.ts',
timeout: 60_000, timeout: 60_000,
expect: { expect: {
timeout: 10_000, timeout: 10_000,
@@ -14,17 +13,17 @@ export default defineConfig({
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
reporter: [["list"]], reporter: [['list']],
use: { use: {
baseURL, baseURL,
trace: "retain-on-failure", trace: 'retain-on-failure',
screenshot: "only-on-failure", screenshot: 'only-on-failure',
video: "retain-on-failure", video: 'retain-on-failure',
}, },
projects: [ projects: [
{ {
name: "Desktop Safari", name: 'Desktop Safari',
use: { ...devices["Desktop Safari"] }, use: { ...devices['Desktop Safari'] },
}, },
], ],
}); })

View File

@@ -73,6 +73,7 @@ import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model'
import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store'
import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store'
import { createNameId } from 'mnemonic-id' import { createNameId } from 'mnemonic-id'
import { buildWorkspaceArchiveRedirectRoute } from '@/utils/workspace-archive-navigation'
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -85,6 +86,7 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) =>
workspace.workspaceKey workspace.workspaceKey
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey
const EMPTY_WORKSPACES = new Map()
interface SidebarWorkspaceListProps { interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[] projects: SidebarProjectEntry[]
@@ -789,7 +791,11 @@ function WorkspaceRowWithMenu({
isCreating?: boolean isCreating?: boolean
}) { }) {
const toast = useToast() const toast = useToast()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES
)
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
const archiveStatus = useCheckoutGitActionsStore((state) => const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ state.getStatus({
@@ -800,6 +806,27 @@ function WorkspaceRowWithMenu({
) )
const isWorktree = workspace.workspaceKind === 'worktree' const isWorktree = workspace.workspaceKind === 'worktree'
const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any
)
}, [
activeWorkspaceSelection,
sessionWorkspaces,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
if (isArchiving) { if (isArchiving) {
@@ -823,12 +850,22 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId, serverId: workspace.serverId,
cwd: workspace.workspaceId, cwd: workspace.workspaceId,
worktreePath: workspace.workspaceId, worktreePath: workspace.workspaceId,
}).then(() => {
redirectAfterArchive()
}).catch((error) => { }).catch((error) => {
const message = error instanceof Error ? error.message : 'Failed to archive worktree' const message = error instanceof Error ? error.message : 'Failed to archive worktree'
toast.error(message) toast.error(message)
}) })
})() })()
}, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId]) }, [
archiveWorktree,
isArchiving,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
@@ -859,13 +896,21 @@ function WorkspaceRowWithMenu({
if (payload.error) { if (payload.error) {
throw new Error(payload.error) throw new Error(payload.error)
} }
redirectAfterArchive()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
} finally { } finally {
setIsArchivingWorkspace(false) setIsArchivingWorkspace(false)
} }
})() })()
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) }, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
const handleCopyPath = useCallback(() => { const handleCopyPath = useCallback(() => {
void Clipboard.setStringAsync(workspace.workspaceId) void Clipboard.setStringAsync(workspace.workspaceId)
@@ -927,7 +972,32 @@ function NonGitProjectRowWithMenuContent({
}) { }) {
const toast = useToast() const toast = useToast()
const contextMenu = useContextMenu() const contextMenu = useContextMenu()
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES
)
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false)
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any
)
}, [
activeWorkspaceSelection,
sessionWorkspaces,
workspace.serverId,
workspace.workspaceId,
])
const handleArchiveWorkspace = useCallback(() => { const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) { if (isArchivingWorkspace) {
@@ -958,13 +1028,21 @@ function NonGitProjectRowWithMenuContent({
if (payload.error) { if (payload.error) {
throw new Error(payload.error) throw new Error(payload.error)
} }
redirectAfterArchive()
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') toast.error(error instanceof Error ? error.message : 'Failed to hide workspace')
} finally { } finally {
setIsArchivingWorkspace(false) setIsArchivingWorkspace(false)
} }
})() })()
}, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) }, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
])
return ( return (
<> <>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceArchiveRedirectRoute,
resolveWorkspaceArchiveRedirectWorkspaceId,
} from "@/utils/workspace-archive-navigation";
import type { WorkspaceDescriptor } from "@/stores/session-store";
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
};
}
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
it("redirects an archived worktree to the visible local checkout for the same project", () => {
const workspaces = [
workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }),
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
];
expect(
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
).toBe("/repo");
});
it("falls back to the project root path when the root checkout is not in the visible workspace list", () => {
const workspaces = [
workspace({
id: "/repo/.paseo/worktrees/feature",
name: "feature",
projectRootPath: "/repo",
}),
];
expect(
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
).toBe("/repo");
});
it("falls back to the host root route when no alternate workspace target exists", () => {
const workspaces = [
workspace({
id: "/notes",
projectId: "notes",
projectRootPath: "/notes",
projectKind: "non_git",
workspaceKind: "directory",
}),
];
expect(
buildWorkspaceArchiveRedirectRoute({
serverId: "server-1",
archivedWorkspaceId: "/notes",
workspaces,
})
).toBe("/h/server-1");
});
});

View File

@@ -0,0 +1,67 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string | null {
const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId);
if (!archivedWorkspaceId) {
return null;
}
const workspaces = Array.from(input.workspaces);
const archivedWorkspace =
workspaces.find((workspace) => workspace.id === archivedWorkspaceId) ?? null;
if (!archivedWorkspace) {
return null;
}
const sameProjectWorkspaces = workspaces.filter(
(workspace) => workspace.projectId === archivedWorkspace.projectId
);
const rootCheckoutWorkspace =
sameProjectWorkspaces.find(
(workspace) =>
workspace.workspaceKind === "local_checkout" &&
workspace.id !== archivedWorkspace.id
) ?? null;
if (rootCheckoutWorkspace) {
return rootCheckoutWorkspace.id;
}
const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath);
if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) {
return fallbackProjectRootPath;
}
const siblingWorkspace =
sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null;
return siblingWorkspace?.id ?? null;
}
export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string;
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string {
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: input.archivedWorkspaceId,
workspaces: input.workspaces,
});
if (!redirectWorkspaceId) {
return buildHostRootRoute(input.serverId);
}
return buildHostWorkspaceRoute(input.serverId, redirectWorkspaceId);
}