mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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:
parent
c213483a7a
commit
2711aa9191
40 changed files with 1421 additions and 44 deletions
|
|
@ -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
114
apps/desktop/popup.html
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -133,5 +133,7 @@
|
|||
"window.close": "关闭窗口",
|
||||
"window.maximize": "最大化窗口",
|
||||
"window.minimize": "最小化窗口",
|
||||
"window.restore": "恢复窗口"
|
||||
"window.pinToTop": "窗口置顶",
|
||||
"window.restore": "恢复窗口",
|
||||
"window.unpinFromTop": "取消置顶"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@
|
|||
"importInvalidFormat": "文件格式不正确。请确认这是有效的 JSON 文件",
|
||||
"importLoading": "正在导入对话…",
|
||||
"importSuccess": "已导入 {{count}} 条消息",
|
||||
"inPopup.description": "此话题已在独立窗口中打开,请前往该窗口继续对话以保持消息一致。",
|
||||
"inPopup.focus": "聚焦独立窗口",
|
||||
"inPopup.title": "已在独立窗口中打开",
|
||||
"loadMore": "更多",
|
||||
"newTopic": "新话题",
|
||||
"renameModal.description": "保持简短且易于识别。",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
11
packages/electron-client-ipc/src/events/topicPopup.ts
Normal file
11
packages/electron-client-ipc/src/events/topicPopup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
28
packages/electron-client-ipc/src/types/topicPopup.ts
Normal file
28
packages/electron-client-ipc/src/types/topicPopup.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
69
src/features/TopicPopupGuard/index.tsx
Normal file
69
src/features/TopicPopupGuard/index.tsx
Normal 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;
|
||||
117
src/features/TopicPopupGuard/useTopicPopupsRegistry.ts
Normal file
117
src/features/TopicPopupGuard/useTopicPopupsRegistry.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
136
src/routes/(main)/agent/index.desktop.test.tsx
Normal file
136
src/routes/(main)/agent/index.desktop.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
58
src/routes/(main)/agent/index.desktop.tsx
Normal file
58
src/routes/(main)/agent/index.desktop.tsx
Normal 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;
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
src/routes/(main)/group/index.desktop.tsx
Normal file
53
src/routes/(main)/group/index.desktop.tsx
Normal 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;
|
||||
48
src/routes/(popup)/_layout/PinOnTopButton.tsx
Normal file
48
src/routes/(popup)/_layout/PinOnTopButton.tsx
Normal 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;
|
||||
71
src/routes/(popup)/_layout/TitleBar.tsx
Normal file
71
src/routes/(popup)/_layout/TitleBar.tsx
Normal 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;
|
||||
35
src/routes/(popup)/_layout/index.tsx
Normal file
35
src/routes/(popup)/_layout/index.tsx
Normal 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;
|
||||
43
src/routes/(popup)/agent/[aid]/[tid]/index.tsx
Normal file
43
src/routes/(popup)/agent/[aid]/[tid]/index.tsx
Normal 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;
|
||||
41
src/routes/(popup)/group/[gid]/[tid]/index.tsx
Normal file
41
src/routes/(popup)/group/[gid]/[tid]/index.tsx
Normal 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;
|
||||
|
|
@ -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
12
src/spa/entry.popup.tsx
Normal 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} />);
|
||||
32
src/spa/router/popupRouter.config.tsx
Normal file
32
src/spa/router/popupRouter.config.tsx
Normal 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',
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue