mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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
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:
parent
7c3071d590
commit
a08b3e396f
19 changed files with 547 additions and 60 deletions
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface AgentTopicParams {
|
|||
|
||||
export interface AgentTopicPageParams {
|
||||
agentId: string;
|
||||
docId?: string;
|
||||
topicId: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
57
src/features/TopicCanvas/useAutoCreateTopicDocument.ts
Normal file
57
src/features/TopicCanvas/useAutoCreateTopicDocument.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
39
src/routes/(main)/agent/(chat)/_layout/HeaderSlot.tsx
Normal file
39
src/routes/(main)/agent/(chat)/_layout/HeaderSlot.tsx
Normal 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 });
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
1
src/routes/(main)/agent/[topicId]/page/[docId]/index.tsx
Normal file
1
src/routes/(main)/agent/[topicId]/page/[docId]/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from '@/routes/(main)/agent/features/Page';
|
||||
|
|
@ -1 +1 @@
|
|||
export { default } from '@/routes/(main)/agent/features/Page';
|
||||
export { default } from '@/routes/(main)/agent/features/Page/PageRedirect';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
25
src/routes/(main)/agent/features/Page/PageRedirect.tsx
Normal file
25
src/routes/(main)/agent/features/Page/PageRedirect.tsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue