feat: tab detach/reattach — pop sessions into separate windows
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m3s

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) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-25 15:37:49 -04:00
parent ddce484eb9
commit 3638745436
3 changed files with 150 additions and 1 deletions

View File

@ -9,11 +9,15 @@ const MainLayout = defineAsyncComponent(
const ToolWindow = defineAsyncComponent( const ToolWindow = defineAsyncComponent(
() => import("@/components/tools/ToolWindow.vue") () => import("@/components/tools/ToolWindow.vue")
); );
const DetachedSession = defineAsyncComponent(
() => import("@/components/session/DetachedSession.vue")
);
const app = useAppStore(); const app = useAppStore();
// Tool window mode detected from URL hash: #/tool/network-scanner?sessionId=abc // Tool window mode detected from URL hash: #/tool/network-scanner?sessionId=abc
const isToolMode = ref(false); const isToolMode = ref(false);
const isDetachedMode = ref(false);
const toolName = ref(""); const toolName = ref("");
const toolSessionId = ref(""); const toolSessionId = ref("");
@ -25,6 +29,8 @@ onMounted(async () => {
const [name, query] = rest.split("?"); const [name, query] = rest.split("?");
toolName.value = name; toolName.value = name;
toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || ""; toolSessionId.value = new URLSearchParams(query || "").get("sessionId") || "";
} else if (hash.startsWith("#/detached-session")) {
isDetachedMode.value = true;
} else { } else {
await app.checkVaultState(); await app.checkVaultState();
} }
@ -32,8 +38,10 @@ onMounted(async () => {
</script> </script>
<template> <template>
<!-- Detached session window mode -->
<DetachedSession v-if="isDetachedMode" />
<!-- Tool popup window mode --> <!-- Tool popup window mode -->
<ToolWindow v-if="isToolMode" :tool="toolName" :session-id="toolSessionId" /> <ToolWindow v-else-if="isToolMode" :tool="toolName" :session-id="toolSessionId" />
<!-- Normal app mode --> <!-- Normal app mode -->
<div v-else class="app-root"> <div v-else class="app-root">
<UnlockLayout v-if="!app.isUnlocked" /> <UnlockLayout v-if="!app.isUnlocked" />

View File

@ -0,0 +1,80 @@
<template>
<div class="h-screen w-screen flex flex-col bg-[#0d1117]">
<!-- Minimal title bar -->
<div class="h-8 flex items-center justify-between px-3 bg-[#161b22] border-b border-[#30363d] shrink-0" data-tauri-drag-region>
<span class="text-xs text-[#8b949e]">{{ sessionName }}</span>
<span class="text-[10px] text-[#484f58]">Detached close to reattach</span>
</div>
<!-- Terminal -->
<div ref="containerRef" class="flex-1 min-h-0" />
<!-- Monitor bar for SSH sessions -->
<MonitorBar v-if="protocol === 'ssh'" :session-id="sessionId" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useTerminal } from "@/composables/useTerminal";
import MonitorBar from "@/components/terminal/MonitorBar.vue";
const sessionId = ref("");
const sessionName = ref("Detached Session");
const protocol = ref("ssh");
const containerRef = ref<HTMLElement | null>(null);
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
onMounted(async () => {
// Parse session info from URL hash
const hash = window.location.hash;
const params = new URLSearchParams(hash.split("?")[1] || "");
sessionId.value = params.get("sessionId") || "";
sessionName.value = decodeURIComponent(params.get("name") || "Detached Session");
protocol.value = params.get("protocol") || "ssh";
if (!sessionId.value || !containerRef.value) return;
// Determine backend type
const backend = protocol.value === "local" ? "pty" : "ssh";
terminalInstance = useTerminal(sessionId.value, backend as 'ssh' | 'pty');
terminalInstance.mount(containerRef.value);
setTimeout(() => {
if (terminalInstance) {
terminalInstance.fit();
terminalInstance.terminal.focus();
// Resize the backend
const resizeCmd = backend === "ssh" ? "ssh_resize" : "pty_resize";
invoke(resizeCmd, {
sessionId: sessionId.value,
cols: terminalInstance.terminal.cols,
rows: terminalInstance.terminal.rows,
}).catch(() => {});
}
}, 50);
// On window close, emit event so main window reattaches the tab
const appWindow = getCurrentWindow();
appWindow.onCloseRequested(async () => {
// Emit a custom event that the main window listens for
const { emit } = await import("@tauri-apps/api/event");
await emit("session:reattach", {
sessionId: sessionId.value,
name: sessionName.value,
protocol: protocol.value,
});
});
});
onBeforeUnmount(() => {
if (terminalInstance) {
terminalInstance.destroy();
terminalInstance = null;
}
});
</script>

View File

@ -15,6 +15,7 @@
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '', isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '', dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '', session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
!session.active ? 'opacity-40 italic' : '',
]" ]"
@click="sessionStore.activateSession(session.id)" @click="sessionStore.activateSession(session.id)"
@dragstart="onDragStart(index, $event)" @dragstart="onDragStart(index, $event)"
@ -22,6 +23,7 @@
@dragleave="dragOverIndex = -1" @dragleave="dragOverIndex = -1"
@drop.prevent="onDrop(index)" @drop.prevent="onDrop(index)"
@dragend="draggedIndex = -1; dragOverIndex = -1" @dragend="draggedIndex = -1; dragOverIndex = -1"
@contextmenu.prevent="showTabMenu($event, session)"
> >
<!-- Badge: protocol dot + root dot + env pills --> <!-- Badge: protocol dot + root dot + env pills -->
<TabBadge <TabBadge
@ -70,6 +72,18 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Tab context menu -->
<Teleport to="body">
<div v-if="tabMenu.visible" class="fixed z-[100] w-44 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden py-1"
:style="{ top: tabMenu.y + 'px', left: tabMenu.x + 'px' }">
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] cursor-pointer"
@click="detachTab">Detach to Window</button>
<div class="border-t border-[#30363d] my-1" />
<button class="w-full px-4 py-2 text-xs text-left text-[var(--wraith-accent-red)] hover:bg-[#30363d] cursor-pointer"
@click="closeMenuTab">Close</button>
</div>
<div v-if="tabMenu.visible" class="fixed inset-0 z-[99]" @click="tabMenu.visible = false" @contextmenu.prevent="tabMenu.visible = false" />
</Teleport>
</div> </div>
</template> </template>
@ -101,6 +115,53 @@ async function spawnShell(shell: ShellInfo): Promise<void> {
await sessionStore.spawnLocalTab(shell.name, shell.path); 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<void> {
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 () => { onMounted(async () => {
try { try {
availableShells.value = await invoke<ShellInfo[]>("list_available_shells"); availableShells.value = await invoke<ShellInfo[]>("list_available_shells");