feat(desktop): add dedicated topic popup window with cross-window sync (#13957)

*  feat(desktop): add dedicated topic popup window with cross-window sync

Introduce a standalone Vite entry for the desktop "open topic in new window"
action. The popup is a lightweight SPA (no sidebar, no portal) hosting only
the Conversation, and stays in sync with the main window through a
BroadcastChannel bus.

- Add popup.html + entry.popup.tsx + popupRouter.config.tsx
- Add /popup/agent/:aid/:tid and /popup/group/:gid/:tid routes
- Reuse main Conversation/ChatInput; wrap in MarketAuth + Hotkeys providers
- Pin-on-top button in the popup titlebar (new windows IPC: set/isAlwaysOnTop)
- Group topic "open in new window" now uses groupId (previously misused agentId)
- Cross-window sync: refreshMessages/refreshTopic emit via BroadcastChannel;
  subscriber revalidates local SWR caches with echo-loop suppression
- Hide WorkingPanel toggle inside /popup (no WorkingSidebar present)
- RendererUrlManager dispatches /popup/* to popup.html in prod; dev middleware
  rewrites SPA deep links while skipping asset/module requests

* 💄 style(desktop): restore loading splash in popup window

* ♻️ refactor(desktop): replace cross-window sync with popup-ownership guard

The BroadcastChannel-based bidirectional sync between the main SPA and the
topic popup window had edge cases during streaming. Drop it in favour of a
simpler ownership model: when a topic is already open in a popup, the main
window shows a "focus popup" redirect instead of rendering a second
conversation.

- Remove src/libs/crossWindowBus.ts and src/features/CrossWindowSync
- Remove postMessagesMutation/postTopicsMutation calls from refresh actions
- Add windows.listTopicPopups + windows.focusTopicPopup IPC
- Main process broadcasts topicPopupsChanged on popup open/close; parses
  (scope, id, topicId) from the popup window's /popup/... path
- Renderer useTopicPopupsRegistry subscribes to broadcasts and fetches the
  initial snapshot; useTopicInPopup selects by scope
- New TopicInPopupGuard component with "Focus popup window" button
- Desktop-only index.desktop.tsx variants for (main)/agent and (main)/group
  render the guard when the current topic is owned by a popup
- i18n: topic.inPopup.title / description / focus in default + en/zh

* 🐛 fix(desktop): re-evaluate popup guard when topic changes

Subscribe to the popups array and derive findPopup via useMemo so scope changes (e.g. switching topic in the sidebar while a popup is open) correctly re-compute the guard and let the main window render the newly active topic.

* 🐛 fix(desktop): focus detached topic popup from main window

*  feat(desktop): add open in popup window action to menu for active topic

Signed-off-by: Innei <tukon479@gmail.com>

* 🎨 style: sort imports to satisfy simple-import-sort rule

*  feat(error): add resetPath prop to ErrorCapture and ErrorBoundary for customizable navigation

Signed-off-by: Innei <tukon479@gmail.com>

* ♻️ refactor: restore ChatHydration in ConversationArea for web/mobile routes

Reintroduce ChatHydration component to agent and group ConversationArea
so that URL query sync (topic/thread) works on web and mobile routes,
not only on desktop entry files.

*  feat(electron): enforce absolute base URL in renderer config to fix asset resolution in popup windows

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-04-19 02:15:29 +08:00 committed by GitHub
parent c213483a7a
commit 2711aa9191
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1421 additions and 44 deletions

View file

@ -15,15 +15,64 @@ import {
import { getExternalDependencies } from './native-deps.config.mjs';
/**
* Rewrite `/` to `/apps/desktop/index.html` so the electron-vite dev server
* serves the desktop HTML entry when root is the monorepo root.
* Force `base: '/'` in renderer config. The `electron-vite` preset
* unconditionally rewrites base to `'./'` in production (with `enforce: 'pre'`),
* which produces relative asset URLs like `../../assets/...`. Those break in
* the popup window because its SPA URL (`/popup/agent/:aid/:tid`) is deep
* enough that relative resolution lands at `/popup/assets/...` instead of the
* actual `/assets/...`. Our `app://` protocol handler resolves absolute
* `/assets/...` correctly regardless of URL depth.
*/
function forceAbsoluteBasePlugin(): PluginOption {
return {
name: 'electron-desktop-force-base',
config(config) {
config.base = '/';
},
};
}
/**
* Rewrite SPA routes to their corresponding HTML entry so the electron-vite
* dev server serves the right HTML when root is the monorepo root.
*
* - `/popup/*` `/apps/desktop/popup.html` (topic popup SPA)
* - `/`, `/index.html`, and everything else `/apps/desktop/index.html`
*/
function electronDesktopHtmlPlugin(): PluginOption {
return {
configureServer(server: ViteDevServer) {
server.middlewares.use((req, _res, next) => {
if (req.url === '/' || req.url === '/index.html') {
const rawUrl = req.url ?? '';
const pathname = rawUrl.split('?')[0];
// Explicit document-entry requests — always rewrite.
if (pathname === '/' || pathname === '/index.html') {
req.url = '/apps/desktop/index.html';
next();
return;
}
if (pathname === '/popup.html') {
req.url = '/apps/desktop/popup.html';
next();
return;
}
// For SPA deep links (e.g. `/popup/agent/A/T`) rewrite to the popup
// HTML — but skip asset / module requests that happen to share the
// prefix (e.g. `/popup/@vite/client` would have been generated by a
// mis-resolved relative import).
const lastSegment = pathname.split('/').pop() ?? '';
const looksLikeAsset =
lastSegment.includes('.') ||
pathname.startsWith('/@') ||
pathname.startsWith('/src/') ||
pathname.startsWith('/node_modules/') ||
pathname.startsWith('/apps/') ||
pathname.startsWith('/packages/');
if (!looksLikeAsset && (pathname === '/popup' || pathname.startsWith('/popup/'))) {
req.url = '/apps/desktop/popup.html';
}
next();
});
@ -102,7 +151,10 @@ export default defineConfig({
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
input: {
main: path.resolve(__dirname, 'index.html'),
popup: path.resolve(__dirname, 'popup.html'),
},
output: sharedRollupOutput,
},
},
@ -112,6 +164,7 @@ export default defineConfig({
},
optimizeDeps: sharedOptimizeDeps,
plugins: [
forceAbsoluteBasePlugin(),
electronDesktopHtmlPlugin(),
...(sharedRendererPlugins({ platform: 'desktop' }) as PluginOption[]),
],

114
apps/desktop/popup.html Normal file
View file

@ -0,0 +1,114 @@
<!doctype html>
<html class="desktop">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html,
body {
margin: 0;
height: 100%;
background: transparent;
}
html[data-theme='dark'] {
background: #141414;
}
html[data-theme='light'] {
background: #fafafa;
}
#loading-screen {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: inherit;
gap: 12px;
}
@keyframes loading-draw {
0% {
stroke-dashoffset: 1000;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes loading-fill {
30% {
fill-opacity: 0.05;
}
100% {
fill-opacity: 1;
}
}
#loading-brand {
display: flex;
align-items: center;
gap: 12px;
color: #1f1f1f;
}
#loading-brand svg path {
fill: currentcolor;
fill-opacity: 0;
stroke: currentcolor;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
stroke-width: 0.25em;
animation:
loading-draw 2s cubic-bezier(0.4, 0, 0.2, 1) infinite,
loading-fill 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
html[data-theme='dark'] #loading-brand {
color: #f0f0f0;
}
</style>
</head>
<body>
<script>
(function () {
var theme = 'system';
try {
theme = localStorage.getItem('theme') || 'system';
} catch (_) {}
var systemTheme =
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
var resolvedTheme = theme === 'system' ? systemTheme : theme;
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}
var urlParams = new URLSearchParams(window.location.search);
var locale = urlParams.get('lng') || navigator.language || 'en-US';
document.documentElement.lang = locale;
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
document.documentElement.dir =
rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
})();
</script>
<div id="loading-screen">
<div id="loading-brand" aria-label="Loading" role="status">
<svg
fill="currentColor"
fill-rule="evenodd"
height="40"
style="flex: none; line-height: 1"
viewBox="0 0 940 320"
xmlns="http://www.w3.org/2000/svg"
>
<title>LobeHub</title>
<path
d="M15 240.035V87.172h39.24V205.75h66.192v34.285H15zM183.731 242c-11.759 0-22.196-2.621-31.313-7.862-9.116-5.241-16.317-12.447-21.601-21.619-5.153-9.317-7.729-19.945-7.729-31.883 0-11.937 2.576-22.492 7.729-31.664 5.164-8.963 12.159-15.98 20.982-21.05l.619-.351c9.117-5.241 19.554-7.861 31.313-7.861s22.196 2.62 31.313 7.861c9.248 5.096 16.449 12.229 21.601 21.401 5.153 9.172 7.729 19.727 7.729 31.664 0 11.938-2.576 22.566-7.729 31.883-5.152 9.172-12.353 16.378-21.601 21.619-9.117 5.241-19.554 7.862-31.313 7.862zm0-32.975c4.36 0 8.191-1.092 11.494-3.275 3.436-2.184 6.144-5.387 8.126-9.609 1.982-4.367 2.973-9.536 2.973-15.505 0-5.968-.991-10.991-2.973-15.067-1.906-4.06-4.483-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.134-3.276-11.494-3.276-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275zM295.508 78l-.001 54.042a34.071 34.071 0 016.541-5.781c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.557 7.424 7.872 4.835 14.105 11.684 18.7 20.546l.325.637c4.756 9.026 7.135 19.799 7.135 32.319 0 12.666-2.379 23.585-7.135 32.757-4.624 9.026-10.966 16.087-19.025 21.182-7.928 4.95-16.78 7.425-26.557 7.425-9.644 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.355-7.532-7.226l.001 11.812h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.494 3.276-3.303 2.184-6.012 5.387-8.126 9.609-1.982 4.076-2.972 9.099-2.972 15.067 0 5.969.99 11.138 2.972 15.505 2.114 4.222 4.823 7.425 8.126 9.609 3.435 2.183 7.266 3.275 11.494 3.275s7.994-1.092 11.297-3.275c3.435-2.184 6.143-5.387 8.125-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.483-7.177-7.732-9.352l-.393-.257c-3.303-2.184-7.069-3.276-11.297-3.276zm105.335 38.653l.084.337a27.857 27.857 0 002.057 5.559c2.246 4.222 5.417 7.498 9.513 9.827 4.096 2.184 8.984 3.276 14.665 3.276 5.285 0 9.777-.801 13.477-2.403 3.579-1.632 7.1-4.025 10.564-7.182l.732-.679 19.818 22.711c-5.153 6.26-11.494 11.064-19.025 14.413-7.531 3.203-16.449 4.804-26.755 4.804-12.683 0-23.782-2.621-33.294-7.862-9.381-5.386-16.713-12.665-21.998-21.837-5.153-9.317-7.729-19.872-7.729-31.665 0-11.792 2.51-22.274 7.53-31.446 5.036-9.105 11.902-16.195 20.596-21.268l.61-.351c8.984-5.241 19.091-7.861 30.322-7.861 10.311 0 19.743 2.286 28.294 6.859l.64.347c8.72 4.659 15.656 11.574 20.809 20.746 5.153 9.172 7.729 20.309 7.729 33.411 0 1.294-.052 2.761-.156 4.4l-.042.623-.17 2.353c-.075 1.01-.151 1.973-.227 2.888h-78.044zm21.365-42.147c-4.492 0-8.456 1.092-11.891 3.276-3.303 2.184-5.879 5.314-7.729 9.39a26.04 26.04 0 00-1.117 2.79 30.164 30.164 0 00-1.121 4.499l-.058.354h43.96l-.015-.106c-.401-2.638-1.122-5.055-2.163-7.252l-.246-.503c-1.776-3.774-4.282-6.742-7.519-8.906l-.409-.266c-3.303-2.184-7.2-3.276-11.692-3.276zm111.695-62.018l-.001 57.432h53.51V87.172h39.24v152.863h-39.24v-59.617H555.9l.001 59.617h-39.24V87.172h39.24zM715.766 242c-8.72 0-16.581-1.893-23.583-5.678-6.87-3.785-12.287-9.681-16.251-17.688-3.832-8.153-5.747-18.417-5.747-30.791v-66.168h37.654v59.398c0 9.172 1.519 15.723 4.558 19.654 3.171 3.931 7.597 5.896 13.278 5.896 3.7 0 7.069-.946 10.108-2.839 3.038-1.892 5.483-4.877 7.332-8.953 1.85-4.222 2.775-9.609 2.775-16.16v-56.996h37.654v118.36h-35.871l.004-12.38c-2.642 3.197-5.682 5.868-9.12 8.012-7.002 4.222-14.599 6.333-22.791 6.333zM841.489 78l-.001 54.041a34.1 34.1 0 016.541-5.78c6.474-4.367 14.269-6.551 23.385-6.551 9.777 0 18.629 2.475 26.556 7.424 7.873 4.835 14.106 11.684 18.701 20.546l.325.637c4.756 9.026 7.134 19.799 7.134 32.319 0 12.666-2.378 23.585-7.134 32.757-4.624 9.026-10.966 16.087-19.026 21.182-7.927 4.95-16.779 7.425-26.556 7.425-9.645 0-17.704-2.184-24.178-6.551-2.825-1.946-5.336-4.354-7.531-7.224v11.81h-35.87V78h37.654zm21.998 74.684c-4.228 0-8.059 1.092-11.495 3.276-3.303 2.184-6.011 5.387-8.125 9.609-1.982 4.076-2.973 9.099-2.973 15.067 0 5.969.991 11.138 2.973 15.505 2.114 4.222 4.822 7.425 8.125 9.609 3.436 2.183 7.267 3.275 11.495 3.275 4.228 0 7.993-1.092 11.296-3.275 3.435-2.184 6.144-5.387 8.126-9.609 2.114-4.367 3.171-9.536 3.171-15.505 0-5.968-1.057-10.991-3.171-15.067-1.906-4.06-4.484-7.177-7.733-9.352l-.393-.257c-3.303-2.184-7.068-3.276-11.296-3.276z"
/>
</svg>
</div>
</div>
<div id="root" style="height: 100%"></div>
<script>
window.__SERVER_CONFIG__ = undefined;
</script>
<script type="module" src="/src/spa/entry.popup.tsx"></script>
</body>
</html>

View file

@ -66,6 +66,20 @@ export const windowTemplates = {
titleBarStyle: 'hidden',
width: 900,
},
// Dedicated single-topic popup window. Loads the popup.html SPA entry
// (no sidebar / portal), one window per (scope, id) pair.
topicPopup: {
allowMultipleInstances: true,
autoHideMenuBar: true,
baseIdentifier: 'topicPopup',
basePath: '/popup',
height: 720,
keepAlive: false,
minWidth: 480,
parentIdentifier: 'app',
titleBarStyle: 'hidden',
width: 900,
},
} satisfies Record<string, WindowTemplate>;
export type AppBrowsersIdentifiers = keyof typeof appBrowsers;

View file

@ -1,4 +1,5 @@
import type {
FocusTopicPopupParams,
InterceptRouteParams,
OpenSettingsWindowOptions,
WindowMinimumSizeParams,
@ -80,6 +81,30 @@ export default class BrowserWindowsCtr extends ControllerModule {
});
}
@IpcMethod()
setWindowAlwaysOnTop(flag: boolean) {
this.withSenderIdentifier((identifier) => {
this.app.browserManager.setWindowAlwaysOnTop(identifier, flag);
});
}
@IpcMethod()
isWindowAlwaysOnTop() {
return this.withSenderIdentifier((identifier) => {
return this.app.browserManager.isWindowAlwaysOnTop(identifier);
});
}
@IpcMethod()
listTopicPopups() {
return this.app.browserManager.listTopicPopups();
}
@IpcMethod()
focusTopicPopup(params: FocusTopicPopupParams) {
return this.app.browserManager.focusTopicPopup(params.identifier);
}
@IpcMethod()
setWindowSize(params: WindowSizeParams) {
this.withSenderIdentifier((identifier) => {

View file

@ -1,4 +1,8 @@
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import type {
MainBroadcastEventKey,
MainBroadcastParams,
TopicPopupInfo,
} from '@lobechat/electron-client-ipc';
import type { WebContents } from 'electron';
import { isLinux } from '@/const/env';
@ -11,6 +15,9 @@ import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
const TOPIC_POPUP_TEMPLATE_ID: WindowTemplateIdentifiers = 'topicPopup';
const TOPIC_POPUP_PATH_RE = /^\/popup\/(agent|group)\/([^/?#]+)\/([^/?#]+)/;
// Create logger
const logger = createLogger('core:BrowserManager');
@ -145,12 +152,62 @@ export class BrowserManager {
const browser = this.retrieveOrInitialize(browserOpts);
if (templateId === TOPIC_POPUP_TEMPLATE_ID) {
// Notify main-window SPAs so they can redirect to the popup instead of
// rendering the same conversation in two places. Re-emit on close to
// release the "topic is in popup" guard.
this.emitTopicPopupsChanged();
browser.browserWindow.once('closed', () => {
this.emitTopicPopupsChanged();
});
}
return {
browser,
identifier: windowId,
};
}
/**
* List currently-open topic popup windows (alive only). Used by the main
* SPA to decide whether to render the conversation or a redirect-to-popup
* guard.
*/
listTopicPopups(): TopicPopupInfo[] {
const popups: TopicPopupInfo[] = [];
this.browsers.forEach((browser, identifier) => {
if (!identifier.startsWith(`${TOPIC_POPUP_TEMPLATE_ID}_`)) return;
const webContents = browser.webContents;
if (!webContents || webContents.isDestroyed()) return;
const match = browser.options.path.match(TOPIC_POPUP_PATH_RE);
if (!match) return;
const scope = match[1] as 'agent' | 'group';
const id = match[2];
const topicId = match[3];
popups.push({
identifier,
scope,
topicId,
...(scope === 'agent' ? { agentId: id } : { groupId: id }),
});
});
return popups;
}
focusTopicPopup(identifier: string): boolean {
const browser = this.browsers.get(identifier);
if (!browser) return false;
const win = browser.browserWindow;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
return true;
}
private emitTopicPopupsChanged(): void {
this.broadcastToAllWindows('topicPopupsChanged', { popups: this.listTopicPopups() });
}
/**
* Get all windows based on template
* @param templateId Template identifier
@ -278,6 +335,16 @@ export class BrowserManager {
browser?.setWindowMinimumSize(size);
}
setWindowAlwaysOnTop(identifier: string, flag: boolean) {
const browser = this.browsers.get(identifier);
browser?.browserWindow.setAlwaysOnTop(flag);
}
isWindowAlwaysOnTop(identifier: string) {
const browser = this.browsers.get(identifier);
return browser?.browserWindow.isAlwaysOnTop() ?? false;
}
getIdentifierByWebContents(webContents: WebContents): string | null {
return this.webContentsMap.get(webContents) || null;
}

View file

@ -12,8 +12,9 @@ import { RendererProtocolManager } from './RendererProtocolManager';
const logger = createLogger('core:RendererUrlManager');
// Vite build with root=monorepo preserves input path structure,
// so index.html ends up at apps/desktop/index.html in outDir.
// so index.html / popup.html end up under apps/desktop/ in outDir.
const SPA_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'index.html');
const POPUP_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'popup.html');
export class RendererUrlManager {
private readonly rendererProtocolManager: RendererProtocolManager;
@ -66,7 +67,8 @@ export class RendererUrlManager {
/**
* Resolve renderer file path in production.
* Static assets map directly; all routes fall back to index.html (SPA).
* Static assets map directly; popup routes go to popup.html, all other
* routes fall back to index.html (SPA).
*/
resolveRendererFilePath = async (url: URL): Promise<string | null> => {
const pathname = url.pathname;
@ -77,7 +79,12 @@ export class RendererUrlManager {
return pathExistsSync(filePath) ? filePath : null;
}
// All routes fallback to index.html (SPA)
// Topic popup window has its own SPA bundle.
if (pathname === '/popup' || pathname.startsWith('/popup/')) {
return POPUP_ENTRY_HTML;
}
// All other routes fallback to index.html (SPA)
return SPA_ENTRY_HTML;
};

View file

@ -133,5 +133,7 @@
"window.close": "Close window",
"window.maximize": "Maximize window",
"window.minimize": "Minimize window",
"window.restore": "Restore window"
"window.pinToTop": "Pin on top",
"window.restore": "Restore window",
"window.unpinFromTop": "Unpin from top"
}

View file

@ -42,6 +42,9 @@
"importInvalidFormat": "Invalid file format. Please ensure it is a valid JSON file.",
"importLoading": "Importing conversation...",
"importSuccess": "Successfully imported {{count}} messages",
"inPopup.description": "This topic is currently open in a separate window. Continue the conversation there to keep messages in sync.",
"inPopup.focus": "Focus popup window",
"inPopup.title": "Open in popup window",
"loadMore": "Load More",
"newTopic": "New Topic",
"renameModal.description": "Keep it short and easy to recognize.",

View file

@ -133,5 +133,7 @@
"window.close": "关闭窗口",
"window.maximize": "最大化窗口",
"window.minimize": "最小化窗口",
"window.restore": "恢复窗口"
"window.pinToTop": "窗口置顶",
"window.restore": "恢复窗口",
"window.unpinFromTop": "取消置顶"
}

View file

@ -42,6 +42,9 @@
"importInvalidFormat": "文件格式不正确。请确认这是有效的 JSON 文件",
"importLoading": "正在导入对话…",
"importSuccess": "已导入 {{count}} 条消息",
"inPopup.description": "此话题已在独立窗口中打开,请前往该窗口继续对话以保持消息一致。",
"inPopup.focus": "聚焦独立窗口",
"inPopup.title": "已在独立窗口中打开",
"loadMore": "更多",
"newTopic": "新话题",
"renameModal.description": "保持简短且易于识别。",

View file

@ -4,6 +4,7 @@ import type { NavigationBroadcastEvents } from './navigation';
import type { ProtocolBroadcastEvents } from './protocol';
import type { RemoteServerBroadcastEvents } from './remoteServer';
import type { SystemBroadcastEvents } from './system';
import type { TopicPopupBroadcastEvents } from './topicPopup';
import type { AutoUpdateBroadcastEvents } from './update';
/**
@ -18,6 +19,7 @@ export interface MainBroadcastEvents
NavigationBroadcastEvents,
RemoteServerBroadcastEvents,
SystemBroadcastEvents,
TopicPopupBroadcastEvents,
ProtocolBroadcastEvents {}
export type MainBroadcastEventKey = keyof MainBroadcastEvents;

View file

@ -0,0 +1,11 @@
import type { TopicPopupInfo } from '../types/topicPopup';
export interface TopicPopupBroadcastEvents {
/**
* Emitted whenever the set of open topic popup windows changes
* (a popup opens, closes, or is reassigned). The payload is the full
* current list renderers should replace their local registry with it
* rather than applying a diff.
*/
topicPopupsChanged: (data: { popups: TopicPopupInfo[] }) => void;
}

View file

@ -8,6 +8,7 @@ export * from './route';
export * from './shortcut';
export * from './system';
export * from './toolDetector';
export * from './topicPopup';
export * from './tray';
export * from './update';
export * from './upload';

View file

@ -0,0 +1,28 @@
/**
* Metadata describing a single topic popup window that is currently open.
*
* The main process maintains the source-of-truth registry and broadcasts
* changes via the `topicPopupsChanged` event so main-window SPAs can
* reactively hide the conversation and redirect users to the popup.
*/
export interface TopicPopupInfo {
/**
* For agent popups: the active agent id. Undefined when scope = 'group'.
*/
agentId?: string;
/**
* For group popups: the active group id. Undefined when scope = 'agent'.
*/
groupId?: string;
/**
* Electron BrowserWindow identifier that can be used with
* `windows.focusTopicPopup` to raise and focus the popup.
*/
identifier: string;
scope: 'agent' | 'group';
topicId: string;
}
export interface FocusTopicPopupParams {
identifier: string;
}

View file

@ -19,9 +19,11 @@ export type ErrorType = Error & { digest?: string };
interface ErrorCaptureProps {
error: ErrorType;
/** Where "back home" navigates; defaults to `/`. */
resetPath?: string;
}
const ErrorCapture = ({ error }: ErrorCaptureProps) => {
const ErrorCapture = ({ error, resetPath = '/' }: ErrorCaptureProps) => {
const { t } = useTranslation('error');
const hasStack = !!error?.stack;
const defaultExpandedKeys: Key[] = typeof __CI__ !== 'undefined' && __CI__ ? ['stack'] : [];
@ -50,7 +52,7 @@ const ErrorCapture = ({ error }: ErrorCaptureProps) => {
<p style={{ marginBottom: '2em' }}>{t('error.desc')}</p>
<Flexbox horizontal gap={12} style={{ marginBottom: '2em' }}>
<Button onClick={() => window.location.reload()}>{t('error.retry')}</Button>
<Button type={'primary'} onClick={() => (window.location.href = '/')}>
<Button type={'primary'} onClick={() => (window.location.href = resetPath)}>
{t('error.backHome')}
</Button>
</Flexbox>

View file

@ -0,0 +1,69 @@
'use client';
import { type TopicPopupInfo } from '@lobechat/electron-client-ipc';
import { Button, Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ExternalLinkIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ensureElectronIpc } from '@/utils/electron/ipc';
const styles = createStaticStyles(({ css, cssVar }) => ({
description: css`
max-width: 360px;
font-size: 14px;
line-height: 1.6;
color: ${cssVar.colorTextSecondary};
text-align: center;
`,
title: css`
margin-block: 0;
font-size: 18px;
font-weight: 600;
color: ${cssVar.colorText};
`,
wrapper: css`
width: 100%;
height: 100%;
padding: 24px;
`,
}));
interface TopicInPopupGuardProps {
popup: TopicPopupInfo;
}
const TopicInPopupGuard = memo<TopicInPopupGuardProps>(({ popup }) => {
const { t } = useTranslation('topic');
const handleFocus = async () => {
try {
await ensureElectronIpc().windows.focusTopicPopup({ identifier: popup.identifier });
} catch (error) {
console.error('[TopicInPopupGuard] Failed to focus popup window:', error);
}
};
return (
<Flexbox
align={'center'}
className={styles.wrapper}
flex={1}
gap={16}
justify={'center'}
width={'100%'}
>
<h2 className={styles.title}>{t('inPopup.title')}</h2>
<p className={styles.description}>{t('inPopup.description')}</p>
<Button icon={ExternalLinkIcon} type={'primary'} onClick={handleFocus}>
{t('inPopup.focus')}
</Button>
</Flexbox>
);
});
TopicInPopupGuard.displayName = 'TopicInPopupGuard';
export default TopicInPopupGuard;

View file

@ -0,0 +1,117 @@
import { type TopicPopupInfo } from '@lobechat/electron-client-ipc';
import { useCallback, useEffect, useMemo } from 'react';
import { create } from 'zustand';
import { ensureElectronIpc } from '@/utils/electron/ipc';
interface TopicPopupsRegistryState {
initialized: boolean;
popups: TopicPopupInfo[];
setPopups: (popups: TopicPopupInfo[]) => void;
}
const useTopicPopupsRegistryStore = create<TopicPopupsRegistryState>((set) => ({
initialized: false,
popups: [],
setPopups: (popups) => set({ initialized: true, popups }),
}));
/**
* Subscribe the main SPA to popup-registry updates from the Electron main
* process. Safe to call from multiple components the subscription is
* installed once at module load time and the store is shared.
*
* On first use, fetches the initial list so callers don't have to wait
* for the first broadcast to fire.
*/
let subscribed = false;
const ensureSubscribed = () => {
if (subscribed) return;
subscribed = true;
const ipcRenderer = (typeof window !== 'undefined' && window.electron?.ipcRenderer) || null;
if (!ipcRenderer) return;
const setPopups = useTopicPopupsRegistryStore.getState().setPopups;
const handler = (_event: unknown, data: { popups: TopicPopupInfo[] }) => {
setPopups(data?.popups ?? []);
};
ipcRenderer.on('topicPopupsChanged' as any, handler);
// Fetch initial snapshot so the guard renders correctly on reload into a
// route that already has a popup open.
void ensureElectronIpc()
.windows.listTopicPopups()
.then((popups: TopicPopupInfo[] | undefined) => {
setPopups(popups ?? []);
})
.catch(() => {
// No-op: IPC may not be available on every platform.
});
};
interface ScopeQuery {
agentId?: string;
groupId?: string;
topicId: string;
}
interface PopupScope {
agentId?: string;
groupId?: string;
}
const findPopup = (popups: TopicPopupInfo[], scope: ScopeQuery): TopicPopupInfo | undefined =>
popups.find((p) => {
if (p.topicId !== scope.topicId) return false;
if (scope.groupId) return p.scope === 'group' && p.groupId === scope.groupId;
if (scope.agentId) return p.scope === 'agent' && p.agentId === scope.agentId;
return false;
});
export const useTopicPopupsRegistry = () => {
useEffect(() => {
ensureSubscribed();
}, []);
return useTopicPopupsRegistryStore((s) => s.popups);
};
export const useTopicInPopup = (scope: ScopeQuery): TopicPopupInfo | undefined => {
useEffect(() => {
ensureSubscribed();
}, []);
const popups = useTopicPopupsRegistryStore((s) => s.popups);
// Recompute when either the popup list or the caller's scope changes.
return useMemo(
() => findPopup(popups, scope),
[popups, scope.agentId, scope.groupId, scope.topicId],
);
};
export const useFocusTopicPopup = (scope: PopupScope) => {
useEffect(() => {
ensureSubscribed();
}, []);
const popups = useTopicPopupsRegistryStore((s) => s.popups);
return useCallback(
async (topicId?: string) => {
if (!topicId) return false;
const popup = findPopup(popups, { ...scope, topicId });
if (!popup) return false;
try {
await ensureElectronIpc().windows.focusTopicPopup({ identifier: popup.identifier });
return true;
} catch (error) {
console.error('[useFocusTopicPopup] Failed to focus popup window:', error);
return false;
}
},
[popups, scope.agentId, scope.groupId],
);
};

View file

@ -139,5 +139,7 @@ export default {
'window.close': 'Close window',
'window.maximize': 'Maximize window',
'window.minimize': 'Minimize window',
'window.pinToTop': 'Pin on top',
'window.restore': 'Restore window',
'window.unpinFromTop': 'Unpin from top',
};

View file

@ -44,6 +44,10 @@ export default {
'importInvalidFormat': 'Invalid file format. Please ensure it is a valid JSON file.',
'importLoading': 'Importing conversation...',
'importSuccess': 'Successfully imported {{count}} messages',
'inPopup.description':
'This topic is currently open in a separate window. Continue the conversation there to keep messages in sync.',
'inPopup.focus': 'Focus popup window',
'inPopup.title': 'Open in popup window',
'loadMore': 'Load More',
'newTopic': 'New Topic',
'renameModal.description': 'Keep it short and easy to recognize.',

View file

@ -104,7 +104,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
id ? operationSelectors.isTopicUnreadCompleted(id) : () => false,
);
const { navigateToTopic, isInAgentSubRoute } = useTopicNavigation();
const { focusTopicPopup, navigateToTopic, isInAgentSubRoute } = useTopicNavigation();
const toggleEditing = useCallback(
(visible?: boolean) => {
@ -120,25 +120,29 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
if (isDesktop) {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
navigateToTopic(id);
void navigateToTopic(id);
}, 250);
} else {
navigateToTopic(id);
void navigateToTopic(id);
}
}, [editing, id, navigateToTopic]);
const handleDoubleClick = useCallback(() => {
const handleDoubleClick = useCallback(async () => {
if (!id || !activeAgentId || !isDesktop) return;
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (await focusTopicPopup(id)) {
void navigateToTopic(id, { skipPopupFocus: true });
return;
}
const reference = pluginRegistry.parseUrl(`/agent/${activeAgentId}`, `topic=${id}`);
if (reference) {
addTab(reference);
navigateToTopic(id);
void navigateToTopic(id);
}
}, [id, activeAgentId, addTab, navigateToTopic]);
}, [id, activeAgentId, addTab, focusTopicPopup, navigateToTopic]);
const { dropdownMenu } = useTopicItemDropdownMenu({
fav,
@ -220,7 +224,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
);
})()}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onDoubleClick={() => void handleDoubleClick()}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
{active && (

View file

@ -0,0 +1,100 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTopicNavigation } from './useTopicNavigation';
const switchTopicMock = vi.hoisted(() => vi.fn());
const toggleMobileTopicMock = vi.hoisted(() => vi.fn());
const pushMock = vi.hoisted(() => vi.fn());
const pathnameMock = vi.hoisted(() => vi.fn());
const focusTopicPopupMock = vi.hoisted(() => vi.fn());
vi.mock('@/features/TopicPopupGuard/useTopicPopupsRegistry', () => ({
useFocusTopicPopup: () => focusTopicPopupMock,
}));
vi.mock('@/hooks/useQueryRoute', () => ({
useQueryRoute: () => ({
push: pushMock,
}),
}));
vi.mock('@/libs/router/navigation', () => ({
usePathname: pathnameMock,
}));
vi.mock('@/store/chat', () => ({
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
activeAgentId: 'agent-1',
switchTopic: switchTopicMock,
}),
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
toggleMobileTopic: toggleMobileTopicMock,
}),
}));
describe('useTopicNavigation', () => {
beforeEach(() => {
pathnameMock.mockReset();
focusTopicPopupMock.mockReset();
pushMock.mockReset();
switchTopicMock.mockReset();
toggleMobileTopicMock.mockReset();
});
it('focuses the popup and still navigates back to the chat route when the topic is detached', async () => {
pathnameMock.mockReturnValue('/agent/agent-1/profile');
focusTopicPopupMock.mockResolvedValue(true);
const { result } = renderHook(() => useTopicNavigation());
await act(async () => {
await result.current.navigateToTopic('topic-in-popup');
});
expect(focusTopicPopupMock).toHaveBeenCalledWith('topic-in-popup');
expect(pushMock).toHaveBeenCalledWith('/agent/agent-1?topic=topic-in-popup');
expect(switchTopicMock).not.toHaveBeenCalled();
expect(toggleMobileTopicMock).toHaveBeenCalledWith(false);
});
it('falls back to the original sub-route navigation when no popup exists', async () => {
pathnameMock.mockReturnValue('/agent/agent-1/profile');
focusTopicPopupMock.mockResolvedValue(false);
const { result } = renderHook(() => useTopicNavigation());
await act(async () => {
await result.current.navigateToTopic('topic-2');
});
expect(focusTopicPopupMock).toHaveBeenCalledWith('topic-2');
expect(pushMock).toHaveBeenCalledWith('/agent/agent-1?topic=topic-2');
expect(switchTopicMock).not.toHaveBeenCalled();
expect(toggleMobileTopicMock).toHaveBeenCalledWith(false);
});
it('switches the main window topic after focusing the popup on the base route', async () => {
pathnameMock.mockReturnValue('/agent/agent-1');
focusTopicPopupMock.mockResolvedValue(true);
const { result } = renderHook(() => useTopicNavigation());
await act(async () => {
await result.current.navigateToTopic('topic-3');
});
expect(focusTopicPopupMock).toHaveBeenCalledWith('topic-3');
expect(pushMock).not.toHaveBeenCalled();
expect(switchTopicMock).toHaveBeenCalledWith('topic-3');
expect(toggleMobileTopicMock).toHaveBeenCalledWith(false);
});
});

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import urlJoin from 'url-join';
import { useFocusTopicPopup } from '@/features/TopicPopupGuard/useTopicPopupsRegistry';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { usePathname } from '@/libs/router/navigation';
import { useChatStore } from '@/store/chat';
@ -10,12 +11,17 @@ import { useGlobalStore } from '@/store/global';
* Hook to handle topic navigation with automatic route detection
* If in agent sub-route (e.g., /agent/:aid/profile), navigate back to chat first
*/
interface NavigateToTopicOptions {
skipPopupFocus?: boolean;
}
export const useTopicNavigation = () => {
const pathname = usePathname();
const activeAgentId = useChatStore((s) => s.activeAgentId);
const router = useQueryRoute();
const toggleConfig = useGlobalStore((s) => s.toggleMobileTopic);
const switchTopic = useChatStore((s) => s.switchTopic);
const focusTopicPopup = useFocusTopicPopup({ agentId: activeAgentId });
const isInAgentSubRoute = useCallback(() => {
if (!activeAgentId) return false;
@ -29,7 +35,11 @@ export const useTopicNavigation = () => {
}, [pathname, activeAgentId]);
const navigateToTopic = useCallback(
(topicId?: string) => {
async (topicId?: string, options?: NavigateToTopicOptions) => {
if (!options?.skipPopupFocus) {
await focusTopicPopup(topicId);
}
// If in agent sub-route, navigate back to agent chat first
if (isInAgentSubRoute() && activeAgentId) {
const basePath = urlJoin('/agent', activeAgentId as string);
@ -42,10 +52,11 @@ export const useTopicNavigation = () => {
switchTopic(topicId);
toggleConfig(false);
},
[activeAgentId, router, switchTopic, toggleConfig, isInAgentSubRoute],
[activeAgentId, focusTopicPopup, router, switchTopic, toggleConfig, isInAgentSubRoute],
);
return {
focusTopicPopup,
isInAgentSubRoute: isInAgentSubRoute(),
navigateToTopic,
};

View file

@ -0,0 +1,130 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useMenu } from './useMenu';
const messageSuccessMock = vi.hoisted(() => vi.fn());
const modalConfirmMock = vi.hoisted(() => vi.fn());
const openTopicInNewWindowMock = vi.hoisted(() => vi.fn());
const toggleWideScreenMock = vi.hoisted(() => vi.fn());
const favoriteTopicMock = vi.hoisted(() => vi.fn());
const autoRenameTopicTitleMock = vi.hoisted(() => vi.fn());
const removeTopicMock = vi.hoisted(() => vi.fn());
const updateTopicTitleMock = vi.hoisted(() => vi.fn());
const useLocationMock = vi.hoisted(() => vi.fn());
vi.mock('@/components/RenameModal', () => ({
openRenameModal: vi.fn(),
}));
vi.mock('@/const/version', () => ({
isDesktop: true,
}));
vi.mock('@lobehub/ui', () => ({
Icon: () => null,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { success: messageSuccessMock },
modal: { confirm: modalConfirmMock },
}),
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => `${options?.ns ? `${options.ns}:` : ''}${key}`,
}),
}));
vi.mock('react-router-dom', () => ({
useLocation: useLocationMock,
}));
vi.mock('@/store/chat/selectors', () => ({
topicSelectors: {
currentActiveTopic: (state: Record<string, unknown>) => state.activeTopic,
currentTopicWorkingDirectory: (state: Record<string, unknown>) => state.workingDirectory,
},
}));
vi.mock('@/store/chat', () => ({
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
activeAgentId: 'agent-1',
activeTopic: {
favorite: false,
id: 'topic-1',
title: 'Topic 1',
},
autoRenameTopicTitle: autoRenameTopicTitleMock,
favoriteTopic: favoriteTopicMock,
removeTopic: removeTopicMock,
updateTopicTitle: updateTopicTitleMock,
workingDirectory: '/tmp/workdir',
}),
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
openTopicInNewWindow: openTopicInNewWindowMock,
toggleWideScreen: toggleWideScreenMock,
}),
}));
vi.mock('@/store/global/selectors', () => ({
systemStatusSelectors: {
wideScreen: () => false,
},
}));
const isActionItem = (
item: unknown,
): item is {
key: string;
label?: unknown;
onClick?: () => void;
} => !!item && typeof item === 'object' && 'key' in item;
describe('Conversation header action menu', () => {
it('includes the desktop popup-window action for the active topic', () => {
useLocationMock.mockReturnValue({ pathname: '/agent/agent-1' });
const { result } = renderHook(() => useMenu());
const popupItem = result.current.menuItems.find(
(item) => isActionItem(item) && item.key === 'openInPopupWindow',
);
expect(popupItem).toBeDefined();
if (!isActionItem(popupItem)) {
throw new Error('Expected popup menu item to be a clickable action item');
}
expect(popupItem?.label).toBe('topic:inPopup.title');
act(() => {
popupItem?.onClick?.();
});
expect(openTopicInNewWindowMock).toHaveBeenCalledWith('agent-1', 'topic-1');
});
it('does not include the popup-window action inside popup routes', () => {
useLocationMock.mockReturnValue({ pathname: '/popup/agent/agent-1/topic-1' });
const { result } = renderHook(() => useMenu());
const popupItem = result.current.menuItems.find(
(item) => isActionItem(item) && item.key === 'openInPopupWindow',
);
expect(popupItem).toBeUndefined();
});
});

View file

@ -2,9 +2,10 @@
import { type DropdownItem, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { Copy, Hash, Maximize2, PencilLine, Star, Trash, Wand2 } from 'lucide-react';
import { Copy, ExternalLink, Hash, Maximize2, PencilLine, Star, Trash, Wand2 } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { openRenameModal } from '@/components/RenameModal';
import { isDesktop } from '@/const/version';
@ -16,12 +17,15 @@ import { systemStatusSelectors } from '@/store/global/selectors';
export const useMenu = (): { menuItems: DropdownItem[] } => {
const { t } = useTranslation(['chat', 'topic', 'common']);
const { modal, message } = App.useApp();
const { pathname } = useLocation();
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
systemStatusSelectors.wideScreen(s),
s.toggleWideScreen,
]);
const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow);
const activeAgentId = useChatStore((s) => s.activeAgentId);
const activeTopic = useChatStore(topicSelectors.currentActiveTopic);
const workingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const [autoRenameTopicTitle, favoriteTopic, removeTopic, updateTopicTitle] = useChatStore((s) => [
@ -87,6 +91,17 @@ export const useMenu = (): { menuItems: DropdownItem[] } => {
});
}
if (isDesktop && activeAgentId && !pathname.startsWith('/popup')) {
items.push({
icon: <Icon icon={ExternalLink} />,
key: 'openInPopupWindow',
label: t('inPopup.title', { ns: 'topic' }),
onClick: () => {
openTopicInNewWindow(activeAgentId, topicId);
},
});
}
items.push(
{
icon: <Icon icon={Hash} />,
@ -137,10 +152,13 @@ export const useMenu = (): { menuItems: DropdownItem[] } => {
topicId,
topicTitle,
isFavorite,
activeAgentId,
pathname,
workingDirectory,
wideScreen,
autoRenameTopicTitle,
favoriteTopic,
openTopicInNewWindow,
removeTopic,
updateTopicTitle,
toggleWideScreen,

View file

@ -5,17 +5,23 @@ import { ActionIcon } from '@lobehub/ui';
import { PanelRightOpenIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
const WorkingPanelToggle = memo(() => {
const { t } = useTranslation('chat');
const { pathname } = useLocation();
const [showRightPanel, toggleRightPanel] = useGlobalStore((s) => [
systemStatusSelectors.showRightPanel(s),
s.toggleRightPanel,
]);
// The popup window has no WorkingSidebar — hide the toggle to avoid a
// button that does nothing visible.
if (pathname.startsWith('/popup')) return null;
if (showRightPanel) return null;
return (

View file

@ -0,0 +1,136 @@
/**
* @vitest-environment happy-dom
*/
import { act, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initialState as initialChatState } from '@/store/chat/initialState';
import { useChatStore } from '@/store/chat/store';
import ChatPage from './index.desktop';
vi.hoisted(() => {
const storage = {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
};
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
});
vi.mock('@lobehub/ui', () => ({
Flexbox: ({
children,
horizontal,
...props
}: {
children?: ReactNode;
horizontal?: boolean;
[key: string]: unknown;
}) => (
<div data-horizontal={horizontal ? 'true' : undefined} {...props}>
{children}
</div>
),
ShikiLobeTheme: {},
}));
vi.mock('@/components/Analytics/MainInterfaceTracker', () => ({
default: () => <div data-testid="main-interface-tracker" />,
}));
vi.mock('@/features/TopicPopupGuard', () => ({
default: () => <div data-testid="topic-popup-guard" />,
}));
vi.mock('@/features/TopicPopupGuard/useTopicPopupsRegistry', () => ({
useTopicInPopup: ({ topicId }: { topicId: string }) =>
topicId === 'popup-topic'
? {
agentId: 'agent-1',
identifier: 'popup-1',
scope: 'agent',
topicId: 'popup-topic',
}
: undefined,
}));
vi.mock('./features/Conversation', () => ({
default: () => <div data-testid="conversation" />,
}));
vi.mock('./features/Conversation/WorkingSidebar', () => ({
default: () => <div data-testid="working-sidebar" />,
}));
vi.mock('./features/PageTitle', () => ({
default: () => <div data-testid="page-title" />,
}));
vi.mock('./features/Portal', () => ({
default: () => <div data-testid="portal" />,
}));
vi.mock('./features/TelemetryNotification', () => ({
default: () => <div data-testid="telemetry-notification" />,
}));
const SearchProbe = () => {
const location = useLocation();
return <div data-testid="search-probe">{location.search}</div>;
};
describe('Agent desktop topic popup guard', () => {
beforeEach(() => {
vi.useFakeTimers();
useChatStore.setState(
{
...initialChatState,
activeAgentId: 'agent-1',
activeTopicId: 'popup-topic',
},
false,
);
});
afterEach(() => {
useChatStore.setState(initialChatState, false);
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('keeps the topic query synchronized while the popup guard is visible', async () => {
render(
<MemoryRouter initialEntries={['/agent/agent-1?topic=popup-topic']}>
<SearchProbe />
<ChatPage />
</MemoryRouter>,
);
expect(screen.getByTestId('topic-popup-guard')).toBeInTheDocument();
expect(screen.getByTestId('search-probe')).toHaveTextContent('?topic=popup-topic');
act(() => {
useChatStore.setState({ activeTopicId: 'other-topic' });
});
expect(screen.queryByTestId('topic-popup-guard')).not.toBeInTheDocument();
expect(screen.getByTestId('conversation')).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(600);
await Promise.resolve();
});
expect(screen.getByTestId('search-probe')).toHaveTextContent('?topic=other-topic');
});
});

View file

@ -0,0 +1,58 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
import TopicInPopupGuard from '@/features/TopicPopupGuard';
import { useTopicInPopup } from '@/features/TopicPopupGuard/useTopicPopupsRegistry';
import { useChatStore } from '@/store/chat';
import Conversation from './features/Conversation';
import ChatHydration from './features/Conversation/ChatHydration';
import AgentWorkingSidebar from './features/Conversation/WorkingSidebar';
import PageTitle from './features/PageTitle';
import Portal from './features/Portal';
import TelemetryNotification from './features/TelemetryNotification';
const ChatPage = memo(() => {
const activeAgentId = useChatStore((s) => s.activeAgentId);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const popup = useTopicInPopup({
agentId: activeAgentId,
topicId: activeTopicId ?? '',
});
// When the same topic is already hosted in a popup window, avoid
// rendering a second (out-of-sync) instance here — guide the user back
// to the popup instead.
if (activeTopicId && popup) {
return (
<>
<ChatHydration />
<PageTitle />
<TopicInPopupGuard popup={popup} />
</>
);
}
return (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Conversation />
<Portal />
<AgentWorkingSidebar />
</Flexbox>
<MainInterfaceTracker />
<TelemetryNotification mobile={false} />
</>
);
});
export default ChatPage;

View file

@ -9,6 +9,7 @@ import DotsLoading from '@/components/DotsLoading';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useFocusTopicPopup } from '@/features/TopicPopupGuard/useTopicPopupsRegistry';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
@ -69,6 +70,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
const toggleMobileTopic = useGlobalStore((s) => s.toggleMobileTopic);
const [activeGroupId, switchTopic] = useAgentGroupStore((s) => [s.activeGroupId, s.switchTopic]);
const addTab = useElectronStore((s) => s.addTab);
const focusTopicPopup = useFocusTopicPopup({ groupId: activeGroupId });
// Construct href for cmd+click support
const href = useMemo(() => {
@ -99,28 +101,39 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
if (isDesktop) {
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
switchTopic(id);
toggleMobileTopic(false);
void (async () => {
await focusTopicPopup(id);
switchTopic(id);
toggleMobileTopic(false);
})();
}, 250);
} else {
switchTopic(id);
toggleMobileTopic(false);
void (async () => {
await focusTopicPopup(id);
switchTopic(id);
toggleMobileTopic(false);
})();
}
}, [editing, id, switchTopic, toggleMobileTopic]);
}, [editing, focusTopicPopup, id, switchTopic, toggleMobileTopic]);
const handleDoubleClick = useCallback(() => {
const handleDoubleClick = useCallback(async () => {
if (!id || !activeGroupId || !isDesktop) return;
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
}
if (await focusTopicPopup(id)) {
switchTopic(id);
toggleMobileTopic(false);
return;
}
const reference = pluginRegistry.parseUrl(`/group/${activeGroupId}`, `topic=${id}`);
if (reference) {
addTab(reference);
switchTopic(id);
toggleMobileTopic(false);
}
}, [id, activeGroupId, addTab, switchTopic, toggleMobileTopic]);
}, [id, activeGroupId, addTab, focusTopicPopup, switchTopic, toggleMobileTopic]);
const dropdownMenu = useTopicItemDropdownMenu({
id,
@ -220,7 +233,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
iconPostfix: unreadNode,
}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onDoubleClick={() => void handleDoubleClick()}
/>
<Editing id={id} title={title} toggleEditing={toggleEditing} />
{active && (

View file

@ -9,7 +9,6 @@ import { useNavigate } from 'react-router-dom';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
import { useElectronStore } from '@/store/electron';
@ -28,8 +27,7 @@ export const useTopicItemDropdownMenu = ({
const { modal, message } = App.useApp();
const navigate = useNavigate();
const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const openGroupTopicInNewWindow = useGlobalStore((s) => s.openGroupTopicInNewWindow);
const activeGroupId = useAgentGroupStore((s) => s.activeGroupId);
const addTab = useElectronStore((s) => s.addTab);
const appOrigin = useAppOrigin();
@ -84,7 +82,7 @@ export const useTopicItemDropdownMenu = ({
key: 'openInNewWindow',
label: t('actions.openInNewWindow'),
onClick: () => {
if (activeAgentId) openTopicInNewWindow(activeAgentId, id);
if (activeGroupId) openGroupTopicInNewWindow(activeGroupId, id);
},
},
{
@ -133,13 +131,12 @@ export const useTopicItemDropdownMenu = ({
].filter(Boolean) as MenuProps['items'];
}, [
id,
activeAgentId,
activeGroupId,
appOrigin,
autoRenameTopicTitle,
duplicateTopic,
removeTopic,
openTopicInNewWindow,
openGroupTopicInNewWindow,
addTab,
navigate,
toggleEditing,

View file

@ -0,0 +1,53 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
import TopicInPopupGuard from '@/features/TopicPopupGuard';
import { useTopicInPopup } from '@/features/TopicPopupGuard/useTopicPopupsRegistry';
import { useChatStore } from '@/store/chat';
import Conversation from './features/Conversation';
import ChatHydration from './features/Conversation/ChatHydration';
import PageTitle from './features/PageTitle';
import Portal from './features/Portal';
import TelemetryNotification from './features/TelemetryNotification';
const ChatPage = memo(() => {
const activeGroupId = useChatStore((s) => s.activeGroupId);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const popup = useTopicInPopup({
groupId: activeGroupId,
topicId: activeTopicId ?? '',
});
if (activeTopicId && popup) {
return (
<>
<ChatHydration />
<PageTitle />
<TopicInPopupGuard popup={popup} />
</>
);
}
return (
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Conversation />
<Portal />
</Flexbox>
<MainInterfaceTracker />
<TelemetryNotification mobile={false} />
</>
);
});
export default ChatPage;

View file

@ -0,0 +1,48 @@
'use client';
import { ActionIcon } from '@lobehub/ui';
import { PinIcon, PinOffIcon } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { electronSystemService } from '@/services/electron/system';
import { electronStylish } from '@/styles/electron';
const PinOnTopButton = memo(() => {
const { t } = useTranslation('electron');
const [pinned, setPinned] = useState(false);
useEffect(() => {
let mounted = true;
void electronSystemService.isWindowAlwaysOnTop().then((value) => {
if (mounted) setPinned(value);
});
return () => {
mounted = false;
};
}, []);
const toggle = async () => {
const next = !pinned;
await electronSystemService.setWindowAlwaysOnTop(next);
setPinned(next);
};
return (
<ActionIcon
active={pinned}
className={electronStylish.nodrag}
icon={pinned ? PinIcon : PinOffIcon}
size={{ blockSize: 28, size: 14 }}
tooltipProps={{ placement: 'bottom' }}
title={t(pinned ? 'window.unpinFromTop' : 'window.pinToTop', {
defaultValue: pinned ? 'Unpin from top' : 'Pin on top',
})}
onClick={toggle}
/>
);
});
PinOnTopButton.displayName = 'PinOnTopButton';
export default PinOnTopButton;

View file

@ -0,0 +1,71 @@
'use client';
import { TITLE_BAR_HEIGHT } from '@lobechat/desktop-bridge';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useWatchThemeUpdate } from '@/features/Electron/system/useWatchThemeUpdate';
import WinControl, { WINDOW_CONTROL_WIDTH } from '@/features/Electron/titlebar/WinControl';
import { electronStylish } from '@/styles/electron';
import { getPlatform, isMacOS } from '@/utils/platform';
import PinOnTopButton from './PinOnTopButton';
// Reserve space for macOS traffic lights when titleBarStyle is hidden.
const MAC_TRAFFIC_LIGHT_WIDTH = 80;
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
user-select: none;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
background: ${cssVar.colorBgLayout};
`,
title: css`
overflow: hidden;
font-size: 13px;
color: ${cssVar.colorTextSecondary};
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
interface PopupTitleBarProps {
title?: string;
}
const PopupTitleBar = memo<PopupTitleBarProps>(({ title }) => {
useWatchThemeUpdate();
const isMac = isMacOS();
const isLinux = getPlatform() === 'Linux';
const showWinControl = !isMac && isLinux;
const leftSpacer = isMac ? MAC_TRAFFIC_LIGHT_WIDTH : 0;
const rightSpacer = showWinControl ? WINDOW_CONTROL_WIDTH : 0;
return (
<Flexbox
horizontal
align={'center'}
className={cx(styles.container, electronStylish.draggable)}
flex={'none'}
gap={4}
height={TITLE_BAR_HEIGHT}
style={{ minHeight: TITLE_BAR_HEIGHT, paddingInline: 8 }}
width={'100%'}
>
<div style={{ flex: `0 0 ${leftSpacer}px` }} />
<Flexbox flex={1} style={{ minWidth: 0 }}>
{title && <div className={styles.title}>{title}</div>}
</Flexbox>
<PinOnTopButton />
{showWinControl ? <WinControl /> : <div style={{ flex: `0 0 ${rightSpacer}px` }} />}
</Flexbox>
);
});
PopupTitleBar.displayName = 'PopupTitleBar';
export default PopupTitleBar;

View file

@ -0,0 +1,35 @@
'use client';
import { HotkeyScopeEnum } from '@lobechat/const/hotkeys';
import { Flexbox } from '@lobehub/ui';
import { type FC } from 'react';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { Outlet } from 'react-router-dom';
import { isDesktop } from '@/const/version';
import ProtocolUrlHandler from '@/features/ProtocolUrlHandler';
import { MarketAuthProvider } from '@/layout/AuthProvider/MarketAuth';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import PopupTitleBar from './TitleBar';
const PopupLayout: FC = () => {
const topicTitle = useChatStore((s) => topicSelectors.currentActiveTopic(s)?.title);
return (
<HotkeysProvider initiallyActiveScopes={[HotkeyScopeEnum.Global]}>
<MarketAuthProvider isDesktop={isDesktop}>
<Flexbox height={'100%'} style={{ overflow: 'hidden' }} width={'100%'}>
<PopupTitleBar title={topicTitle} />
<Flexbox flex={1} style={{ minHeight: 0, overflow: 'hidden', position: 'relative' }}>
<Outlet />
</Flexbox>
{isDesktop && <ProtocolUrlHandler />}
</Flexbox>
</MarketAuthProvider>
</HotkeysProvider>
);
};
export default PopupLayout;

View file

@ -0,0 +1,43 @@
'use client';
import { memo, useLayoutEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
import Conversation from '@/routes/(main)/agent/features/Conversation';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
const PopupAgentTopicPage = memo(() => {
const { aid, tid } = useParams<{ aid: string; tid: string }>();
useInitAgentConfig(aid);
useLayoutEffect(() => {
if (!aid) return;
useAgentStore.setState({ activeAgentId: aid }, false, 'PopupAgentTopicPage/sync');
useChatStore.setState(
{
activeAgentId: aid,
activeGroupId: undefined,
activeThreadId: undefined,
activeTopicId: tid,
},
false,
'PopupAgentTopicPage/sync',
);
}, [aid, tid]);
// Populate topicDataMap so the title-bar can resolve the topic title,
// and so chat operations that read topic metadata behave correctly.
useFetchTopics();
if (!aid || !tid) return null;
return <Conversation />;
});
PopupAgentTopicPage.displayName = 'PopupAgentTopicPage';
export default PopupAgentTopicPage;

View file

@ -0,0 +1,41 @@
'use client';
import { memo, useLayoutEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useFetchTopics } from '@/hooks/useFetchTopics';
import { useInitGroupConfig } from '@/hooks/useInitGroupConfig';
import GroupConversation from '@/routes/(main)/group/features/Conversation';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
const PopupGroupTopicPage = memo(() => {
const { gid, tid } = useParams<{ gid: string; tid: string }>();
useInitGroupConfig();
useLayoutEffect(() => {
if (!gid) return;
useAgentGroupStore.setState({ activeGroupId: gid }, false, 'PopupGroupTopicPage/sync');
useChatStore.setState(
{
activeAgentId: undefined,
activeGroupId: gid,
activeThreadId: undefined,
activeTopicId: tid,
},
false,
'PopupGroupTopicPage/sync',
);
}, [gid, tid]);
useFetchTopics();
if (!gid || !tid) return null;
return <GroupConversation />;
});
PopupGroupTopicPage.displayName = 'PopupGroupTopicPage';
export default PopupGroupTopicPage;

View file

@ -45,6 +45,14 @@ class ElectronSystemService {
return this.ipc.windows.minimizeWindow();
}
async setWindowAlwaysOnTop(flag: boolean): Promise<void> {
return this.ipc.windows.setWindowAlwaysOnTop(flag);
}
async isWindowAlwaysOnTop(): Promise<boolean> {
return this.ipc.windows.isWindowAlwaysOnTop();
}
async setWindowSize(params: WindowSizeParams): Promise<void> {
return this.ipc.windows.setWindowSize(params);
}

12
src/spa/entry.popup.tsx Normal file
View file

@ -0,0 +1,12 @@
import '../initialize';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { createAppRouter } from '@/utils/router';
import { popupRoutes } from './router/popupRouter.config';
const router = createAppRouter(popupRoutes);
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />);

View file

@ -0,0 +1,32 @@
'use client';
import type { RouteObject } from 'react-router-dom';
import PopupLayout from '@/routes/(popup)/_layout';
import PopupAgentTopicPage from '@/routes/(popup)/agent/[aid]/[tid]';
import PopupGroupTopicPage from '@/routes/(popup)/group/[gid]/[tid]';
import { ErrorBoundary, redirectElement } from '@/utils/router';
// Popup router configuration — dedicated SPA entry for single-topic windows.
// Desktop-only; no sidebar, no portal, hosts a single conversation per window.
export const popupRoutes: RouteObject[] = [
{
children: [
{
element: <PopupAgentTopicPage />,
path: 'agent/:aid/:tid',
},
{
element: <PopupGroupTopicPage />,
path: 'group/:gid/:tid',
},
{
element: redirectElement('/popup'),
path: '*',
},
],
element: <PopupLayout />,
errorElement: <ErrorBoundary resetPath="/popup" />,
path: '/popup',
},
];

View file

@ -65,17 +65,17 @@ export class GlobalGeneralActionImpl {
};
openTopicInNewWindow = async (agentId: string, topicId: string): Promise<void> => {
const url = `/agent/${agentId}?topic=${topicId}${isDesktop ? '&mode=single' : ''}`;
const popupPath = `/popup/agent/${agentId}/${topicId}`;
const browserUrl = `/agent/${agentId}?topic=${topicId}`;
if (isDesktop) {
try {
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
const path = `/agent/${agentId}?topic=${topicId}&mode=single`;
const result = await ensureElectronIpc().windows.createMultiInstanceWindow({
path,
templateId: 'chatSingle',
uniqueId: `chat_${agentId}_${topicId}`,
path: popupPath,
templateId: 'topicPopup',
uniqueId: `topicPopup_agent_${agentId}_${topicId}`,
});
if (!result.success) {
@ -91,7 +91,37 @@ export class GlobalGeneralActionImpl {
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
const features = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=yes`;
window.open(url, `agent_${agentId}_topic_${topicId}`, features);
window.open(browserUrl, `agent_${agentId}_topic_${topicId}`, features);
}
};
openGroupTopicInNewWindow = async (groupId: string, topicId: string): Promise<void> => {
const popupPath = `/popup/group/${groupId}/${topicId}`;
const browserUrl = `/group/${groupId}?topic=${topicId}`;
if (isDesktop) {
try {
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
const result = await ensureElectronIpc().windows.createMultiInstanceWindow({
path: popupPath,
templateId: 'topicPopup',
uniqueId: `topicPopup_group_${groupId}_${topicId}`,
});
if (!result.success) {
console.error('Failed to open group topic in new window:', result.error);
}
} catch (error) {
console.error('Error opening group topic in new window:', error);
}
} else {
const width = 1200;
const height = 800;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
const features = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,status=yes`;
window.open(browserUrl, `group_${groupId}_topic_${topicId}`, features);
}
};

View file

@ -86,7 +86,12 @@ export function dynamicLayout<P = NonNullable<unknown>>(
);
}
export const ErrorBoundary = () => {
export interface ErrorBoundaryProps {
/** Base path for "back home" on the error screen (defaults to `/`). */
resetPath?: string;
}
export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => {
const error = useRouteError() as Error;
if (typeof window !== 'undefined' && isChunkLoadError(error)) {
@ -95,7 +100,7 @@ export const ErrorBoundary = () => {
return (
<ThemeProvider theme={{ cssVar: { key: 'lobe-vars' } }}>
<ErrorCapture error={error} />
<ErrorCapture error={error} resetPath={resetPath} />
</ThemeProvider>
);
};