feat: promote agent documents as primary workspace panel (#13924)

* ♻️ refactor: adopt Notebook list + EditorCanvas for agent documents

The agent working sidebar previously used a FileTree directory view and
a hand-rolled Markdown+TextArea editor with manual save. Agent documents
already back onto the canonical `documents` table via an FK, so they can
reuse the exact same rendering surface as Notebook.

- AgentDocumentsGroup: replace FileTree with a flat card list styled
  after Portal/Notebook/DocumentItem (icon + title + description + delete).
- AgentDocumentEditorPanel: drop the bespoke draft/save/segmented view
  logic; mount the shared <EditorCanvas documentId={doc.documentId}
  sourceType="notebook" /> inside an EditorProvider so auto-save and
  rich editing are handled by useDocumentStore.

*  feat: promote agent documents as the primary workspace panel

- Replace the agent-document sidebar with a Notebook-style list: pill
  filter (All/Docs/Web), per-item createdAt, globe icon for sourceType=web.
- Add a stable panel header "Resources" with a close button (small size,
  consistent with other chat header actions); no border divider.
- Wire clicks to the shared Portal Document view via openDocument(),
  retiring the inline AgentDocumentEditorPanel.
- Portal/Document/Header now resolves title directly from documentId
  via documentService.getDocumentById + a skeleton loading state.
- Portal top-right close icon switched to `X`.
- Layout: move AgentWorkingSidebar to the rightmost position; auto-collapse
  the left navigation sidebar while Portal is open (PortalAutoCollapse).
- Header: remove dead NotebookButton, drop the Notebook menu item; add a
  WorkingPanelToggle visible only when the working panel is collapsed.
- ProgressSection hides itself when the topic has no GTD todos.
- Builtin tool list removes Notebook; migrate CreateDocument Render and
  Streaming renderers to builtin-tool-agent-documents (notebook package
  kept for legacy rendering of historical tool calls).
- agent_documents list UI now reads from a separate SWR key
  (documentsList) so the agent-store context mapping doesn't strip
  documentId/sourceType/createdAt from the UI payload.
- i18n: add workingPanel.resources.filter.{all,documents,web},
  viewMode.{list,tree}, and the expanded empty-state copy; zh-CN
  translations seeded for preview.
- New local-testing reference: agent-browser-login (inject better-auth
  cookie for authenticated agent-browser sessions).

* update

* 🐛 fix: satisfy tsc strict i18next keys, remove duplicate getDocumentById, coerce showLeftPanel

* ♻️ refactor: graduate agent working panel out of labs
This commit is contained in:
Arvin Xu 2026-04-17 23:04:59 +08:00 committed by GitHub
parent 7981bab5bd
commit 75b55edca1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1052 additions and 842 deletions

View file

@ -173,6 +173,10 @@ agent-browser state save auth.json
agent-browser state load auth.json
```
### LobeHub dev server — inject better-auth cookie
`agent-browser --headed` on macOS can create an off-screen Chromium window, blocking manual login. For a local LobeHub dev server (e.g. `localhost:3011`), copy the `better-auth.session_token` cookie out of a **Network request** in the user's own Chrome DevTools and load it via `state load`. See [references/agent-browser-login.md](./references/agent-browser-login.md) for the full recipe.
## Semantic Locators (Alternative to Refs)
```bash

View file

@ -0,0 +1,110 @@
# Log `agent-browser` into a local LobeHub dev server
`agent-browser --headed` on macOS often creates the Chromium window off-screen — the user can't see or interact with it, so manual login inside the agent-browser session fails. Instead of sharing the user's real Chrome profile, copy the **better-auth session cookie** out of a request in DevTools and inject it into the agent-browser session as a Playwright-style state file.
## When to use
- You need `agent-browser` to reach an authenticated page on `http://localhost:<port>` (e.g. `localhost:3011`).
- The user already has a logged-in tab of the same dev server in their own Chrome.
- Spawning a headed Chromium to let the user log in manually is unreliable (window off-screen, no interaction).
Do **not** use this on production URLs — only local dev. Treat the cookie as a secret: don't paste it into shared logs, PRs, or commit it anywhere.
## Step 1 — Ask the user to copy the cookie from a Network request, NOT `document.cookie`
`document.cookie` will not return HttpOnly cookies, which is exactly where better-auth puts its session. Instruct the user:
1. Open the logged-in tab (`http://localhost:<port>/…`) in their own Chrome.
2. `Cmd+Option+I`**Network** tab.
3. Refresh, click any same-origin request (e.g. the top-level document request).
4. In the right pane under **Request Headers**, right-click the `Cookie:` line → **Copy value** (or copy the entire header).
5. Paste the string into chat.
You only need the better-auth pieces. Everything else (Clerk, `LOBE_LOCALE`, HMR hash, theme vars) is noise and can stay. The minimum viable set is:
```
better-auth.session_token=<value>; better-auth.state=<value>
```
## Step 2 — Build a Playwright-style state file
`agent-browser state load` expects Playwright's `storageState` format: a JSON with a `cookies` array and an `origins` array.
```bash
cat > /tmp/mkstate.py << 'PY'
import json, sys, time
# Read the Cookie header from stdin (allows optional "Cookie: " prefix).
raw = sys.stdin.read().strip()
if raw.lower().startswith("cookie:"):
raw = raw.split(":", 1)[1].strip()
# Keep only better-auth cookies. Extend this set if the app genuinely needs more.
WANTED = {"better-auth.session_token", "better-auth.state"}
cookies = []
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
for pair in raw.split("; "):
if "=" not in pair:
continue
name, _, value = pair.partition("=")
if name not in WANTED:
continue
cookies.append({
"name": name,
"value": value,
"domain": "localhost",
"path": "/",
"expires": exp,
"httpOnly": False,
"secure": False,
"sameSite": "Lax",
})
if not cookies:
sys.stderr.write("no better-auth cookies found in input\n")
sys.exit(1)
print(json.dumps({"cookies": cookies, "origins": []}, indent=2))
PY
# Feed the copied Cookie header in via env var or heredoc.
printf '%s' "$COOKIE_HEADER" | python3 /tmp/mkstate.py > /tmp/state.json
```
**Note on `httpOnly`**: the real cookie in the user's browser is HttpOnly, but `storageState` doesn't enforce the flag on load — it just attaches the value. Storing with `httpOnly: false` is fine for local dev and sidesteps a CDP-context quirk where HttpOnly cookies sometimes fail to attach.
## Step 3 — Load state and navigate
```bash
SESSION="my-test" # any stable session name
agent-browser --session "$SESSION" state load /tmp/state.json
agent-browser --session "$SESSION" open "http://localhost:3011/"
agent-browser --session "$SESSION" get url
# Expect NOT /signin?callbackUrl=… — if you still see signin, cookie didn't apply.
```
## Step 4 — Verify
```bash
agent-browser --session "$SESSION" snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
```
## Common failure modes
| Symptom | Cause | Fix |
| ----------------------------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------- |
| Still redirects to `/signin` after `state load` | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| `state load` reports 0 cookies | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is; split on `"; "` |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-load |
| Domain mismatch | Use `domain: "localhost"` literally, no leading dot for local dev | — |
## Scope
Only covers authenticating an **agent-browser** session into a **local** LobeHub dev server. It does not:
- Work for production — production cookies are `Secure; HttpOnly; Domain=.lobehub.com` and must be delivered over HTTPS.
- Replace real OAuth flows — tests that must exercise the login UI need a real Chromium with `--remote-debugging-port` or a bot account.
- Flow cookies back to the user's Chrome — injection is one-way (into agent-browser only).

View file

@ -552,33 +552,38 @@
"workflow.toolDisplayName.upsertDocumentByFilename": "更新了文档",
"workflow.toolDisplayName.writeLocalFile": "写入了文件",
"workflow.working": "处理中...",
"workingPanel.agentDocuments": "理文档",
"workingPanel.agentDocuments": "理文档",
"workingPanel.documents.close": "关闭",
"workingPanel.documents.discard": "放弃更改",
"workingPanel.documents.discard": "放弃",
"workingPanel.documents.edit": "编辑",
"workingPanel.documents.error": "文档加载失败",
"workingPanel.documents.loading": "文档加载中...",
"workingPanel.documents.loading": "文档加载中",
"workingPanel.documents.preview": "预览",
"workingPanel.documents.save": "保存",
"workingPanel.documents.saved": "所有更改已保存",
"workingPanel.documents.saved": "已保存全部变更",
"workingPanel.documents.title": "文档",
"workingPanel.documents.unsaved": "有未保存",
"workingPanel.documents.unsaved": "有未保存的变更",
"workingPanel.progress": "进度",
"workingPanel.progress.allCompleted": "全部任务已完成",
"workingPanel.progress.allCompleted": "所有任务已完成",
"workingPanel.resources": "资源",
"workingPanel.resources.deleteConfirm": "操作无法撤销。",
"workingPanel.resources.deleteError": "删除文档失败",
"workingPanel.resources.deleteConfirm": "操作无法撤销。",
"workingPanel.resources.deleteError": "删除失败",
"workingPanel.resources.deleteSuccess": "文档已删除",
"workingPanel.resources.deleteTitle": "删除文档?",
"workingPanel.resources.empty": "暂无助理文档",
"workingPanel.resources.deleteTitle": "删除文档?",
"workingPanel.resources.empty": "暂无文档,当前 agent 关联的文档将会显示在这里",
"workingPanel.resources.error": "资源加载失败",
"workingPanel.resources.loading": "资源加载中...",
"workingPanel.resources.filter.all": "全部",
"workingPanel.resources.filter.documents": "文档",
"workingPanel.resources.filter.web": "网页",
"workingPanel.resources.loading": "资源加载中…",
"workingPanel.resources.previewError": "预览加载失败",
"workingPanel.resources.previewLoading": "预览加载中...",
"workingPanel.resources.previewLoading": "预览加载中",
"workingPanel.resources.renameEmpty": "标题不能为空",
"workingPanel.resources.renameError": "重命名文档失败",
"workingPanel.resources.renameSuccess": "文档已重命名",
"workingPanel.title": "Working Panel",
"workingPanel.resources.renameError": "重命名失败",
"workingPanel.resources.renameSuccess": "重命名成功",
"workingPanel.resources.viewMode.list": "列表视图",
"workingPanel.resources.viewMode.tree": "目录视图",
"workingPanel.title": "工作面板",
"you": "你",
"zenMode": "专注模式"
}

View file

@ -14,5 +14,13 @@
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^5",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
}
}

View file

@ -0,0 +1,130 @@
'use client';
import { ActionIcon, CopyButton, Flexbox, Markdown, ScrollShadow, TooltipGroup } from '@lobehub/ui';
import { Button } from 'antd';
import { createStaticStyles } from 'antd-style';
import { FileTextIcon, Maximize2, Minimize2, PencilLine } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/slices/portal/selectors';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
position: relative;
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 16px;
background: ${cssVar.colorBgContainer};
`,
content: css`
padding-inline: 16px;
font-size: 14px;
`,
expandButton: css`
position: absolute;
inset-block-end: 16px;
inset-inline-start: 50%;
transform: translateX(-50%);
box-shadow: ${cssVar.boxShadow};
`,
header: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
icon: css`
color: ${cssVar.colorPrimary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
interface DocumentCardProps {
content: string;
documentId?: string;
title: string;
}
const DocumentCard = memo<DocumentCardProps>(({ content, documentId, title }) => {
const { t } = useTranslation('plugin');
const [portalDocumentId, openDocument, closeDocument] = useChatStore((s) => [
chatPortalSelectors.portalDocumentId(s),
s.openDocument,
s.closeDocument,
]);
const isExpanded = !!documentId && portalDocumentId === documentId;
const handleToggle = () => {
if (!documentId) return;
if (isExpanded) {
closeDocument();
} else {
openDocument(documentId);
}
};
return (
<Flexbox className={styles.container}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<FileTextIcon className={styles.icon} size={16} />
<Flexbox flex={1}>
<div className={styles.title}>{title}</div>
</Flexbox>
<TooltipGroup>
<Flexbox horizontal gap={4}>
<CopyButton
content={content}
size={'small'}
title={t('builtins.lobe-notebook.actions.copy')}
/>
{documentId && (
<ActionIcon
icon={PencilLine}
size={'small'}
title={t('builtins.lobe-notebook.actions.edit')}
onClick={handleToggle}
/>
)}
</Flexbox>
</TooltipGroup>
</Flexbox>
<ScrollShadow className={styles.content} offset={12} size={12} style={{ maxHeight: 400 }}>
<Markdown style={{ overflow: 'unset', paddingBottom: 40 }} variant={'chat'}>
{content}
</Markdown>
</ScrollShadow>
{documentId && (
<Button
className={styles.expandButton}
color={'default'}
icon={isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
shape={'round'}
variant={'outlined'}
onClick={handleToggle}
>
{isExpanded
? t('builtins.lobe-notebook.actions.collapse')
: t('builtins.lobe-notebook.actions.expand')}
</Button>
)}
</Flexbox>
);
});
export default DocumentCard;

View file

@ -0,0 +1,23 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import type { CreateDocumentArgs, CreateDocumentState } from '../../../types';
import DocumentCard from './DocumentCard';
export type CreateDocumentRenderProps = Pick<
BuiltinRenderProps<CreateDocumentArgs, CreateDocumentState>,
'args' | 'pluginState'
>;
const CreateDocument = memo<CreateDocumentRenderProps>(({ args, pluginState }) => {
const title = args?.title;
const content = args?.content;
if (!title || !content) return null;
return <DocumentCard content={content} documentId={pluginState?.documentId} title={title} />;
});
export default CreateDocument;

View file

@ -1,3 +1,7 @@
// Intentionally empty: Agent Documents has no dedicated render components yet.
// Keep this export as a stable extension point for future UI renderers.
export const AgentDocumentsRenders = {};
import CreateDocument from './CreateDocument';
export const AgentDocumentsRenders = {
createDocument: CreateDocument,
};
export { default as CreateDocument } from './CreateDocument';

View file

@ -0,0 +1,73 @@
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FileTextIcon } from 'lucide-react';
import { memo } from 'react';
import BubblesLoading from '@/components/BubblesLoading';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import StreamingMarkdown from '@/components/StreamingMarkdown';
import type { CreateDocumentArgs } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 16px;
background: ${cssVar.colorBgContainer};
`,
header: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
icon: css`
color: ${cssVar.colorPrimary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
export const CreateDocumentStreaming = memo<BuiltinStreamingProps<CreateDocumentArgs>>(
({ args }) => {
const { content, title } = args || {};
if (!content && !title) return null;
return (
<Flexbox className={styles.container}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<FileTextIcon className={styles.icon} size={16} />
<Flexbox flex={1}>
<div className={styles.title}>{title}</div>
</Flexbox>
<NeuralNetworkLoading size={20} />
</Flexbox>
{!content ? (
<Flexbox paddingBlock={16} paddingInline={12}>
<BubblesLoading />
</Flexbox>
) : (
<StreamingMarkdown>{content}</StreamingMarkdown>
)}
</Flexbox>
);
},
);
CreateDocumentStreaming.displayName = 'AgentDocumentsCreateDocumentStreaming';
export default CreateDocumentStreaming;

View file

@ -0,0 +1,8 @@
import type { BuiltinStreaming } from '@lobechat/types';
import { AgentDocumentsApiName } from '../../types';
import { CreateDocumentStreaming } from './CreateDocument';
export const AgentDocumentsStreamings: Record<string, BuiltinStreaming> = {
[AgentDocumentsApiName.createDocument]: CreateDocumentStreaming as BuiltinStreaming,
};

View file

@ -2,3 +2,4 @@ export { AgentDocumentsManifest } from '../manifest';
export * from '../types';
export { AgentDocumentsInspectors } from './Inspector';
export { AgentDocumentsRenders } from './Render';
export { AgentDocumentsStreamings } from './Streaming';

View file

@ -14,7 +14,6 @@ import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
import { MessageManifest } from '@lobechat/builtin-tool-message';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store';
@ -85,7 +84,6 @@ export const runtimeManagedToolIds = [
LocalSystemManifest.identifier,
MemoryManifest.identifier,
RemoteDeviceManifest.identifier,
AgentDocumentsManifest.identifier,
WebBrowsingManifest.identifier,
];
@ -135,6 +133,11 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: CloudSandboxManifest,
type: 'builtin',
},
{
identifier: AgentDocumentsManifest.identifier,
manifest: AgentDocumentsManifest,
type: 'builtin',
},
{
identifier: CredsManifest.identifier,
manifest: CredsManifest,
@ -165,12 +168,6 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: AgentBuilderManifest,
type: 'builtin',
},
{
hidden: true,
identifier: AgentDocumentsManifest.identifier,
manifest: AgentDocumentsManifest,
type: 'builtin',
},
{
discoverable: false,
hidden: true,
@ -196,11 +193,6 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: GTDManifest,
type: 'builtin',
},
{
identifier: NotebookManifest.identifier,
manifest: NotebookManifest,
type: 'builtin',
},
{
identifier: CalculatorManifest.identifier,
manifest: CalculatorManifest,

View file

@ -4,6 +4,8 @@ import {
} from '@lobechat/builtin-tool-activator/client';
import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client';
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
import { AgentDocumentsRenders } from '@lobechat/builtin-tool-agent-documents/client';
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
import { AgentManagementRenders } from '@lobechat/builtin-tool-agent-management/client';
import { ClaudeCodeIdentifier, ClaudeCodeRenders } from '@lobechat/builtin-tool-claude-code/client';
@ -39,6 +41,7 @@ import { type BuiltinRender } from '@lobechat/types';
*/
const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
[AgentBuilderManifest.identifier]: AgentBuilderRenders as Record<string, BuiltinRender>,
[AgentDocumentsManifest.identifier]: AgentDocumentsRenders as Record<string, BuiltinRender>,
[AgentManagementManifest.identifier]: AgentManagementRenders as Record<string, BuiltinRender>,
[ClaudeCodeIdentifier]: ClaudeCodeRenders as Record<string, BuiltinRender>,
[CloudSandboxManifest.identifier]: CloudSandboxRenders as Record<string, BuiltinRender>,

View file

@ -2,6 +2,10 @@ import {
AgentBuilderManifest,
AgentBuilderStreamings,
} from '@lobechat/builtin-tool-agent-builder/client';
import {
AgentDocumentsManifest,
AgentDocumentsStreamings,
} from '@lobechat/builtin-tool-agent-documents/client';
import {
AgentManagementManifest,
AgentManagementStreamings,
@ -38,6 +42,7 @@ import { type BuiltinStreaming } from '@lobechat/types';
*/
const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> = {
[AgentBuilderManifest.identifier]: AgentBuilderStreamings as Record<string, BuiltinStreaming>,
[AgentDocumentsManifest.identifier]: AgentDocumentsStreamings as Record<string, BuiltinStreaming>,
[AgentManagementManifest.identifier]: AgentManagementStreamings as Record<
string,
BuiltinStreaming

View file

@ -15,7 +15,7 @@ export const RECOMMENDED_SKILLS: RecommendedSkillItem[] = [
{ id: 'lobe-user-memory', type: RecommendedSkillType.Builtin },
{ id: 'lobe-cloud-sandbox', type: RecommendedSkillType.Builtin },
{ id: 'lobe-gtd', type: RecommendedSkillType.Builtin },
{ id: 'lobe-notebook', type: RecommendedSkillType.Builtin },
{ id: 'lobe-agent-documents', type: RecommendedSkillType.Builtin },
{ id: 'lobe-message', type: RecommendedSkillType.Builtin },
// Klavis skills
{ id: 'gmail', type: RecommendedSkillType.Klavis },

View file

@ -17,7 +17,6 @@ export const DEFAULT_PREFERENCE: UserPreference = {
topic: true,
},
lab: {
enableAgentWorkingPanel: false,
enableHeterogeneousAgent: false,
enableInputMarkdown: true,
},

View file

@ -78,6 +78,8 @@ export class AgentDocumentModel {
policyLoadFormat,
policyLoadPosition: settings.policyLoadPosition,
policyLoadRule: settings.policyLoadRule,
source: doc.source ?? null,
sourceType: doc.sourceType,
templateId: settings.templateId ?? null,
title: doc.title ?? doc.filename ?? '',
updatedAt: settings.updatedAt,

View file

@ -22,6 +22,8 @@ export {
// Type-only exports (interfaces)
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic';
export interface AgentDocument {
accessPublic: number;
accessSelf: number;
@ -43,6 +45,8 @@ export interface AgentDocument {
policyLoadFormat: DocumentLoadFormat;
policyLoadPosition: string;
policyLoadRule: string;
source: string | null;
sourceType: AgentDocumentSourceType;
templateId: string | null;
title: string;
updatedAt: Date;

View file

@ -38,10 +38,6 @@ export const UserGuideSchema = z.object({
export type UserGuide = z.infer<typeof UserGuideSchema>;
export const UserLabSchema = z.object({
/**
* enable agent working panel entry in chat header menu
*/
enableAgentWorkingPanel: z.boolean().optional(),
/**
* enable server-side agent execution via Gateway WebSocket
*/

View file

@ -1,59 +1,44 @@
'use client';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { Flexbox, Skeleton, Text } from '@lobehub/ui';
import { cx } from 'antd-style';
import { ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useClientDataSWR } from '@/libs/swr';
import { documentService } from '@/services/document';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useNotebookStore } from '@/store/notebook';
import { notebookSelectors } from '@/store/notebook/selectors';
import { oneLineEllipsis } from '@/styles';
import { standardizeIdentifier } from '@/utils/identifier';
import AutoSaveHint from './AutoSaveHint';
const Header = () => {
const { t } = useTranslation('portal');
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const documentId = useChatStore(chatPortalSelectors.portalDocumentId);
const [topicId, documentId] = useChatStore((s) => [
s.activeTopicId,
chatPortalSelectors.portalDocumentId(s),
]);
const { data: document, isLoading } = useClientDataSWR(
documentId ? ['portal-document-header', documentId] : null,
() => documentService.getDocumentById(documentId!),
);
const [useFetchDocuments, title, fileType] = useNotebookStore((s) => [
s.useFetchDocuments,
notebookSelectors.getDocumentById(topicId, documentId)(s)?.title,
notebookSelectors.getDocumentById(topicId, documentId)(s)?.fileType,
]);
useFetchDocuments(topicId);
const title = document?.filename || document?.title;
const handleOpenInPageEditor = async () => {
if (!documentId) return;
if (!documentId) return null;
setLoading(true);
try {
// Update fileType to custom/document so it appears in page list
await documentService.updateDocument({
fileType: 'custom/document',
id: documentId,
});
// Navigate to the page editor
// Note: /page route automatically adds 'docs_' prefix to the id
navigate(`/page/${standardizeIdentifier(documentId)}`);
} finally {
setLoading(false);
}
};
if (!title) return null;
if (isLoading || !title) {
return (
<Flexbox
horizontal
align={'center'}
flex={1}
gap={12}
justify={'space-between'}
width={'100%'}
>
<Flexbox flex={1}>
<Skeleton.Button active size={'small'} style={{ height: 16, width: 180 }} />
</Flexbox>
</Flexbox>
);
}
return (
<Flexbox horizontal align={'center'} flex={1} gap={12} justify={'space-between'} width={'100%'}>
@ -64,17 +49,6 @@ const Header = () => {
</Flexbox>
<Flexbox horizontal align={'center'} gap={8}>
<AutoSaveHint />
{fileType !== 'agent/plan' && (
<Button
icon={<ExternalLink size={14} />}
loading={loading}
size={'small'}
type={'text'}
onClick={handleOpenInPageEditor}
>
{t('openInPageEditor')}
</Button>
)}
</Flexbox>
</Flexbox>
);

View file

@ -2,7 +2,7 @@
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { ArrowLeft, PanelRightCloseIcon } from 'lucide-react';
import { ArrowLeft, X } from 'lucide-react';
import { type ReactNode } from 'react';
import { memo } from 'react';
@ -31,7 +31,7 @@ const Header = memo<{ title: ReactNode }>(({ title }) => {
}
right={
<ActionIcon
icon={PanelRightCloseIcon}
icon={X}
size={DESKTOP_HEADER_ICON_SIZE}
onClick={() => {
clearPortalStack();

View file

@ -1,12 +1,9 @@
import { INBOX_SESSION_ID } from '@lobechat/const';
import { HotkeyEnum } from '@lobechat/const/hotkeys';
import { useLocation } from 'react-router-dom';
import { useNavigateToAgent } from '@/hooks/useNavigateToAgent';
import { usePinnedAgentState } from '@/hooks/usePinnedAgentState';
import { useGlobalStore } from '@/store/global';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
import { useHotkeyById } from './useHotkeyById';
@ -44,23 +41,11 @@ export const useToggleLeftPanelHotkey = () => {
export const useToggleRightPanelHotkey = () => {
const isZenMode = useGlobalStore((s) => s.status.zenMode);
const toggleConfig = useGlobalStore((s) => s.toggleRightPanel);
const { pathname } = useLocation();
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
return useHotkeyById(
HotkeyEnum.ToggleRightPanel,
() => {
// TODO: Remove this labs-only guard once Working Panel is no longer an experimental feature.
// Keep in sync with the related TODO in `src/routes/(main)/agent/features/Conversation/index.tsx`.
const isChatRoute = pathname.startsWith('/agent') || pathname.startsWith('/chat');
if (isChatRoute && !enableAgentWorkingPanel) return;
toggleConfig();
},
{
enableOnContentEditable: true,
enabled: !isZenMode,
},
);
return useHotkeyById(HotkeyEnum.ToggleRightPanel, () => toggleConfig(), {
enableOnContentEditable: true,
enabled: !isZenMode,
});
};
// CMDK

View file

@ -609,14 +609,20 @@ export default {
'workingPanel.resources.deleteError': 'Failed to delete document',
'workingPanel.resources.deleteSuccess': 'Document deleted',
'workingPanel.resources.deleteTitle': 'Delete document?',
'workingPanel.resources.empty': 'No agent documents yet',
'workingPanel.resources.empty':
'No documents yet. Documents associated with this agent will show up here.',
'workingPanel.resources.error': 'Failed to load resources',
'workingPanel.resources.filter.all': 'All',
'workingPanel.resources.filter.documents': 'Documents',
'workingPanel.resources.filter.web': 'Web',
'workingPanel.resources.loading': 'Loading resources...',
'workingPanel.resources.previewError': 'Failed to load preview',
'workingPanel.resources.previewLoading': 'Loading preview...',
'workingPanel.resources.renameEmpty': 'Title cannot be empty',
'workingPanel.resources.renameError': 'Failed to rename document',
'workingPanel.resources.renameSuccess': 'Document renamed',
'workingPanel.resources.viewMode.list': 'List view',
'workingPanel.resources.viewMode.tree': 'Tree view',
'workingPanel.documents.close': 'Close',
'workingPanel.documents.error': 'Failed to load document',
'workingPanel.documents.loading': 'Loading document...',

View file

@ -1,7 +1,4 @@
export default {
'features.agentWorkingPanel.desc':
"A place to view an agent's progress and accessible resources.",
'features.agentWorkingPanel.title': 'Working Panel',
'features.assistantMessageGroup.desc':
'Group agent messages and their tool call results together for display',
'features.assistantMessageGroup.title': 'Agent Message Grouping',

View file

@ -0,0 +1,37 @@
'use client';
import { memo, useEffect, useRef } from 'react';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
/**
* Auto-collapse the left nav panel while Portal is open on the agent page.
* Restores the previous state when Portal closes.
*/
const PortalAutoCollapse = memo(() => {
const showPortal = useChatStore(chatPortalSelectors.showPortal);
const savedShowLeftPanelRef = useRef<boolean | null>(null);
useEffect(() => {
const { status, toggleLeftPanel } = useGlobalStore.getState();
if (showPortal) {
// Remember the left-panel state only on the transition false→true
if (savedShowLeftPanelRef.current === null) {
savedShowLeftPanelRef.current = !!status.showLeftPanel;
if (status.showLeftPanel) toggleLeftPanel(false);
}
} else if (savedShowLeftPanelRef.current !== null) {
if (savedShowLeftPanelRef.current) toggleLeftPanel(true);
savedShowLeftPanelRef.current = null;
}
}, [showPortal]);
return null;
});
PortalAutoCollapse.displayName = 'PortalAutoCollapse';
export default PortalAutoCollapse;

View file

@ -7,6 +7,7 @@ import ProtocolUrlHandler from '@/features/ProtocolUrlHandler';
import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
import AgentIdSync from '@/routes/(main)/agent/_layout/AgentIdSync';
import PortalAutoCollapse from './PortalAutoCollapse';
import RegisterHotkeys from './RegisterHotkeys';
import Sidebar from './Sidebar';
import { styles } from './style';
@ -23,6 +24,7 @@ const Layout: FC = () => {
<RegisterHotkeys />
{isDesktop && <ProtocolUrlHandler />}
<AgentIdSync />
<PortalAutoCollapse />
</>
);
};

View file

@ -2,67 +2,34 @@
import { Icon } from '@lobehub/ui';
import { type DropdownItem } from '@lobehub/ui';
import { FilePenIcon, Maximize2, PanelRightOpen } from 'lucide-react';
import { Maximize2 } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
export const useMenu = (): { menuItems: DropdownItem[] } => {
const { t } = useTranslation('chat');
const { t: tPortal } = useTranslation('portal');
const [wideScreen, toggleRightPanel, toggleWideScreen] = useGlobalStore((s) => [
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
systemStatusSelectors.wideScreen(s),
s.toggleRightPanel,
s.toggleWideScreen,
]);
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
const toggleNotebook = useChatStore((s) => s.toggleNotebook);
const menuItems = useMemo<DropdownItem[]>(() => {
const items: DropdownItem[] = [
const menuItems = useMemo<DropdownItem[]>(
() => [
{
icon: <Icon icon={FilePenIcon} />,
key: 'notebook',
label: tPortal('notebook.title'),
onClick: () => toggleNotebook(),
checked: wideScreen,
icon: <Icon icon={Maximize2} />,
key: 'full-width',
label: t('viewMode.fullWidth'),
onCheckedChange: toggleWideScreen,
type: 'switch',
},
];
if (enableAgentWorkingPanel) {
items.push({
icon: <Icon icon={PanelRightOpen} />,
key: 'agent-workspace',
label: t('workingPanel.title'),
onClick: () => toggleRightPanel(),
});
}
items.push({
checked: wideScreen,
icon: <Icon icon={Maximize2} />,
key: 'full-width',
label: t('viewMode.fullWidth'),
onCheckedChange: toggleWideScreen,
type: 'switch',
});
return items;
}, [
enableAgentWorkingPanel,
t,
tPortal,
toggleNotebook,
toggleRightPanel,
toggleWideScreen,
wideScreen,
]);
],
[t, toggleWideScreen, wideScreen],
);
return { menuItems };
};

View file

@ -1,26 +0,0 @@
'use client';
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
import { ActionIcon } from '@lobehub/ui';
import { FilePenIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
const NotebookButton = memo(() => {
const { t } = useTranslation('portal');
const [showNotebook, toggleNotebook] = useChatStore((s) => [s.showNotebook, s.toggleNotebook]);
return (
<ActionIcon
active={showNotebook}
icon={FilePenIcon}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('notebook.title')}
onClick={() => toggleNotebook()}
/>
);
});
export default NotebookButton;

View file

@ -0,0 +1,31 @@
'use client';
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@lobechat/const';
import { ActionIcon } from '@lobehub/ui';
import { PanelRightOpenIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
const WorkingPanelToggle = memo(() => {
const { t } = useTranslation('chat');
const [showRightPanel, toggleRightPanel] = useGlobalStore((s) => [
systemStatusSelectors.showRightPanel(s),
s.toggleRightPanel,
]);
if (showRightPanel) return null;
return (
<ActionIcon
icon={PanelRightOpenIcon}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
title={t('workingPanel.title')}
onClick={() => toggleRightPanel(true)}
/>
);
});
export default WorkingPanelToggle;

View file

@ -9,6 +9,7 @@ import NavHeader from '@/features/NavHeader';
import HeaderActions from './HeaderActions';
import ShareButton from './ShareButton';
import Tags from './Tags';
import WorkingPanelToggle from './WorkingPanelToggle';
const Header = memo(() => {
return (
@ -21,6 +22,7 @@ const Header = memo(() => {
right={
<Flexbox horizontal align={'center'} style={{ backgroundColor: cssVar.colorBgContainer }}>
<ShareButton />
<WorkingPanelToggle />
<HeaderActions />
</Flexbox>
}

View file

@ -1,252 +0,0 @@
import { DESKTOP_HEADER_ICON_SIZE } from '@lobechat/const';
import { ActionIcon, Button, Flexbox, Icon, Markdown, Skeleton, Text } from '@lobehub/ui';
import { Segmented } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { Eye, PanelRightCloseIcon, SquarePen } from 'lucide-react';
import { extname } from 'pathe';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EditorTextArea from '@/features/EditorModal/TextArea';
import { useClientDataSWR } from '@/libs/swr';
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
import { useAgentStore } from '@/store/agent';
const styles = createStaticStyles(({ css }) => ({
container: css`
height: 100%;
background: ${cssVar.colorBgContainer};
`,
editor: css`
flex: 1;
min-height: 0;
padding: 12px;
`,
editorWrapper: css`
position: relative;
flex: 1;
min-height: 0;
`,
footer: css`
overflow: hidden;
transition:
max-height 0.2s ${cssVar.motionEaseInOut},
opacity 0.2s ${cssVar.motionEaseInOut},
transform 0.2s ${cssVar.motionEaseInOut},
border-color 0.2s ${cssVar.motionEaseInOut};
`,
footerOpen: css`
transform: translateY(0);
max-height: 72px;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
opacity: 1;
`,
footerClosed: css`
pointer-events: none;
transform: translateY(8px);
max-height: 0;
border-block-start-color: transparent;
opacity: 0;
`,
footerInner: css`
padding: 12px;
`,
header: css`
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
preview: css`
overflow-y: auto;
width: 100%;
height: 100%;
padding: 12px;
`,
}));
interface AgentDocumentEditorPanelProps {
onClose: () => void;
selectedDocumentId: string | null;
}
type DocumentViewMode = 'edit' | 'preview';
const isMarkdownFile = (filename?: string) => {
if (!filename) return false;
const extension = extname(filename).toLowerCase();
return extension === '.md' || extension === '.markdown';
};
const AgentDocumentEditorPanel = memo<AgentDocumentEditorPanelProps>(
({ selectedDocumentId, onClose }) => {
const { t } = useTranslation('chat');
const agentId = useAgentStore((s) => s.activeAgentId);
const [draft, setDraft] = useState('');
const [savedContent, setSavedContent] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [viewMode, setViewMode] = useState<DocumentViewMode>('edit');
const initializedDocumentIdRef = useRef<string | null>(null);
const { data, error, isLoading, mutate } = useClientDataSWR(
agentId && selectedDocumentId
? agentDocumentSWRKeys.readDocument(agentId, selectedDocumentId)
: null,
() => agentDocumentService.readDocument({ agentId: agentId!, id: selectedDocumentId! }),
);
useEffect(() => {
if (!data) return;
if (data.id !== selectedDocumentId) return;
if (initializedDocumentIdRef.current === data.id) return;
setDraft(data.content);
setSavedContent(data.content);
setViewMode(isMarkdownFile(data.filename || data.title) ? 'preview' : 'edit');
initializedDocumentIdRef.current = data.id;
}, [data, selectedDocumentId]);
useEffect(() => {
if (selectedDocumentId) return;
initializedDocumentIdRef.current = null;
}, [selectedDocumentId]);
const isDirty = useMemo(() => draft !== savedContent, [draft, savedContent]);
const isDocumentReady = data?.id === selectedDocumentId;
const shouldShowLoading = Boolean(selectedDocumentId) && (isLoading || !isDocumentReady);
const isOpen = Boolean(selectedDocumentId);
const isMarkdownDocument = isMarkdownFile(data?.filename || data?.title);
if (!agentId) return null;
const saveDocument = async () => {
if (!isDirty || isSaving || !selectedDocumentId) return;
setIsSaving(true);
try {
await agentDocumentService.editDocument({
agentId,
content: draft,
id: selectedDocumentId,
});
await mutate();
setSavedContent(draft);
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<Flexbox className={styles.container} data-testid="workspace-document-panel">
<Flexbox
horizontal
align={'center'}
className={styles.header}
justify={'space-between'}
padding={12}
>
<Text strong>{data?.filename || data?.title || t('workingPanel.documents.title')}</Text>
<Flexbox horizontal align={'center'} gap={8}>
{isMarkdownDocument && (
<Segmented<DocumentViewMode>
value={viewMode}
options={[
{
label: (
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={Eye} size={14} />
<span style={{ fontSize: 12 }}>{t('workingPanel.documents.preview')}</span>
</Flexbox>
),
value: 'preview',
},
{
label: (
<Flexbox horizontal align={'center'} gap={4}>
<Icon icon={SquarePen} size={14} />
<span style={{ fontSize: 12 }}>{t('workingPanel.documents.edit')}</span>
</Flexbox>
),
value: 'edit',
},
]}
onChange={(value) => setViewMode(value)}
/>
)}
<ActionIcon
icon={PanelRightCloseIcon}
size={DESKTOP_HEADER_ICON_SIZE}
onClick={onClose}
/>
</Flexbox>
</Flexbox>
{shouldShowLoading && (
<Flexbox className={styles.editor} gap={8}>
<Skeleton active paragraph={{ rows: 10 }} title={false} />
</Flexbox>
)}
{error && (
<Text style={{ padding: 12 }} type={'danger'}>
{t('workingPanel.documents.error')}
</Text>
)}
{!shouldShowLoading && !error && data && (
<>
<Flexbox className={styles.editorWrapper}>
{viewMode === 'preview' ? (
<div className={styles.preview}>
<Markdown variant={'chat'}>{draft}</Markdown>
</div>
) : (
<EditorTextArea
style={{ height: '100%', resize: 'none' }}
value={draft}
onChange={setDraft}
/>
)}
</Flexbox>
<Flexbox
className={`${styles.footer} ${isDirty ? styles.footerOpen : styles.footerClosed}`}
>
<Flexbox
horizontal
align={'center'}
className={styles.footerInner}
justify={'space-between'}
>
<Text type={'secondary'}>{t('workingPanel.documents.unsaved')}</Text>
<Flexbox horizontal gap={8}>
<Button
disabled={isSaving || shouldShowLoading}
size={'small'}
onClick={() => setDraft(savedContent)}
>
{t('workingPanel.documents.discard')}
</Button>
<Button
disabled={shouldShowLoading || Boolean(error)}
loading={isSaving}
size={'small'}
type={'primary'}
onClick={saveDocument}
>
{t('workingPanel.documents.save')}
</Button>
</Flexbox>
</Flexbox>
</Flexbox>
</>
)}
</Flexbox>
);
},
);
AgentDocumentEditorPanel.displayName = 'AgentDocumentEditorPanel';
export default AgentDocumentEditorPanel;

View file

@ -110,6 +110,8 @@ const ProgressSection = memo(() => {
);
const hasTasks = progress.items.length > 0;
if (!hasTasks) return null;
return (
<>
<div className={styles.barWrap}>

View file

@ -7,12 +7,39 @@ import AgentDocumentsGroup from './AgentDocumentsGroup';
const useClientDataSWR = vi.fn();
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
ActionIcon: ({ onClick, title }: { onClick?: (e: React.MouseEvent) => void; title?: string }) => (
<button aria-label={title} onClick={onClick}>
{title}
</button>
),
Center: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
Flexbox: ({
children,
onClick,
...props
}: {
children?: ReactNode;
onClick?: () => void;
[key: string]: unknown;
}) => (
<div onClick={onClick} {...props}>
{children}
</div>
),
Text: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { error: vi.fn(), success: vi.fn() },
modal: { confirm: vi.fn() },
}),
},
Spin: () => <div data-testid="spin" />,
}));
vi.mock('@/libs/swr', () => ({
useClientDataSWR: (...args: unknown[]) => useClientDataSWR(...args),
}));
@ -22,12 +49,11 @@ vi.mock('react-i18next', () => ({
t: (key: string) =>
(
({
'workingPanel.agentDocuments': 'Agent Documents',
'workingPanel.resources.empty': 'No agent documents yet',
'workingPanel.resources.error': 'Failed to load resources',
'workingPanel.resources.loading': 'Loading resources...',
'workingPanel.resources.previewError': 'Failed to load preview',
'workingPanel.resources.previewLoading': 'Loading preview...',
'workingPanel.resources.filter.all': 'All',
'workingPanel.resources.filter.documents': 'Documents',
'workingPanel.resources.filter.web': 'Web',
}) as Record<string, string>
)[key] || key,
}),
@ -36,9 +62,11 @@ vi.mock('react-i18next', () => ({
vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: {
documents: (agentId: string) => ['agent-documents', agentId],
documentsList: (agentId: string) => ['agent-documents-list', agentId],
},
agentDocumentService: {
getDocuments: vi.fn(),
removeDocument: vi.fn(),
},
}));
@ -47,24 +75,18 @@ vi.mock('@/store/agent', () => ({
selector({ activeAgentId: 'agent-1' }),
}));
vi.mock('@/features/FileTree', () => ({
default: ({
onSelectFile,
resourceTree,
}: {
onSelectFile: (path: string) => void;
resourceTree: Array<{ children?: Array<{ name: string; path: string }> }>;
}) => (
<div>
{resourceTree.flatMap((node) =>
(node.children || []).map((child) => (
<button key={child.path} onClick={() => onSelectFile(child.path)}>
{child.name}
</button>
)),
)}
</div>
),
const openDocument = vi.fn();
const closeDocument = vi.fn();
vi.mock('@/store/chat', () => ({
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ closeDocument, openDocument, portalStack: [] }),
}));
vi.mock('@/store/chat/selectors', () => ({
chatPortalSelectors: {
portalDocumentId: () => null,
},
}));
describe('AgentDocumentsGroup', () => {
@ -72,26 +94,109 @@ describe('AgentDocumentsGroup', () => {
useClientDataSWR.mockReset();
});
it('renders documents and delegates selection to parent', async () => {
const onSelectDocument = vi.fn();
it('renders documents and opens via openDocument', async () => {
useClientDataSWR.mockImplementation((key: unknown) => {
if (Array.isArray(key) && key[0] === 'agent-documents') {
if (Array.isArray(key) && key[0] === 'agent-documents-list') {
return {
data: [{ filename: 'brief.md', id: 'doc-1', templateId: 'claw', title: 'Brief' }],
data: [
{
createdAt: new Date('2026-04-16T00:00:00Z'),
description: 'A short brief',
documentId: 'doc-content-1',
filename: 'brief.md',
id: 'doc-1',
sourceType: 'file',
templateId: 'claw',
title: 'Brief',
},
],
error: undefined,
isLoading: false,
mutate: vi.fn(),
};
}
return { data: undefined, error: undefined, isLoading: false };
return { data: undefined, error: undefined, isLoading: false, mutate: vi.fn() };
});
render(<AgentDocumentsGroup selectedDocumentId={null} onSelectDocument={onSelectDocument} />);
render(<AgentDocumentsGroup />);
expect(await screen.findByText('brief.md')).toBeInTheDocument();
const item = await screen.findByText('brief.md');
expect(item).toBeInTheDocument();
expect(screen.getByText('A short brief')).toBeInTheDocument();
fireEvent.click(screen.getByText('brief.md'));
expect(onSelectDocument).toHaveBeenCalledWith('doc-1');
fireEvent.click(item);
expect(openDocument).toHaveBeenCalledWith('doc-content-1');
});
it('filters documents by source type via segmented tabs', () => {
useClientDataSWR.mockReturnValue({
data: [
{
createdAt: new Date('2026-04-16T00:00:00Z'),
description: 'File doc',
documentId: 'doc-content-1',
filename: 'brief.md',
id: 'doc-1',
sourceType: 'file',
templateId: 'claw',
title: 'Brief',
},
{
createdAt: new Date('2026-04-16T00:00:00Z'),
description: 'Crawled page',
documentId: 'doc-content-2',
filename: 'example.com',
id: 'doc-2',
sourceType: 'web',
templateId: null,
title: 'Example',
},
],
error: undefined,
isLoading: false,
mutate: vi.fn(),
});
render(<AgentDocumentsGroup />);
expect(screen.getByText('brief.md')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
fireEvent.click(screen.getByText('Web'));
expect(screen.queryByText('brief.md')).not.toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
fireEvent.click(screen.getByText('Documents'));
expect(screen.getByText('brief.md')).toBeInTheDocument();
expect(screen.queryByText('example.com')).not.toBeInTheDocument();
});
it('renders empty state when no documents', () => {
useClientDataSWR.mockReturnValue({
data: [],
error: undefined,
isLoading: false,
mutate: vi.fn(),
});
render(<AgentDocumentsGroup />);
expect(screen.getByText('No agent documents yet')).toBeInTheDocument();
});
it('renders error state', () => {
useClientDataSWR.mockReturnValue({
data: [],
error: new Error('oops'),
isLoading: false,
mutate: vi.fn(),
});
render(<AgentDocumentsGroup />);
expect(screen.getByText('Failed to load resources')).toBeInTheDocument();
});
});

View file

@ -1,204 +1,312 @@
import type { SkillResourceTreeNode } from '@lobechat/types';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { App } from 'antd';
import { Pencil, Trash2 } from 'lucide-react';
import { memo, useCallback, useMemo, useState } from 'react';
import { ActionIcon, Center, Empty, Flexbox, Text } from '@lobehub/ui';
import { App, Spin } from 'antd';
import { createStaticStyles, cx } from 'antd-style';
import dayjs from 'dayjs';
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 FileTree, { FileTreeSkeleton } from '@/features/FileTree';
import { useClientDataSWR } from '@/libs/swr';
import { agentDocumentService, agentDocumentSWRKeys } from '@/services/agentDocument';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
interface AgentDocumentsGroupProps {
onSelectDocument: (id: string | null) => void;
selectedDocumentId: string | null;
}
dayjs.extend(relativeTime);
type ResourceFilter = 'all' | 'documents' | 'web';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
cursor: pointer;
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillTertiary};
&:hover {
background: ${cssVar.colorFillSecondary};
}
`,
containerActive: css`
background: ${cssVar.colorFillSecondary};
`,
description: css`
font-size: 12px;
line-height: 1.5;
color: ${cssVar.colorTextSecondary};
`,
groupLabel: css`
padding-inline: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
`,
meta: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
pillActive: css`
font-weight: 500;
color: ${cssVar.colorText};
background: ${cssVar.colorFillSecondary};
&:hover {
background: ${cssVar.colorFillSecondary};
}
`,
pillTab: css`
cursor: pointer;
user-select: none;
padding-block: 4px;
padding-inline: 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
color: ${cssVar.colorTextSecondary};
background: transparent;
transition:
background ${cssVar.motionDurationFast} ${cssVar.motionEaseInOut},
color ${cssVar.motionDurationFast} ${cssVar.motionEaseInOut};
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
}
`,
title: css`
font-weight: 500;
`,
}));
const FILTER_OPTIONS = [
{ labelKey: 'workingPanel.resources.filter.all', value: 'all' },
{ labelKey: 'workingPanel.resources.filter.documents', value: 'documents' },
{ labelKey: 'workingPanel.resources.filter.web', value: 'web' },
] as const satisfies readonly { labelKey: string; value: ResourceFilter }[];
type AgentDocumentListItem = Awaited<ReturnType<typeof agentDocumentService.getDocuments>>[number];
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
({ onSelectDocument, selectedDocumentId }) => {
const { t } = useTranslation(['chat', 'common']);
const { message, modal } = App.useApp();
const agentId = useAgentStore((s) => s.activeAgentId);
const [editingDocumentId, setEditingDocumentId] = useState<string | null>(null);
interface DocumentItemProps {
agentId: string;
document: AgentDocumentListItem;
isActive: boolean;
mutate: () => Promise<unknown>;
}
const {
data = [],
error,
isLoading,
mutate,
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documents(agentId) : null, () =>
agentDocumentService.getDocuments({ agentId: agentId! }),
);
const DocumentItem = memo<DocumentItemProps>(({ agentId, document, isActive, 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 resourceTree = useMemo<SkillResourceTreeNode[]>(
() => [
{
children: data.map((item) => ({
name: item.filename || item.title,
path: item.id,
type: 'file' as const,
})),
name: t('workingPanel.agentDocuments'),
path: 'agent-documents',
type: 'directory' as const,
},
],
[data, t],
);
const title = document.filename || document.title || '';
const description = document.description ?? undefined;
const isWeb = document.sourceType === 'web';
const IconComponent: LucideIcon = isWeb ? GlobeIcon : FileTextIcon;
const createdAtLabel = document.createdAt ? dayjs(document.createdAt).fromNow() : null;
const handleCommitRenameDocument = useCallback(
async (file: { name: string; path: string }, nextName: string) => {
if (!agentId) return;
const normalizedTitle = nextName.trim();
setEditingDocumentId(null);
if (!normalizedTitle) {
message.error(t('workingPanel.resources.renameEmpty', { ns: 'chat' }));
return;
}
if (normalizedTitle === file.name) {
return;
}
const handleOpen = () => {
if (!document.documentId) return;
openDocument(document.documentId);
};
const handleDelete = (e: MouseEvent) => {
e.stopPropagation();
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
setDeleting(true);
try {
await mutate(
async (current: AgentDocumentListItem[] = []) => {
const renamed = await agentDocumentService.renameDocument({
agentId,
id: file.path,
newTitle: normalizedTitle,
});
return current.map((item) =>
item.id === file.path
? {
...item,
filename: renamed?.filename ?? item.filename,
title: renamed?.title ?? normalizedTitle,
}
: item,
);
},
{
optimisticData: (current: AgentDocumentListItem[] = []) =>
current.map((item) =>
item.id === file.path
? {
...item,
filename: normalizedTitle,
title: normalizedTitle,
}
: item,
),
revalidate: false,
rollbackOnError: true,
},
);
message.success(t('workingPanel.resources.renameSuccess', { ns: 'chat' }));
if (isActive) closeDocument();
await agentDocumentService.removeDocument({ agentId, id: document.id });
await mutate();
message.success(t('workingPanel.resources.deleteSuccess', { ns: 'chat' }));
} catch (error) {
message.error(
error instanceof Error
? error.message
: t('workingPanel.resources.renameError', { ns: 'chat' }),
: t('workingPanel.resources.deleteError', { ns: 'chat' }),
);
} finally {
setDeleting(false);
}
},
[agentId, message, mutate, t],
);
title: t('workingPanel.resources.deleteTitle', { ns: 'chat' }),
});
};
const handleDeleteDocument = useCallback(
(id: string) => {
if (!agentId) return;
modal.confirm({
content: t('workingPanel.resources.deleteConfirm', { ns: 'chat' }),
okButtonProps: { danger: true },
okText: t('delete', { ns: 'common' }),
onOk: async () => {
const wasSelected = selectedDocumentId === id;
if (wasSelected) onSelectDocument(null);
try {
await mutate(
async (current = []) => {
await agentDocumentService.removeDocument({ agentId, id });
return current.filter((item) => item.id !== id);
},
{
optimisticData: (current = []) => current.filter((item) => item.id !== id),
revalidate: false,
rollbackOnError: true,
},
);
message.success(t('workingPanel.resources.deleteSuccess', { ns: 'chat' }));
} catch (error) {
if (wasSelected) onSelectDocument(id);
message.error(
error instanceof Error
? error.message
: t('workingPanel.resources.deleteError', { ns: 'chat' }),
);
throw error;
}
},
title: t('workingPanel.resources.deleteTitle', { ns: 'chat' }),
});
},
[agentId, message, modal, mutate, onSelectDocument, selectedDocumentId, t],
);
const getFileContextMenuItems = useCallback(
(file: { path: string }) => [
{
icon: <Icon icon={Pencil} />,
key: 'rename',
label: t('rename', { ns: 'common' }),
onClick: () => setEditingDocumentId(file.path),
},
{ type: 'divider' as const },
{
danger: true,
icon: <Icon icon={Trash2} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: () => handleDeleteDocument(file.path),
},
],
[handleDeleteDocument, t],
);
if (!agentId) return null;
return (
<Flexbox gap={8}>
{isLoading && <FileTreeSkeleton rows={6} showRootFile={false} />}
{error && <Text type={'danger'}>{t('workingPanel.resources.error')}</Text>}
{!isLoading && !error && data.length === 0 && (
<Text type={'secondary'}>{t('workingPanel.resources.empty')}</Text>
)}
{!isLoading && !error && data.length > 0 && (
<FileTree
editableFilePath={editingDocumentId}
getFileContextMenuItems={getFileContextMenuItems}
resourceTree={resourceTree}
rootFile={null}
selectedFile={selectedDocumentId || ''}
onCancelRenameFile={() => setEditingDocumentId(null)}
onCommitRenameFile={handleCommitRenameDocument}
onSelectFile={onSelectDocument}
return (
<Flexbox
horizontal
align={'flex-start'}
className={`${styles.container} ${isActive ? styles.containerActive : ''}`}
gap={8}
onClick={handleOpen}
>
<IconComponent size={16} style={{ flexShrink: 0, marginTop: 2 }} />
<Flexbox gap={4} style={{ flex: 1, minWidth: 0 }}>
<Flexbox horizontal align={'center'} distribution={'space-between'}>
<Text ellipsis className={styles.title}>
{title}
</Text>
<ActionIcon
icon={Trash2Icon}
loading={deleting}
size={'small'}
title={t('delete', { ns: 'common' })}
onClick={handleDelete}
/>
</Flexbox>
{description && (
<Text className={styles.description} ellipsis={{ rows: 2 }}>
{description}
</Text>
)}
{createdAtLabel && <Text className={styles.meta}>{createdAtLabel}</Text>}
</Flexbox>
</Flexbox>
);
});
DocumentItem.displayName = 'AgentDocumentsGroupItem';
interface AgentDocumentsGroupProps {
viewMode?: 'list' | 'tree';
}
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 {
data = [],
error,
isLoading,
mutate,
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
agentDocumentService.getDocuments({ agentId: agentId! }),
);
const filteredData = useMemo(() => {
if (filter === 'documents') return data.filter((doc) => doc.sourceType !== 'web');
if (filter === 'web') return data.filter((doc) => doc.sourceType === 'web');
return data;
}, [data, filter]);
const treeGroups = useMemo(() => {
const docs = data.filter((doc) => doc.sourceType !== 'web');
const webs = data.filter((doc) => doc.sourceType === 'web');
return (
[
{ items: docs, labelKey: 'workingPanel.resources.filter.documents' },
{ items: webs, labelKey: 'workingPanel.resources.filter.web' },
] as const
).filter((group) => group.items.length > 0);
}, [data]);
if (!agentId) return null;
if (isLoading) {
return (
<Center flex={1} paddingBlock={24}>
<Spin />
</Center>
);
}
if (error) {
return (
<Center flex={1} paddingBlock={24}>
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
</Center>
);
}
if (data.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.resources.empty')} icon={FileTextIcon} />
</Center>
);
}
if (viewMode === 'tree') {
return (
<Flexbox gap={16}>
{treeGroups.map((group) => (
<Flexbox gap={8} key={group.labelKey}>
<Text className={styles.groupLabel} type={'secondary'}>
{t(group.labelKey)}
</Text>
<Flexbox gap={8}>
{group.items.map((doc) => (
<DocumentItem
agentId={agentId}
document={doc}
isActive={activeDocumentId === doc.documentId}
key={doc.id}
mutate={mutate}
/>
))}
</Flexbox>
</Flexbox>
))}
</Flexbox>
);
},
);
}
return (
<Flexbox gap={12}>
<Flexbox horizontal gap={4} role={'tablist'}>
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<div
aria-selected={active}
className={cx(styles.pillTab, active && styles.pillActive)}
key={option.value}
role={'tab'}
onClick={() => setFilter(option.value)}
>
{t(option.labelKey)}
</div>
);
})}
</Flexbox>
{filteredData.length === 0 ? (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty
description={t('workingPanel.resources.empty')}
icon={filter === 'web' ? GlobeIcon : FileTextIcon}
/>
</Center>
) : (
<Flexbox gap={8}>
{filteredData.map((doc) => (
<DocumentItem
agentId={agentId}
document={doc}
isActive={activeDocumentId === doc.documentId}
key={doc.id}
mutate={mutate}
/>
))}
</Flexbox>
)}
</Flexbox>
);
});
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';

View file

@ -1,39 +1,14 @@
import { Accordion, AccordionItem, Flexbox, Text } from '@lobehub/ui';
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import AgentDocumentsGroup from './AgentDocumentsGroup';
interface ResourcesSectionProps {
onSelectDocument: (id: string | null) => void;
selectedDocumentId: string | null;
}
const ResourcesSection = memo<ResourcesSectionProps>(({ onSelectDocument, selectedDocumentId }) => {
const { t } = useTranslation('chat');
export type ResourceViewMode = 'list' | 'tree';
const ResourcesSection = memo(() => {
return (
<Flexbox data-testid="workspace-resources" padding={16}>
<Accordion defaultExpandedKeys={['resources']} gap={0}>
<AccordionItem
itemKey={'resources'}
paddingBlock={2}
paddingInline={6}
title={<Text strong>{t('workingPanel.resources')}</Text>}
styles={{
header: {
width: 'fit-content',
},
}}
>
<Flexbox paddingBlock={8}>
<AgentDocumentsGroup
selectedDocumentId={selectedDocumentId}
onSelectDocument={onSelectDocument}
/>
</Flexbox>
</AccordionItem>
</Accordion>
<Flexbox data-testid="workspace-resources" paddingBlock={8} paddingInline={16}>
<AgentDocumentsGroup viewMode={'list'} />
</Flexbox>
);
});

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -8,7 +8,6 @@ import { initialState } from '@/store/global/initialState';
import { useUserStore } from '@/store/user';
import { initialState as initialUserState } from '@/store/user/initialState';
import Conversation from '../index';
import AgentWorkingSidebar from './index';
vi.mock('@/libs/swr', async (importOriginal) => {
@ -38,6 +37,9 @@ vi.mock('@lobehub/ui', () => ({
Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<button {...props}>{children}</button>
),
Center: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
Checkbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
@ -58,6 +60,7 @@ vi.mock('@lobehub/ui', () => ({
{children}
</div>
),
Empty: ({ description }: { description?: ReactNode }) => <div>{description}</div>,
Avatar: ({ avatar }: { avatar?: ReactNode | string }) => <div>{avatar}</div>,
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
@ -73,6 +76,17 @@ vi.mock('@lobehub/ui', () => ({
TooltipGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { error: vi.fn(), success: vi.fn() },
modal: { confirm: vi.fn() },
}),
},
Progress: () => <div data-testid="workspace-progress-bar" />,
Spin: () => <div data-testid="spin" />,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
@ -85,49 +99,30 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('@/components/DragUploadZone', () => ({
default: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
useUploadFiles: () => ({ handleUploadFiles: vi.fn() }),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector?.({
activeAgentId: 'agent-1',
useFetchBotProviders: () => ({ data: [], isLoading: false }),
useFetchPlatformDefinitions: () => ({ data: [], isLoading: false }),
}),
}));
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
currentAgentModel: () => 'mock-model',
currentAgentModelProvider: () => 'mock-provider',
vi.mock('@/store/chat', () => ({
useChatStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
activeTopicId: undefined,
closeDocument: vi.fn(),
dbMessagesMap: {},
openDocument: vi.fn(),
portalStack: [],
}),
}));
vi.mock('@/store/chat/selectors', () => ({
chatPortalSelectors: {
portalDocumentId: () => null,
},
}));
vi.mock('@/features/Conversation/store', () => ({
dataSelectors: {
dbMessages: (state: { dbMessages?: unknown[] }) => state.dbMessages,
},
useConversationStore: (selector: (state: { dbMessages: unknown[] }) => unknown) =>
selector({ dbMessages: [] }),
}));
vi.mock('../ConversationArea', () => ({
default: () => <div>conversation-area</div>,
}));
vi.mock('../Header', () => ({
default: () => <div>chat-header</div>,
}));
vi.mock('./AgentDocumentEditorPanel', () => ({
default: ({ selectedDocumentId }: { selectedDocumentId: string | null }) => (
<div data-testid="workspace-document-panel">{selectedDocumentId}</div>
),
}));
beforeEach(() => {
vi.mocked(swr.useClientDataSWR).mockImplementation((() => ({
data: [],
@ -143,7 +138,7 @@ beforeEach(() => {
...initialUserState,
preference: {
...initialUserState.preference,
lab: { ...initialUserState.preference.lab, enableAgentWorkingPanel: false },
lab: { ...initialUserState.preference.lab },
},
});
});
@ -152,56 +147,21 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe('Conversation right panel mount', () => {
it('does not mount the conversation-side right panel path when working panel lab feature is disabled', () => {
render(<Conversation />);
describe('AgentWorkingSidebar', () => {
it('renders panel header title and resources empty state', () => {
render(<AgentWorkingSidebar />);
expect(screen.getByText('chat-header')).toBeInTheDocument();
expect(screen.getByText('conversation-area')).toBeInTheDocument();
expect(screen.queryByTestId('right-panel')).not.toBeInTheDocument();
expect(screen.queryByTestId('workspace-resources')).not.toBeInTheDocument();
});
it('mounts the conversation-side right panel path and defaults the right panel to collapsed when working panel lab feature is enabled', async () => {
useUserStore.setState({
preference: {
...useUserStore.getState().preference,
lab: { ...useUserStore.getState().preference.lab, enableAgentWorkingPanel: true },
},
});
const { unmount } = render(<Conversation />);
expect(screen.getByText('chat-header')).toBeInTheDocument();
expect(screen.getByText('conversation-area')).toBeInTheDocument();
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-stable-layout', 'true');
expect(screen.getByTestId('workspace-resources')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-expand', 'false');
expect(useGlobalStore.getState().status.showRightPanel).toBe(false);
});
unmount();
expect(useGlobalStore.getState().status.showRightPanel).toBe(false);
});
it('renders resources section and empty state', () => {
render(<AgentWorkingSidebar selectedDocumentId={null} onSelectDocument={vi.fn()} />);
// Panel-level title
expect(screen.getAllByText('Resources').length).toBeGreaterThan(0);
const resources = screen.getByTestId('workspace-resources');
expect(resources).toHaveTextContent('Resources');
expect(resources).toHaveTextContent('No agent documents yet');
});
it('switches to document editor inside the right panel when a document is selected', () => {
render(<AgentWorkingSidebar selectedDocumentId={'doc-1'} onSelectDocument={vi.fn()} />);
it('mounts a right panel wrapper', () => {
render(<AgentWorkingSidebar />);
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
expect(screen.getByTestId('workspace-document-panel')).toHaveTextContent('doc-1');
expect(screen.queryByTestId('workspace-resources')).not.toBeInTheDocument();
expect(screen.getByTestId('right-panel')).toHaveAttribute('data-stable-layout', 'true');
});
});

View file

@ -1,53 +1,56 @@
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { PanelRightCloseIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { DESKTOP_HEADER_ICON_SMALL_SIZE } from '@/const/layoutTokens';
import RightPanel from '@/features/RightPanel';
import { useGlobalStore } from '@/store/global';
import AgentDocumentEditorPanel from './AgentDocumentEditorPanel';
import ProgressSection from './ProgressSection';
import ResourcesSection from './ResourcesSection';
interface AgentWorkingSidebarProps {
onSelectDocument: (id: string | null) => void;
selectedDocumentId: string | null;
}
const styles = createStaticStyles(({ css }) => ({
body: css`
overflow-y: auto;
flex: 1;
min-height: 0;
`,
header: css`
flex-shrink: 0;
`,
}));
const AgentWorkingSidebar = memo<AgentWorkingSidebarProps>(
({ onSelectDocument, selectedDocumentId }) => {
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
const isDocumentMode = Boolean(selectedDocumentId);
const AgentWorkingSidebar = memo(() => {
const { t } = useTranslation('chat');
const toggleRightPanel = useGlobalStore((s) => s.toggleRightPanel);
return (
<RightPanel stableLayout defaultWidth={360} maxWidth={720} minWidth={300}>
{isDocumentMode ? (
<AgentDocumentEditorPanel
selectedDocumentId={selectedDocumentId}
onClose={() => onSelectDocument(null)}
return (
<RightPanel stableLayout defaultWidth={360} maxWidth={720} minWidth={300}>
<Flexbox height={'100%'} width={'100%'}>
<Flexbox
horizontal
align={'center'}
className={styles.header}
height={44}
justify={'space-between'}
paddingInline={16}
>
<Text strong>{t('workingPanel.resources')}</Text>
<ActionIcon
icon={PanelRightCloseIcon}
size={DESKTOP_HEADER_ICON_SMALL_SIZE}
onClick={() => toggleRightPanel(false)}
/>
) : (
<Flexbox height={'100%'} style={{ position: 'relative' }} width={'100%'}>
<ActionIcon
icon={PanelRightCloseIcon}
size={DESKTOP_HEADER_ICON_SIZE}
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
onClick={() => toggleRightPanel(false)}
/>
<Flexbox gap={8} height={'100%'} style={{ overflowY: 'auto' }} width={'100%'}>
{/* <AgentSummary /> */}
<ProgressSection />
<ResourcesSection
selectedDocumentId={selectedDocumentId}
onSelectDocument={onSelectDocument}
/>
</Flexbox>
</Flexbox>
)}
</RightPanel>
);
},
);
</Flexbox>
<Flexbox className={styles.body} gap={8} width={'100%'}>
<ProgressSection />
<ResourcesSection />
</Flexbox>
</Flexbox>
</RightPanel>
);
});
export default AgentWorkingSidebar;

View file

@ -1,5 +1,5 @@
import { Flexbox, TooltipGroup } from '@lobehub/ui';
import React, { memo, Suspense, useEffect, useState } from 'react';
import React, { memo, Suspense, useEffect } from 'react';
import DragUploadZone, { useUploadFiles } from '@/components/DragUploadZone';
import Loading from '@/components/Loading/BrandTextLoading';
@ -7,12 +7,9 @@ import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
import ConversationArea from './ConversationArea';
import ChatHeader from './Header';
import AgentWorkingSidebar from './WorkingSidebar';
const wrapperStyle: React.CSSProperties = {
height: '100%',
@ -23,19 +20,12 @@ const wrapperStyle: React.CSSProperties = {
const ChatConversation = memo(() => {
const showHeader = useGlobalStore(systemStatusSelectors.showChatHeader);
const isStatusInit = useGlobalStore(systemStatusSelectors.isStatusInit);
const enableAgentWorkingPanel = useUserStore(labPreferSelectors.enableAgentWorkingPanel);
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
// Get current agent's model info for vision support check
const model = useAgentStore(agentSelectors.currentAgentModel);
const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
const { handleUploadFiles } = useUploadFiles({ model, provider });
useEffect(() => {
setSelectedDocumentId(null);
}, [activeAgentId]);
useEffect(() => {
if (!isStatusInit) return;
useGlobalStore.getState().toggleRightPanel(false);
@ -44,26 +34,11 @@ const ChatConversation = memo(() => {
return (
<Suspense fallback={<Loading debugId="Agent > ChatConversation" />}>
<DragUploadZone style={wrapperStyle} onUploadFiles={handleUploadFiles}>
<Flexbox
horizontal
height={'100%'}
style={{ overflow: 'hidden', position: 'relative' }}
width={'100%'}
>
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
{showHeader && <ChatHeader />}
<TooltipGroup>
<ConversationArea />
</TooltipGroup>
</Flexbox>
{/* TODO: Remove this labs-only mount gate once Working Panel is no longer experimental.
See the matching TODO in `src/hooks/useHotkeys/globalScope.ts`. */}
{enableAgentWorkingPanel && (
<AgentWorkingSidebar
selectedDocumentId={selectedDocumentId}
onSelectDocument={setSelectedDocumentId}
/>
)}
<Flexbox flex={1} height={'100%'} style={{ minWidth: 0 }}>
{showHeader && <ChatHeader />}
<TooltipGroup>
<ConversationArea />
</TooltipGroup>
</Flexbox>
</DragUploadZone>
</Suspense>

View file

@ -6,6 +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';
@ -22,6 +23,7 @@ const ChatPage = memo(() => {
>
<Conversation />
<Portal />
<AgentWorkingSidebar />
</Flexbox>
<MainInterfaceTracker />
<TelemetryNotification mobile={false} />

View file

@ -39,14 +39,12 @@ const Page = memo(() => {
isPreferenceInit,
enableInputMarkdown,
enableGatewayMode,
enableAgentWorkingPanel,
enableHeterogeneousAgent,
updateLab,
] = useUserStore((s) => [
preferenceSelectors.isPreferenceInit(s),
labPreferSelectors.enableInputMarkdown(s),
labPreferSelectors.enableGatewayMode(s),
labPreferSelectors.enableAgentWorkingPanel(s),
labPreferSelectors.enableHeterogeneousAgent(s),
s.updateLab,
]);
@ -125,19 +123,6 @@ const Page = memo(() => {
label: tLabs('features.inputMarkdown.title'),
minWidth: undefined,
},
{
children: (
<Switch
checked={enableAgentWorkingPanel}
loading={!isPreferenceInit}
onChange={(checked: boolean) => updateLab({ enableAgentWorkingPanel: checked })}
/>
),
className: styles.labItem,
desc: tLabs('features.agentWorkingPanel.desc'),
label: tLabs('features.agentWorkingPanel.title'),
minWidth: undefined,
},
...(isDesktop
? [
{

View file

@ -9,6 +9,12 @@ import { lambdaClient } from '@/libs/trpc/client';
export const agentDocumentSWRKeys = {
documents: (agentId: string) => ['agent-documents', agentId] as const,
/**
* UI-side list: raw AgentDocumentWithRules (includes documentId, sourceType, createdAt).
* Kept separate from `documents` because the agent store writes mapAgentDocumentsToContext(...)
* under that key, which drops those fields.
*/
documentsList: (agentId: string) => ['agent-documents-list', agentId] as const,
readDocument: (agentId: string, id: string) =>
['workspace-agent-document-editor', agentId, id] as const,
};
@ -29,6 +35,7 @@ export const normalizeAgentDocumentPosition = (
const revalidateAgentDocuments = async (agentId: string) => {
await mutate(agentDocumentSWRKeys.documents(agentId));
await mutate(agentDocumentSWRKeys.documentsList(agentId));
};
const revalidateReadDocument = async (agentId: string, id: string) => {

View file

@ -3,8 +3,6 @@ import { DEFAULT_PREFERENCE } from '@lobechat/const';
import { type UserState } from '@/store/user/initialState';
export const labPreferSelectors = {
enableAgentWorkingPanel: (s: UserState): boolean =>
s.preference.lab?.enableAgentWorkingPanel ?? false,
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
enableHeterogeneousAgent: (s: UserState): boolean =>
s.preference.lab?.enableHeterogeneousAgent ?? false,