From 363874543613ad279f3b5d266db8eca9430bd351 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Wed, 25 Mar 2026 15:37:49 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20tab=20detach/reattach=20=E2=80=94=20pop?= =?UTF-8?q?=20sessions=20into=20separate=20windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click any tab → "Detach to Window" opens the session in its own Tauri window. The tab dims (opacity + italic) while detached. Close the detached window → session reattaches to the main tab bar. Architecture: - DetachedSession.vue: standalone terminal that connects to the same backend session (SSH/PTY events keep flowing) - App.vue detects #/detached-session?sessionId=X hash - Tab context menu: Detach to Window, Close - session:reattach event emitted on window close, main window listens - Monitor bar included in detached SSH windows - Session.active flag tracks detached state Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.vue | 10 ++- src/components/session/DetachedSession.vue | 80 ++++++++++++++++++++++ src/components/session/TabBar.vue | 61 +++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/components/session/DetachedSession.vue diff --git a/src/App.vue b/src/App.vue index c30fb14..3548954 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,11 +9,15 @@ const MainLayout = defineAsyncComponent( const ToolWindow = defineAsyncComponent( () => import("@/components/tools/ToolWindow.vue") ); +const DetachedSession = defineAsyncComponent( + () => import("@/components/session/DetachedSession.vue") +); const app = useAppStore(); // Tool window mode — detected from URL hash: #/tool/network-scanner?sessionId=abc const isToolMode = ref(false); +const isDetachedMode = ref(false); const toolName = ref(""); const toolSessionId = ref(""); @@ -25,6 +29,8 @@ onMounted(async () => { const [name, query] = rest.split("?"); toolName.value = name; toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || ""; + } else if (hash.startsWith("#/detached-session")) { + isDetachedMode.value = true; } else { await app.checkVaultState(); } @@ -32,8 +38,10 @@ onMounted(async () => { @@ -101,6 +115,53 @@ async function spawnShell(shell: ShellInfo): Promise { await sessionStore.spawnLocalTab(shell.name, shell.path); } +// Tab right-click context menu +const tabMenu = ref<{ visible: boolean; x: number; y: number; session: Session | null }>({ + visible: false, x: 0, y: 0, session: null, +}); + +function showTabMenu(event: MouseEvent, session: Session): void { + tabMenu.value = { visible: true, x: event.clientX, y: event.clientY, session }; +} + +async function detachTab(): Promise { + const session = tabMenu.value.session; + tabMenu.value.visible = false; + if (!session) return; + + // Mark as detached in the store + session.active = false; + + // Open a new Tauri window for this session + const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); + const label = `detached-${session.id.substring(0, 8)}-${Date.now()}`; + new WebviewWindow(label, { + title: `${session.name} — Wraith`, + width: 900, + height: 600, + resizable: true, + center: true, + url: `index.html#/detached-session?sessionId=${session.id}&name=${encodeURIComponent(session.name)}&protocol=${session.protocol}`, + }); +} + +function closeMenuTab(): void { + const session = tabMenu.value.session; + tabMenu.value.visible = false; + if (session) sessionStore.closeSession(session.id); +} + +// Listen for reattach events from detached windows +import { listen } from "@tauri-apps/api/event"; +listen<{ sessionId: string; name: string; protocol: string }>("session:reattach", (event) => { + const { sessionId } = event.payload; + const session = sessionStore.sessions.find(s => s.id === sessionId); + if (session) { + session.active = true; + sessionStore.activateSession(sessionId); + } +}); + onMounted(async () => { try { availableShells.value = await invoke("list_available_shells");