Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Works with any CLI agent. Built for local worktree-based development.
| **ports.json ポートの常時表示** | ports.json に定義されたポートをプロセス検出の有無にかかわらず常にサイドバーに表示。Docker 等で検知できないポートもラベル付きで一覧に出る。検出済みポートは従来通りアクティブ表示、未検出は グレー表示で区別 | [#7](https://github.com/MocA-Love/superset/pull/7) | 2026-03-28 |
| **Ports ワークスペース名の改善** | Ports セクションのワークスペース名をワークツリーのディレクトリ名ベースに変更。同名ワークスペースが複数ある場合でもどのワークツリーか一目で区別可能 | [#8](https://github.com/MocA-Love/superset/pull/8) | 2026-03-28 |
| **ブラウザタブ機能強化** | ズーム倍率表示と [-]/[+] ボタン(Cmd+/- と同期)、target="_blank" リンクや Cmd+click を新しいブラウザタブで開く機能、URL コピーボタンを追加。タブが非表示中でもリンクイベントを正しく処理するグローバルハンドラ実装 | [#10](https://github.com/MocA-Love/superset/pull/10) | 2026-03-29 |
| **タブのポップアウト** | ペインツールバーの Pop out ボタンでタブを独立ウィンドウとして分離。閉じるとメインウィンドウに自動返却。ターミナルセッション維持、preload 同期注入方式で Zustand persist との競合を排除 | [#11](https://github.com/MocA-Love/superset/pull/11) | 2026-03-29 |

## Fork のビルド方法 (macOS)

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BrowserWindow } from "electron";
import type { WindowManager } from "main/lib/window-manager";
import { router } from "..";
import { createAnalyticsRouter } from "./analytics";
import { createAuthRouter } from "./auth";
Expand Down Expand Up @@ -26,9 +27,13 @@ import { createSettingsRouter } from "./settings";
import { createTerminalRouter } from "./terminal";
import { createUiStateRouter } from "./ui-state";
import { createWindowRouter } from "./window";
import { createTabTearoffRouter } from "./tab-tearoff";
import { createWorkspacesRouter } from "./workspaces";

export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
export const createAppRouter = (
getWindow: () => BrowserWindow | null,
wm: WindowManager,
) => {
return router({
chatRuntimeService: createChatRuntimeServiceRouter(),
chatService: createChatServiceRouter(),
Expand Down Expand Up @@ -57,6 +62,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
uiState: createUiStateRouter(),
ringtone: createRingtoneRouter(getWindow),
hostServiceManager: createHostServiceManagerRouter(),
tabTearoff: createTabTearoffRouter(wm),
});
};

Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/lib/trpc/routers/tab-tearoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from "zod";
import type { WindowManager } from "main/lib/window-manager";
import { publicProcedure, router } from "..";

export const createTabTearoffRouter = (wm: WindowManager) => {
return router({
create: publicProcedure
.input(
z.object({
tab: z.unknown(),
panes: z.record(z.string(), z.unknown()),
workspaceId: z.string(),
screenX: z.number(),
screenY: z.number(),
}),
)
.mutation(({ input }) => {
const windowId = `tearoff-${Date.now()}`;
Comment thread
MocA-Love marked this conversation as resolved.

// Store data FIRST so it's available when preload requests it
wm.setPendingTearoffData(windowId, {
tab: input.tab,
panes: input.panes,
workspaceId: input.workspaceId,
});

wm.createTearoffWindow({
windowId,
screenX: input.screenX,
screenY: input.screenY,
});

return { windowId };
}),
});
};
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/window-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BrowserWindow } from "electron";
import { env } from "shared/env.shared";

/** Window IDs defined in the router configuration */
type WindowId = "main" | "about";
type WindowId = "main" | "about" | "tearoff";

/**
* Load an Electron window with the appropriate URL for TanStack Router.
Expand Down
146 changes: 146 additions & 0 deletions apps/desktop/src/main/lib/window-manager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { join } from "node:path";
import { BrowserWindow, ipcMain, nativeTheme } from "electron";
import { createWindow } from "lib/electron-app/factories/windows/create";

interface TearoffWindowOptions {
windowId: string;
screenX: number;
screenY: number;
width?: number;
height?: number;
}

interface TearoffTabData {
tab: unknown;
panes: Record<string, unknown>;
workspaceId: string;
}

type IpcHandler = {
attachWindow: (window: BrowserWindow) => void;
detachWindow: (window: BrowserWindow) => void;
};

export class WindowManager {
private windows = new Map<string, BrowserWindow>();
private ipcHandler: IpcHandler | null = null;
private ipcRegistered = false;
private pendingTearoffData = new Map<string, TearoffTabData>();

setIpcHandler(handler: IpcHandler): void {
this.ipcHandler = handler;
this.registerIpcHandlers();
}

private registerIpcHandlers(): void {
if (this.ipcRegistered) return;
this.ipcRegistered = true;

// Synchronous IPC: preload fetches tearoff data before React starts
ipcMain.on("get-tearoff-data", (event, windowId: string) => {
const data = this.pendingTearoffData.get(windowId);
if (data) this.pendingTearoffData.delete(windowId);
event.returnValue = data ?? null;
Comment thread
MocA-Love marked this conversation as resolved.
});

// Tearoff window closing: return all tabs to main window (single message)
ipcMain.on(
"tearoff-return-tabs",
(
_event,
data: Array<{ tab: unknown; panes: Record<string, unknown> }>,
) => {
const mainWindow = this.getMain();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("tearoff-tab-returned", data);
} else {
console.warn(
"[window-manager] Main window unavailable; returned tabs lost:",
data.length,
);
}
Comment thread
MocA-Love marked this conversation as resolved.
},
);
Comment thread
MocA-Love marked this conversation as resolved.
}

setPendingTearoffData(windowId: string, data: TearoffTabData): void {
this.pendingTearoffData.set(windowId, data);
setTimeout(() => this.pendingTearoffData.delete(windowId), 30_000);
}

register(windowId: string, window: BrowserWindow): void {
this.windows.set(windowId, window);
}

unregister(windowId: string): void {
this.windows.delete(windowId);
}

get(windowId: string): BrowserWindow | null {
return this.windows.get(windowId) ?? null;
}

getMain(): BrowserWindow | null {
return this.windows.get("main") ?? null;
}

getAll(): Map<string, BrowserWindow> {
return new Map(this.windows);
}

createTearoffWindow(options: TearoffWindowOptions): {
windowId: string;
window: BrowserWindow;
} {
const { windowId } = options;

const window = createWindow({
id: "tearoff",
title: "Superset",
width: options.width ?? 900,
height: options.height ?? 600,
x: Math.round(options.screenX - 100),
y: Math.round(options.screenY - 20),
minWidth: 400,
minHeight: 400,
show: false,
backgroundColor: nativeTheme.shouldUseDarkColors ? "#252525" : "#ffffff",
frame: false,
titleBarStyle: "hidden",
trafficLightPosition: { x: 16, y: 16 },
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
webviewTag: true,
partition: "persist:superset",
additionalArguments: [`--tearoff-window-id=${windowId}`],
},
});

this.register(windowId, window);
this.ipcHandler?.attachWindow(window);

// Detach IPC BEFORE window is destroyed (close fires before closed)
window.on("close", () => {
this.ipcHandler?.detachWindow(window);
});
window.on("closed", () => {
this.windows.delete(windowId);
});

window.webContents.once("did-finish-load", () => {
window.show();
});

return { windowId, window };
}

broadcast(channel: string, ...args: unknown[]): void {
for (const window of this.windows.values()) {
if (!window.isDestroyed()) {
window.webContents.send(channel, ...args);
}
}
}
}

export const windowManager = new WindowManager();
6 changes: 5 additions & 1 deletion apps/desktop/src/main/windows/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { AgentLifecycleEvent } from "shared/notification-types";
import { createIPCHandler } from "trpc-electron/main";
import { productName } from "~/package.json";
import { appState } from "../lib/app-state";
import { windowManager } from "../lib/window-manager";
import { browserManager } from "../lib/browser/browser-manager";
import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu";
import { playNotificationSound } from "../lib/notification-sound";
Expand Down Expand Up @@ -129,6 +130,7 @@ export async function MainWindow() {
registerMenuHotkeyUpdates();

currentWindow = window;
windowManager.register("main", window);

// macOS Sequoia+: background throttling can corrupt GPU compositor layers
if (PLATFORM.IS_MAC) {
Expand All @@ -139,9 +141,10 @@ export async function MainWindow() {
ipcHandler.attachWindow(window);
} else {
ipcHandler = createIPCHandler({
router: createAppRouter(getWindow),
router: createAppRouter(getWindow, windowManager),
windows: [window],
});
windowManager.setIpcHandler(ipcHandler);
}

const server = notificationsApp.listen(
Expand Down Expand Up @@ -320,6 +323,7 @@ export async function MainWindow() {
getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners();
// Detach window from IPC handler (handler stays alive for window reopen)
ipcHandler?.detachWindow(window);
windowManager.unregister("main");
currentWindow = null;
});

Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ declare global {
}
}

// Tearoff: synchronously fetch tab data BEFORE React/Zustand initialize
const tearoffWindowId = (() => {
const arg = process.argv.find((a) => a.startsWith("--tearoff-window-id="));
return arg ? arg.split("=")[1] : null;
})();
// biome-ignore lint/suspicious/noExplicitAny: tearoff data is untyped at preload level
const tearoffData: any = tearoffWindowId
? ipcRenderer.sendSync("get-tearoff-data", tearoffWindowId)
: null;
Comment thread
MocA-Love marked this conversation as resolved.

const API = {
sayHelloFromBridge: () => console.log("\nHello from bridgeAPI! 👋\n\n"),
username: process.env.USER,
appVersion: __APP_VERSION__,
tearoffWindowId,
tearoffData,
};

// Store mapping of user listeners to wrapped listeners for proper cleanup
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/hooks/useTearoffInit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
useTearoffInit,
useReturnedTabListener,
getTearoffWindowId,
isTearoffWindow,
} from "./useTearoffInit";
81 changes: 81 additions & 0 deletions apps/desktop/src/renderer/hooks/useTearoffInit/useTearoffInit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useTabsStore } from "renderer/stores/tabs/store";
import type { Tab } from "renderer/stores/tabs/types";
import type { Pane } from "shared/tabs-types";

// Cached at module load from preload-injected data
const _cachedWindowId: string | null =
typeof window !== "undefined" ? window.App?.tearoffWindowId ?? null : null;

export function getTearoffWindowId(): string | null {
return _cachedWindowId;
}

export function isTearoffWindow(): boolean {
return _cachedWindowId !== null;
}

export function useTearoffInit() {
const initialized = useRef(false);
const navigate = useNavigate();
const tabs = useTabsStore((s) => s.tabs);

// Navigate to the workspace for the tearoff tab
useEffect(() => {
if (!_cachedWindowId || initialized.current || tabs.length === 0) return;
initialized.current = true;
const tab = tabs[0];
navigate({ to: `/workspace/${tab.workspaceId}`, replace: true });
}, [tabs, navigate]);

// Return ALL tabs to main window when this tearoff window closes
useEffect(() => {
if (!_cachedWindowId) return;
const handleBeforeUnload = () => {
const state = useTabsStore.getState();
if (state.tabs.length === 0) return;

// Collect all tabs + their panes into a single message
const tabsWithPanes = state.tabs.map((tab) => {
const panes: Record<string, Pane> = {};
for (const [id, pane] of Object.entries(state.panes)) {
if (pane.tabId === tab.id) {
panes[id] = pane;
}
}
return { tab, panes };
});
Comment thread
MocA-Love marked this conversation as resolved.

// Send as ONE message to avoid race conditions
window.ipcRenderer.send("tearoff-return-tabs", tabsWithPanes);
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, []);
}

export function useReturnedTabListener() {
useEffect(() => {
if (isTearoffWindow()) return;
const handler = (
entries: Array<{ tab: unknown; panes: Record<string, unknown> }>,
) => {
const store = useTabsStore.getState();
const existingTabIds = new Set(store.tabs.map((t) => t.id));

for (const entry of entries) {
const tab = entry.tab as Tab;
// Skip if tab already exists (prevent duplicates)
if (existingTabIds.has(tab.id)) continue;
const panes = entry.panes as Record<string, Pane>;
store.hydrateReturnedTab(tab, panes);
existingTabIds.add(tab.id);
}
};
window.ipcRenderer.on("tearoff-tab-returned", handler);
return () => {
window.ipcRenderer.off("tearoff-tab-returned", handler);
};
}, []);
}
Loading