feat(agent-page): bind documentId to URL and introduce HeaderSlot
Some checks are pending
E2E CI / Check Duplicate Run (push) Waiting to run
E2E CI / Test Web App (push) Blocked by required conditions
Test CI / Check Duplicate Run (push) Waiting to run
Test CI / Test Packages (push) Blocked by required conditions
Test CI / Test App (shard 1/3) (push) Blocked by required conditions
Test CI / Test App (shard 2/3) (push) Blocked by required conditions
Test CI / Test App (shard 3/3) (push) Blocked by required conditions
Test CI / Merge and Upload App Coverage (push) Blocked by required conditions
Test CI / Test Desktop App (push) Blocked by required conditions
Test CI / Test Database (push) Blocked by required conditions

- Add nested /agent/:aid/:topicId/page/:docId route with PageRedirect for bare /page
- Introduce useAutoCreateTopicDocument with module-level inflight de-dup
- Lift Portal + WorkingSidebar to (chat) layout; keep ChatHeader in left column
- Sidebar document clicks on page route navigate to /page/:docId instead of opening Portal
- Add HeaderSlot (context + createPortal) as a reusable header injection point
- Mount AutoSaveHint via HeaderSlot; register Files hotkey scope in TopicCanvas so Cmd+S triggers manual save
- Sync desktopRouter.config.tsx and desktopRouter.config.desktop.tsx
- Extend RecentlyViewed plugin to round-trip optional docId segment
This commit is contained in:
Innei 2026-04-20 22:28:00 +08:00
parent 7c3071d590
commit a08b3e396f
No known key found for this signature in database
GPG key ID: 0F62D33977F021F7
19 changed files with 547 additions and 60 deletions

View file

@ -0,0 +1,182 @@
# Topic Page URL Binding
Date: 2026-04-20
Scope: SPA routing for the agent topic page view.
## Context
Today `/agent/:aid/:topicId/page` renders `TopicPage`, which maintains the
currently-active document in React state (hoisted to `(chat)/_layout` via
`PageDocContext`). Clicking a document in the resources sidebar swaps the
in-memory `active` and replaces canvas content. The URL never reflects which
document is being edited, so the state cannot be deep-linked, refreshed, or
navigated via browser back/forward.
## Goals
1. Make the document id a first-class URL segment: `/page/:docId`.
2. Keep `/page` (bare) as a convenient entry — it redirects to the topic's
auto-created notebook document.
3. Gracefully fall back when the URL carries an invalid `docId`.
4. Remove the layout-level context now that URL is the source of truth.
## Decisions
- **Default document on entry**: the topic's auto-created notebook document
(`useAutoCreateTopicDocument`). Agent documents opened via the sidebar are
transient selections reflected only in URL; no cross-session memory.
- **Bare `/page`**: component mounts, waits for `useAutoCreateTopicDocument`
to resolve a `topicDocId`, then `navigate(/page/<topicDocId>, { replace })`.
- **Invalid `/page/:docId`**: silent fallback to `/page/<topicDocId>` via
`navigate(replace)`. No error UI.
## Architecture
### Route tree
Update `src/spa/router/desktopRouter.config.tsx` **and**
`src/spa/router/desktopRouter.config.desktop.tsx` together. Register a nested
route tree for `page`:
```
path: 'page'
├── { index: true } → PageRedirect
└── { path: ':docId' } → TopicPage
```
### Components
**`src/routes/(main)/agent/features/Page/PageRedirect.tsx`** (new)
- Reads `topicId` from `useParams`.
- Uses `useAutoCreateTopicDocument(topicId)` to fetch or auto-create.
- Once a `document.id` is available, calls
`navigate(`/agent/${aid}/${topicId}/page/${document.id}`, { replace: true })`.
- While loading, renders the existing editor skeleton (reuse
`EditorCanvas`'s skeleton styling or a lightweight centered `Spin`).
**`src/routes/(main)/agent/features/Page/index.tsx`** (update)
- `docId` comes from `useParams<{ docId: string }>()`.
- Fetch metadata via
`useClientDataSWR(['page-document-meta', docId], () => documentService.getDocumentById(docId))`.
- If the fetch resolves to `null`/throws, call
`useAutoCreateTopicDocument(topicId)` and
`navigate(`/agent/${aid}/${topicId}/page/${topicDocId}`, { replace: true })`.
- When valid, render `TopicCanvas` with `documentId={docId}` plus the
debounced title save. Saving covers both doc kinds by:
1. `documentService.updateDocument({ id, title, saveSource: 'autosave' })`
2. `mutate(agentDocumentSWRKeys.documentsList(agentId))` (refreshes
sidebar tiles when the doc is an agent-document)
3. `mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId])` (refreshes
notebook list so topic-notebook docs reflect the new title)
The notebook key may be inlined or exported from `src/store/notebook/action.ts`.
- Reset `titleDraft` when `docId` changes.
**`src/routes/(main)/agent/features/Conversation/WorkingSidebar/ResourcesSection/AgentDocumentsGroup.tsx`** (update)
- `DocumentItem` becomes URL-aware: each leaf calls
`useMatch('/agent/:aid/:topicId/page/:docId?')` itself.
- When the match hits, `handleOpen` calls
`navigate(/agent/${aid}/${topicId}/page/${document.documentId})`
instead of the old `onOpenDocument` / `openDocument(Portal)` path.
- `isActive` derived from the match's `params.docId`.
- Fall back to `openDocument` (Portal) only when no page-match (chat mode).
- Prop chain for `onOpenDocument` / `activeDocumentId` is dropped:
intermediate components no longer need to know.
**`src/routes/(main)/agent/features/Conversation/WorkingSidebar/index.tsx`** (update)
- Remove the `onOpenDocument` / `activeDocumentId` props added earlier.
- No URL awareness lives at this level — the decision is pushed down to
`DocumentItem` so intermediate components stay dumb.
- `useMatch('/agent/:aid/:topicId/page/*')` may still be used here **only**
to decide whether the sidebar header, etc. should take page-specific
styling (if needed); leave out if not necessary.
- Critically: `pathname.endsWith('/page')` must **not** be used — it
breaks once the URL carries `/:docId`.
**`src/routes/(main)/agent/(chat)/_layout/index.tsx`** (update)
- Keep horizontal layout, keep `Portal` + `AgentWorkingSidebar` mounts.
- Remove the `PageDocContext` provider and the `useState` for `active`.
**`src/routes/(main)/agent/(chat)/_layout/pageDocContext.ts`** (delete)
**`src/features/TopicCanvas/useAutoCreateTopicDocument.ts`** (harden)
- Promote the `creatingRef` de-dup from component-instance scope to a
module-level `Map<topicId, Promise<NotebookDocument>>`. Concurrent
mounts / rapid remounts then share the same in-flight creation promise
and never issue duplicate inserts.
- The module-level map entry is cleared on promise resolution or
rejection.
**`src/features/Electron/titlebar/RecentlyViewed/plugins/agentTopicPagePlugin.ts`** (update)
- Extend `AGENT_TOPIC_PAGE_PATH_REGEX` to tolerate an optional trailing
`/:docId` segment:
`^\/agent\/([^/?]+)\/(tpc_[^/?]+)\/page(?:\/[^/?]+)?$`
- `generateUrl` continues to return bare `/page` (round-trips into the
redirect); callers that want a deep link with docId can carry it in
`reference.params` and include it in the path. Add
`docId?: string` to `AgentTopicPageParams` (in `types.ts`) and plumb
through `parseUrl` (capture 3rd group) and `generateUrl` (append
`/${docId}` when present).
## Data Flow
```
URL /page/:docId (source of truth)
├─▶ TopicPage
│ • fetch doc metadata + validate
│ • render TopicCanvas with documentId
│ • debounced title save via documentService
└─▶ AgentWorkingSidebar
• reads docId via useParams
• highlights matching DocumentItem
• click → navigate(/page/:newId)
```
Clicking a doc in the sidebar causes a URL change; both `TopicPage` and
the sidebar re-render to match.
## Error Handling
| Case | Behavior |
| -------------------------------------- | ----------------------------------------------------------------------------- |
| Bare `/page` | Mount `PageRedirect`, auto-create if needed, `navigate(replace)` to concrete. |
| `/page/:docId` with nonexistent docId | Fetch returns null → `navigate(replace)` to topic doc. |
| `/page/:docId` belonging to other user | Same as above (fetch returns null). |
| `topicDocId` creation fails | Existing `notebookStore.createDocument` error surface; no special UI. |
## Testing
- Enter `/agent/:aid/:topicId/page` bare → URL rewrites to `/page/<topicDocId>`.
- Enter `/page/<topicDocId>` directly → canvas loads without redirect.
- Enter `/page/<invalid>` → URL rewrites to topic doc; no error banner.
- Click another document in sidebar → URL changes, canvas swaps, sidebar
highlight follows (verifies `useMatch` detection works on `/page/:docId`).
- Browser back/forward cycles through visited docs.
- Refresh `/page/<docId>` → same doc loads.
- Switch topic (`:topicId` change) → URL reset to that topic's main doc;
previous in-memory selection discarded.
- Chat route `/agent/:aid/:topicId` — Portal/Sidebar still behave as before;
clicking sidebar doc opens Portal (not page).
- Title edit persists for **both** topic-notebook docs and agent-docs
(verify notebook-store SWR cache + agent-documents SWR cache both
show the new title after debounce).
- Rapid remount of the topic page (e.g., hot-reload or fast topic
switching) does not produce duplicate topic-notebook docs (module-level
de-dup map).
- Electron Recently Viewed entry for `/page/<docId>` round-trips: parse
→ resolve → generate yields the same URL including docId.
## Out of Scope
- Per-topic memory of last-viewed document (rejected in favor of A).
- Multi-document tabbing inside the page view.
- Emoji / icon persistence for topic docs.
- Cross-agent document sharing UX.

View file

@ -8,7 +8,7 @@ import { type AgentTopicPageParams, type PageReference, type ResolvedPageData }
import { type PluginContext, type RecentlyViewedPlugin } from './types';
import { createPageReference } from './types';
const AGENT_TOPIC_PAGE_PATH_REGEX = /^\/agent\/([^/?]+)\/(tpc_[^/?]+)\/page$/;
const AGENT_TOPIC_PAGE_PATH_REGEX = /^\/agent\/([^/?]+)\/(tpc_[^/?]+)\/page(?:\/([^/?]+))?$/;
const pageIcon = getRouteById('page')?.icon || FileText;
@ -22,13 +22,16 @@ export const agentTopicPagePlugin: RecentlyViewedPlugin<'agent-topic-page'> = {
},
generateId(reference: PageReference<'agent-topic-page'>): string {
const { agentId, topicId } = reference.params;
return `agent-topic-page:${agentId}:${topicId}`;
const { agentId, topicId, docId } = reference.params;
return docId
? `agent-topic-page:${agentId}:${topicId}:${docId}`
: `agent-topic-page:${agentId}:${topicId}`;
},
generateUrl(reference: PageReference<'agent-topic-page'>): string {
const { agentId, topicId } = reference.params;
return SESSION_CHAT_TOPIC_PAGE_URL(agentId, topicId);
const { agentId, topicId, docId } = reference.params;
const base = SESSION_CHAT_TOPIC_PAGE_URL(agentId, topicId);
return docId ? `${base}/${docId}` : base;
},
getDefaultIcon() {
@ -50,8 +53,8 @@ export const agentTopicPagePlugin: RecentlyViewedPlugin<'agent-topic-page'> = {
const match = pathname.match(AGENT_TOPIC_PAGE_PATH_REGEX);
if (!match) return null;
const [, agentId, topicId] = match;
const params: AgentTopicPageParams = { agentId, topicId };
const [, agentId, topicId, docId] = match;
const params: AgentTopicPageParams = { agentId, topicId, ...(docId ? { docId } : {}) };
const id = this.generateId({ params } as PageReference<'agent-topic-page'>);
return createPageReference('agent-topic-page', params, id);
},

View file

@ -32,6 +32,7 @@ export interface AgentTopicParams {
export interface AgentTopicPageParams {
agentId: string;
docId?: string;
topicId: string;
}

View file

@ -8,6 +8,7 @@ import { memo } from 'react';
import { EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas';
import WideScreenContainer from '@/features/WideScreenContainer';
import { useRegisterFilesHotkeys } from '@/hooks/useHotkeys';
import { StyleSheet } from '@/utils/styles';
import TitleSection, { type TitleSectionProps } from './TitleSection';
@ -32,15 +33,18 @@ const styles = StyleSheet.create({
export interface TopicCanvasProps extends TitleSectionProps {
agentId?: string;
documentId?: string;
placeholder?: string;
style?: CSSProperties;
topicId?: string | null;
}
const TopicCanvasBody = memo<TopicCanvasProps>(
({ placeholder, style, emoji, title, onEmojiChange, onTitleChange }) => {
({ placeholder, style, emoji, title, documentId, onEmojiChange, onTitleChange }) => {
const editor = useEditor();
useRegisterFilesHotkeys();
return (
<Flexbox
horizontal
@ -57,7 +61,13 @@ const TopicCanvasBody = memo<TopicCanvasProps>(
onEmojiChange={onEmojiChange}
onTitleChange={onTitleChange}
/>
<SharedEditorCanvas editor={editor} placeholder={placeholder} style={style} />
<SharedEditorCanvas
documentId={documentId}
editor={editor}
placeholder={placeholder}
sourceType={'notebook'}
style={style}
/>
</Flexbox>
</WideScreenContainer>
</Flexbox>

View file

@ -0,0 +1,57 @@
'use client';
import type { NotebookDocument } from '@lobechat/types';
import { useEffect } from 'react';
import { notebookSelectors, useNotebookStore } from '@/store/notebook';
interface UseAutoCreateTopicDocumentResult {
document: NotebookDocument | undefined;
isLoading: boolean;
}
const inflight = new Map<string, Promise<unknown>>();
/**
* Fetch the topic-scoped notebook document for a topic; auto-create one when
* the list is empty. Returns the first document (topic page is 1:1 in practice).
*
* Deduplicates concurrent creations across component instances via a module-level
* promise map keyed by topicId.
*/
export const useAutoCreateTopicDocument = (
topicId: string | undefined,
): UseAutoCreateTopicDocumentResult => {
const useFetchDocuments = useNotebookStore((s) => s.useFetchDocuments);
const createDocument = useNotebookStore((s) => s.createDocument);
const { isLoading } = useFetchDocuments(topicId);
const documents = useNotebookStore(notebookSelectors.getDocumentsByTopicId(topicId));
useEffect(() => {
if (!topicId || isLoading) return;
if (documents.length > 0) return;
if (inflight.has(topicId)) return;
const promise = createDocument({
content: '',
description: '',
title: '',
topicId,
type: 'markdown',
})
.catch((error) => {
console.error('[TopicCanvas] Failed to auto-create topic document:', error);
})
.finally(() => {
inflight.delete(topicId);
});
inflight.set(topicId, promise);
}, [topicId, isLoading, documents.length, createDocument]);
return {
document: documents[0],
isLoading,
};
};

View file

@ -0,0 +1,39 @@
'use client';
import { createContext, memo, type ReactNode, use, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
interface HeaderSlotContextValue {
el: HTMLElement | null;
setEl: (el: HTMLElement | null) => void;
}
const HeaderSlotContext = createContext<HeaderSlotContextValue>({
el: null,
setEl: () => {},
});
const Provider = memo<{ children: ReactNode }>(({ children }) => {
const [el, setEl] = useState<HTMLElement | null>(null);
const value = useMemo<HeaderSlotContextValue>(() => ({ el, setEl }), [el]);
return <HeaderSlotContext value={value}>{children}</HeaderSlotContext>;
});
Provider.displayName = 'HeaderSlotProvider';
const Outlet = memo(() => {
const { setEl } = use(HeaderSlotContext);
return <span ref={setEl} />;
});
Outlet.displayName = 'HeaderSlotOutlet';
const HeaderSlot = memo<{ children: ReactNode }>(({ children }) => {
const { el } = use(HeaderSlotContext);
if (!el) return null;
return createPortal(children, el);
});
HeaderSlot.displayName = 'HeaderSlot';
export default Object.assign(HeaderSlot, { Outlet, Provider });

View file

@ -5,19 +5,35 @@ import { memo } from 'react';
import { Outlet } from 'react-router-dom';
import ChatHeader from '@/routes/(main)/agent/features/Conversation/Header';
import AgentWorkingSidebar from '@/routes/(main)/agent/features/Conversation/WorkingSidebar';
import Portal from '@/routes/(main)/agent/features/Portal';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import HeaderSlot from './HeaderSlot';
const ChatLayout = memo(() => {
const showHeader = useGlobalStore(systemStatusSelectors.showChatHeader);
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0 }} width={'100%'}>
{showHeader && <ChatHeader />}
<Flexbox flex={1} style={{ minHeight: 0, position: 'relative' }}>
<Outlet />
<HeaderSlot.Provider>
<Flexbox
horizontal
flex={1}
height={'100%'}
style={{ minHeight: 0, overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Flexbox flex={1} style={{ minHeight: 0, minWidth: 0 }}>
{showHeader && <ChatHeader />}
<Flexbox flex={1} style={{ minHeight: 0, position: 'relative' }}>
<Outlet />
</Flexbox>
</Flexbox>
<Portal />
<AgentWorkingSidebar />
</Flexbox>
</Flexbox>
</HeaderSlot.Provider>
);
});

View file

@ -0,0 +1 @@
export { default } from '@/routes/(main)/agent/features/Page';

View file

@ -1 +1 @@
export { default } from '@/routes/(main)/agent/features/Page';
export { default } from '@/routes/(main)/agent/features/Page/PageRedirect';

View file

@ -4,15 +4,20 @@ import { ActionIcon, DropdownMenu } from '@lobehub/ui';
import { MoreHorizontal } from 'lucide-react';
import { memo } from 'react';
import HeaderSlot from '@/routes/(main)/agent/(chat)/_layout/HeaderSlot';
import { useMenu } from './useMenu';
const HeaderActions = memo(() => {
const { menuItems } = useMenu();
return (
<DropdownMenu items={menuItems}>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</DropdownMenu>
<>
<HeaderSlot.Outlet />
<DropdownMenu items={menuItems}>
<ActionIcon icon={MoreHorizontal} size={'small'} />
</DropdownMenu>
</>
);
});

View file

@ -59,6 +59,11 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('react-router-dom', () => ({
useMatch: () => null,
useNavigate: () => vi.fn(),
}));
vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: {
documents: (agentId: string) => ['agent-documents', agentId],

View file

@ -6,6 +6,7 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { FileTextIcon, GlobeIcon, type LucideIcon, Trash2Icon } from 'lucide-react';
import { memo, type MouseEvent, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { useClientDataSWR } from '@/libs/swr';
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
@ -13,6 +14,8 @@ import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
const PAGE_ROUTE_PATTERN = '/agent/:aid/:topicId/page/:docId?';
dayjs.extend(relativeTime);
type ResourceFilter = 'all' | 'documents' | 'web';
@ -96,16 +99,18 @@ type AgentDocumentListItem = Awaited<ReturnType<typeof agentDocumentService.getD
interface DocumentItemProps {
agentId: string;
document: AgentDocumentListItem;
isActive: boolean;
mutate: () => Promise<unknown>;
}
const DocumentItem = memo<DocumentItemProps>(({ agentId, document, isActive, mutate }) => {
const DocumentItem = memo<DocumentItemProps>(({ agentId, document, mutate }) => {
const { t } = useTranslation(['chat', 'common']);
const { message, modal } = App.useApp();
const [deleting, setDeleting] = useState(false);
const openDocument = useChatStore((s) => s.openDocument);
const closeDocument = useChatStore((s) => s.closeDocument);
const portalDocumentId = useChatStore(chatPortalSelectors.portalDocumentId);
const navigate = useNavigate();
const pageMatch = useMatch(PAGE_ROUTE_PATTERN);
const title = document.title || document.filename || '';
const description = document.description ?? undefined;
@ -113,8 +118,17 @@ const DocumentItem = memo<DocumentItemProps>(({ agentId, document, isActive, mut
const IconComponent: LucideIcon = isWeb ? GlobeIcon : FileTextIcon;
const createdAtLabel = document.createdAt ? dayjs(document.createdAt).fromNow() : null;
const activeDocumentId = pageMatch ? pageMatch.params.docId : portalDocumentId;
const isActive = activeDocumentId === document.documentId;
const handleOpen = () => {
if (!document.documentId) return;
if (pageMatch?.params.aid && pageMatch.params.topicId) {
navigate(
`/agent/${pageMatch.params.aid}/${pageMatch.params.topicId}/page/${document.documentId}`,
);
return;
}
openDocument(document.documentId);
};
@ -186,7 +200,6 @@ interface AgentDocumentsGroupProps {
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ viewMode = 'list' }) => {
const { t } = useTranslation('chat');
const agentId = useAgentStore((s) => s.activeAgentId);
const activeDocumentId = useChatStore(chatPortalSelectors.portalDocumentId);
const [filter, setFilter] = useState<ResourceFilter>('all');
const {
@ -251,13 +264,7 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ viewMode = 'list'
</Text>
<Flexbox gap={8}>
{group.items.map((doc) => (
<DocumentItem
agentId={agentId}
document={doc}
isActive={activeDocumentId === doc.documentId}
key={doc.id}
mutate={mutate}
/>
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
</Flexbox>
</Flexbox>
@ -294,13 +301,7 @@ const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ viewMode = 'list'
) : (
<Flexbox gap={8}>
{filteredData.map((doc) => (
<DocumentItem
agentId={agentId}
document={doc}
isActive={activeDocumentId === doc.documentId}
key={doc.id}
mutate={mutate}
/>
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
</Flexbox>
)}

View file

@ -0,0 +1,25 @@
'use client';
import { memo, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import BrandTextLoading from '@/components/Loading/BrandTextLoading';
import { useAutoCreateTopicDocument } from '@/features/TopicCanvas/useAutoCreateTopicDocument';
const PageRedirect = memo(() => {
const { aid, topicId } = useParams<{ aid?: string; topicId?: string }>();
const navigate = useNavigate();
const { document } = useAutoCreateTopicDocument(topicId);
useEffect(() => {
if (!aid || !topicId || !document?.id) return;
navigate(`/agent/${aid}/${topicId}/page/${document.id}`, { replace: true });
}, [aid, topicId, document?.id, navigate]);
return <BrandTextLoading debugId={'PageRedirect'} />;
});
PageRedirect.displayName = 'PageRedirect';
export default PageRedirect;

View file

@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest';
import TopicPage from './index';
const useParamsMock = vi.hoisted(() => vi.fn());
const useNavigateMock = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@ -15,10 +16,43 @@ vi.mock('react-router-dom', async () => {
return {
...actual,
useNavigate: () => useNavigateMock,
useParams: useParamsMock,
};
});
vi.mock('swr', () => ({
mutate: vi.fn(),
}));
vi.mock('@/libs/swr', () => ({
useClientDataSWR: () => ({ data: null, error: undefined, isLoading: false }),
}));
vi.mock('@/services/document', () => ({
documentService: {
getDocumentById: vi.fn(),
updateDocument: vi.fn(),
},
}));
vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: { documentsList: (id: string) => ['agent-documents-list', id] },
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (s: { activeAgentId?: string }) => unknown) =>
selector({ activeAgentId: 'agt_test' }),
}));
vi.mock('@/store/notebook/action', () => ({
SWR_USE_FETCH_NOTEBOOK_DOCUMENTS: 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS',
}));
vi.mock('@/features/TopicCanvas/useAutoCreateTopicDocument', () => ({
useAutoCreateTopicDocument: () => ({ document: undefined, isLoading: false }),
}));
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
@ -51,9 +85,18 @@ vi.mock('@/features/FloatingChatPanel', () => ({
}));
vi.mock('@/features/TopicCanvas', () => ({
default: ({ agentId, topicId }: { agentId?: string; topicId?: string | null }) => (
default: ({
agentId,
documentId,
topicId,
}: {
agentId?: string;
documentId?: string;
topicId?: string | null;
}) => (
<div
data-agent-id={agentId ?? ''}
data-document-id={documentId ?? ''}
data-testid="topic-canvas"
data-topic-id={topicId ?? 'null'}
/>
@ -62,15 +105,19 @@ vi.mock('@/features/TopicCanvas', () => ({
describe('Topic page route', () => {
it('renders FloatingChatPanel with route topic context', () => {
useParamsMock.mockReturnValue({ aid: 'agt_test', topicId: 'tpc_test' });
useParamsMock.mockReturnValue({
aid: 'agt_test',
docId: 'doc_test',
topicId: 'tpc_test',
});
render(<TopicPage />);
expect(screen.getByTestId('agent-page-container')).toBeInTheDocument();
expect(screen.getByTestId('topic-canvas')).toHaveAttribute('data-agent-id', 'agt_test');
expect(screen.getByTestId('topic-canvas')).toHaveAttribute('data-topic-id', 'tpc_test');
expect(screen.getByTestId('topic-canvas')).toHaveAttribute('data-document-id', 'doc_test');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-agent-id', 'agt_test');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-open', 'true');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute(
'data-title',
'Floating Chat Panel',

View file

@ -1,42 +1,120 @@
'use client';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { debounce } from 'es-toolkit/compat';
import { memo, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { mutate } from 'swr';
import { AutoSaveHint } from '@/features/EditorCanvas';
import FloatingChatPanel from '@/features/FloatingChatPanel';
import TopicCanvas from '@/features/TopicCanvas';
import { useAutoCreateTopicDocument } from '@/features/TopicCanvas/useAutoCreateTopicDocument';
import { useClientDataSWR } from '@/libs/swr';
import HeaderSlot from '@/routes/(main)/agent/(chat)/_layout/HeaderSlot';
import { agentDocumentSWRKeys } from '@/services/agentDocument';
import { documentService } from '@/services/document';
import { useAgentStore } from '@/store/agent';
import { SWR_USE_FETCH_NOTEBOOK_DOCUMENTS } from '@/store/notebook/action';
const MAX_PANEL_WIDTH = 1024;
const TITLE_SAVE_DEBOUNCE = 500;
const TopicPage = memo(() => {
const params = useParams<{ aid?: string; topicId?: string }>();
const { aid, topicId, docId } = useParams<{ aid?: string; docId?: string; topicId?: string }>();
const navigate = useNavigate();
if (!params.aid || !params.topicId) return null;
const agentId = useAgentStore((s) => s.activeAgentId);
const { document: topicDocument } = useAutoCreateTopicDocument(topicId);
const [titleDraft, setTitleDraft] = useState<string | undefined>();
const {
data: documentMeta,
error: documentError,
isLoading: isDocLoading,
} = useClientDataSWR(docId ? ['page-document-meta', docId] : null, () =>
documentService.getDocumentById(docId!),
);
const isInvalidDoc = docId && !isDocLoading && (documentError || documentMeta === null);
useEffect(() => {
if (!aid || !topicId) return;
if (!isInvalidDoc) return;
if (!topicDocument?.id) return;
if (topicDocument.id === docId) return;
navigate(`/agent/${aid}/${topicId}/page/${topicDocument.id}`, { replace: true });
}, [aid, topicId, docId, isInvalidDoc, topicDocument?.id, navigate]);
useEffect(() => {
setTitleDraft(undefined);
}, [docId]);
const debouncedSaveTitle = useMemo(
() =>
debounce(
async (
id: string,
nextTitle: string,
ctx: { agentId: string | undefined; topicId: string | undefined },
) => {
await documentService.updateDocument({
id,
saveSource: 'autosave',
title: nextTitle,
});
if (ctx.agentId) await mutate(agentDocumentSWRKeys.documentsList(ctx.agentId));
if (ctx.topicId) await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, ctx.topicId]);
await mutate(['page-document-meta', id]);
},
TITLE_SAVE_DEBOUNCE,
),
[],
);
const handleTitleChange = (next: string) => {
setTitleDraft(next);
if (docId) debouncedSaveTitle(docId, next, { agentId, topicId });
};
if (!aid || !topicId) return null;
const displayTitle = titleDraft ?? documentMeta?.title ?? '';
return (
<Flexbox
align={'center'}
data-testid="agent-page-container"
height={'100%'}
style={{ minHeight: 0, position: 'relative' }}
style={{ minHeight: 0, minWidth: 0, position: 'relative' }}
width={'100%'}
>
{docId && (
<HeaderSlot>
<AutoSaveHint documentId={docId} />
</HeaderSlot>
)}
<Flexbox
flex={1}
gap={12}
style={{ maxWidth: MAX_PANEL_WIDTH, minHeight: 0, paddingBlockEnd: 16 }}
width={'100%'}
>
<Flexbox flex={1} style={{ minHeight: 0 }} width={'100%'}>
<TopicCanvas agentId={params.aid} topicId={params.topicId} />
<TopicCanvas
agentId={aid}
documentId={docId}
title={displayTitle}
topicId={topicId}
onTitleChange={handleTitleChange}
/>
</Flexbox>
<FloatingChatPanel
agentId={params.aid}
agentId={aid}
maxHeight={0.92}
minHeight={320}
title={'Floating Chat Panel'}
topicId={params.topicId}
topicId={topicId}
variant={'embedded'}
/>
</Flexbox>

View file

@ -6,9 +6,7 @@ import { memo } from 'react';
import MainInterfaceTracker from '@/components/Analytics/MainInterfaceTracker';
import Conversation from './features/Conversation';
import AgentWorkingSidebar from './features/Conversation/WorkingSidebar';
import PageTitle from './features/PageTitle';
import Portal from './features/Portal';
import TelemetryNotification from './features/TelemetryNotification';
const ChatPage = memo(() => {
@ -16,14 +14,11 @@ const ChatPage = memo(() => {
<>
<PageTitle />
<Flexbox
horizontal
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
style={{ minHeight: 0, overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Conversation />
<Portal />
<AgentWorkingSidebar />
</Flexbox>
<MainInterfaceTracker />
<TelemetryNotification mobile={false} />

View file

@ -18,7 +18,8 @@ import AgentPage from '@/routes/(main)/agent';
import DesktopChatLayout from '@/routes/(main)/agent/_layout';
import DesktopAgentChatLayout from '@/routes/(main)/agent/(chat)/_layout';
import AgentTopicPage from '@/routes/(main)/agent/[topicId]';
import AgentTopicNotebookPage from '@/routes/(main)/agent/[topicId]/page';
import AgentTopicNotebookRedirectPage from '@/routes/(main)/agent/[topicId]/page';
import AgentTopicNotebookDocPage from '@/routes/(main)/agent/[topicId]/page/[docId]';
import AgentChannelPage from '@/routes/(main)/agent/channel';
import AgentCronDetailPage from '@/routes/(main)/agent/cron/[cronId]';
import AgentPageRedirectPage from '@/routes/(main)/agent/page';
@ -103,7 +104,16 @@ export const desktopRoutes: RouteObject[] = [
index: true,
},
{
element: <AgentTopicNotebookPage />,
children: [
{
element: <AgentTopicNotebookRedirectPage />,
index: true,
},
{
element: <AgentTopicNotebookDocPage />,
path: ':docId',
},
],
path: 'page',
},
],

View file

@ -40,10 +40,22 @@ export const desktopRoutes: RouteObject[] = [
index: true,
},
{
element: dynamicElement(
() => import('@/routes/(main)/agent/[topicId]/page'),
'Desktop > Chat > Topic > Page',
),
children: [
{
element: dynamicElement(
() => import('@/routes/(main)/agent/[topicId]/page'),
'Desktop > Chat > Topic > Page > Redirect',
),
index: true,
},
{
element: dynamicElement(
() => import('@/routes/(main)/agent/[topicId]/page/[docId]'),
'Desktop > Chat > Topic > Page > Doc',
),
path: ':docId',
},
],
path: 'page',
},
],

View file

@ -15,7 +15,7 @@ import { type NotebookStore } from './store';
const n = setNamespace('notebook');
const SWR_USE_FETCH_NOTEBOOK_DOCUMENTS = 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS';
export const SWR_USE_FETCH_NOTEBOOK_DOCUMENTS = 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS';
type ExtendedDocumentType = DocumentType | 'agent/plan';