diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 1a8176561..a335fa1bb 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14640,7 +14640,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.7", + "version": "1.1.10", "category": "workflow-automation", "tags": [ "siem", @@ -14662,7 +14662,7 @@ "name": "Flocks SOC Workspace Query", "description": "Query information from Flocks own SOC workspace pages, including dashboard, overview, alert lists, alert details, triage reports, and workflow status.", "descriptionCn": "查询 Flocks 自身 SOC 工作区里的信息,包括 dashboard、overview、告警调查列表、告警详情、研判报告和工作流状态。", - "version": "1.0.0", + "version": "1.1.9", "category": "integration", "tags": [ "siem", @@ -14746,6 +14746,86 @@ "trust": "official", "riskLevel": "medium", "manifestPath": "plugins/components/soc-workspace/manifest.json" + }, + { + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/code_audit_ui/manifest.json" + }, + { + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/code-audit-workspace/manifest.json" + }, + { + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/webuis/ai_redteam_ui/manifest.json" + }, + { + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "trust": "official", + "riskLevel": "low", + "edition": "pro", + "manifestPath": "plugins/components/ai-redteam-workspace/manifest.json" } ] } diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json new file mode 100644 index 000000000..72c3a7342 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "ai-redteam-workspace", + "name": "AI Redteam Workspace", + "nameCn": "AI 红队工作区", + "description": "Composite package for the Flocks AI redteam workspace WebUI.", + "descriptionCn": "Flocks AI 红队工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json new file mode 100644 index 000000000..6dbb294e3 --- /dev/null +++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai-redteam-workspace", + "type": "component", + "name": "AI Redteam Workspace Component", + "nameCn": "AI 红队场景套件", + "description": "Composite installer for the Flocks AI redteam workspace.", + "descriptionCn": "Flocks AI 红队工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/ai-redteam-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "ai_redteam_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/component.json b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json new file mode 100644 index 000000000..4d5da5021 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "hub.component.v1", + "id": "code-audit-workspace", + "name": "Code Audit Workspace", + "nameCn": "代码审计工作区", + "description": "Composite package for the Flocks code audit workspace WebUI.", + "descriptionCn": "Flocks 代码审计工作区组件包,包含 WebUI 页面。", + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ] +} diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json new file mode 100644 index 000000000..89886bab4 --- /dev/null +++ b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code-audit-workspace", + "type": "component", + "name": "Code Audit Workspace Component", + "nameCn": "代码审计场景套件", + "description": "Composite installer for the Flocks code audit workspace.", + "descriptionCn": "Flocks 代码审计工作区场景套件安装器。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/components/code-audit-workspace" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "component.json" + ], + "components": [ + { + "type": "webui", + "id": "code_audit_ui" + } + ], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json index a1ca1acf3..124188616 100644 --- a/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json +++ b/.flocks/flockshub/plugins/tools/python/soc_workspace_query/manifest.json @@ -5,7 +5,7 @@ "name": "Flocks SOC Workspace Query", "description": "Query information from Flocks own SOC workspace pages, including dashboard, overview, alert lists, alert details, triage reports, and workflow status.", "descriptionCn": "查询 Flocks 自身 SOC 工作区里的信息,包括 dashboard、overview、告警调查列表、告警详情、研判报告和工作流状态。", - "version": "1.0.0", + "version": "1.1.9", "author": "Flocks Team", "license": "MIT", "homepage": "", diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json new file mode 100644 index 000000000..777ed3f52 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-overview", + "title": "演练总览", + "titleEn": "Campaign Overview", + "route": "/contracts/webui/ai-redteam-overview", + "icon": "Crosshair", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx new file mode 100644 index 000000000..4a867e5a0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

AI 红队场景的入口:编排演练、跟踪每一轮的结论。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json new file mode 100644 index 000000000..0979aec6d --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "ai-redteam-surface", + "title": "攻击面", + "titleEn": "Attack Surface", + "route": "/contracts/webui/ai-redteam-surface", + "icon": "Radar", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx new file mode 100644 index 000000000..a93aa94b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

汇总目标系统暴露出的攻击面与已验证的利用路径。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json new file mode 100644 index 000000000..eb4358c3f --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "ai_redteam_ui", + "type": "webui", + "name": "AI Redteam Workspace WebUI", + "nameCn": "AI 红队工作区页面", + "description": "AI redteam workspace pages for campaigns and attack surface.", + "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/ai_redteam_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json new file mode 100644 index 000000000..161447dd0 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "ai_redteam_ui", + "version": "1.0.0", + "title": "AI 红队", + "titleEn": "AI Redteam", + "icon": "Crosshair", + "order": 30, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "ai-redteam-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json new file mode 100644 index 000000000..1d3a2cd1b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-findings", + "title": "缺陷清单", + "titleEn": "Findings", + "route": "/contracts/webui/code-audit-findings", + "icon": "ListChecks", + "order": 20, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx new file mode 100644 index 000000000..0de7204b2 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

按严重级别列出代码审计发现的问题,支持指派与复核。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json new file mode 100644 index 000000000..7d64ec965 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "code-audit-overview", + "title": "审计总览", + "titleEn": "Audit Overview", + "route": "/contracts/webui/code-audit-overview", + "icon": "FileSearch", + "order": 10, + "enabled": true, + "placement": "home.after", + "entry": "src/index.tsx", + "updatedAt": 1788900000000 +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx new file mode 100644 index 000000000..62d6b9e99 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx @@ -0,0 +1,14 @@ +import { Card } from '@flocks/webui-contract-sdk'; + +export default function Page() { + return ( + +

代码审计场景的入口:接入仓库、发起审计、跟踪修复进度。

+ +
+ ); +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx new file mode 100644 index 000000000..12c3418bb --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx @@ -0,0 +1,3 @@ +import Page from './Page'; + +export default Page; diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json new file mode 100644 index 000000000..70b899324 --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": "hub.plugin.v1", + "id": "code_audit_ui", + "type": "webui", + "name": "Code Audit Workspace WebUI", + "nameCn": "代码审计工作区页面", + "description": "Code audit workspace pages for audit overview and findings.", + "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。", + "version": "1.0.0", + "author": "Flocks Team", + "license": "MIT", + "homepage": "", + "category": "workflow-automation", + "tags": [ + "integration" + ], + "useCases": [ + "security-reporting" + ], + "domains": [ + "security-ops" + ], + "capabilities": [ + "workflow" + ], + "trust": "official", + "edition": "pro", + "source": { + "kind": "bundled", + "path": "plugins/webuis/code_audit_ui" + }, + "compatibility": { + "flocks": ">=0.8.0", + "os": [ + "darwin", + "linux", + "windows" + ] + }, + "dependencies": { + "skills": [], + "tools": [], + "python": [], + "external": [] + }, + "permissions": { + "tools": [], + "network": false, + "shell": false, + "filesystem": "read" + }, + "risk": { + "level": "low", + "reasons": [] + }, + "entrypoints": [ + "workspace.json" + ], + "components": [], + "checksums": {} +} diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json new file mode 100644 index 000000000..01ac6d16b --- /dev/null +++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json @@ -0,0 +1,11 @@ +{ + "id": "code_audit_ui", + "version": "1.0.0", + "title": "代码审计", + "titleEn": "Code Audit", + "icon": "FileSearch", + "order": 20, + "enabled": true, + "placement": "sceneWorkspace", + "defaultPageId": "code-audit-overview" +} diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json index bb3542920..44104ee78 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json +++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json @@ -5,7 +5,7 @@ "name": "SOC Workspace WebUI", "description": "SOC workspace pages for posture, overview, and alert investigation.", "descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。", - "version": "1.1.7", + "version": "1.1.10", "author": "Flocks Team", "license": "MIT", "homepage": "", diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx index 6ba6232a5..06ec03ec5 100644 --- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx +++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_alerts/src/index.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '@flocks/webui-contract-sdk'; import { filterOptionText, matchesFilterOptionSearch } from './filterValues'; @@ -1123,6 +1123,13 @@ function Badge({ children, tone = 'slate' }: { children: React.ReactNode; tone?: return {children}; } +// This page is bundled by esbuild without Tailwind, so only utility classes that +// already appear in the host webui/src are generated. Layout values that the host +// never uses (arbitrary grid tracks, fixed widths) have to be inline styles. +const FILTER_GRID_STYLE = { gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 220px), 1fr))' }; +const FILTER_PANEL_MIN_WIDTH = 320; +const FILTER_PANEL_EDGE_GAP = 16; + function FilterDropdown({ config, value, @@ -1144,6 +1151,8 @@ function FilterDropdown({ }) { const [search, setSearch] = useState(''); const [draft, setDraft] = useState(value); + const [alignRight, setAlignRight] = useState(false); + const rootRef = useRef(null); useEffect(() => { if (open) { @@ -1152,6 +1161,33 @@ function FilterDropdown({ } }, [open, value]); + // The panel is at least FILTER_PANEL_MIN_WIDTH wide, which is wider than a + // narrow grid column. Flip it to the column's right edge when left alignment + // would run past the page's right edge (and cause a horizontal scrollbar). + useLayoutEffect(() => { + if (!open) return undefined; + const measure = () => { + const root = rootRef.current; + if (!root) return; + const rect = root.getBoundingClientRect(); + const pageRoot = root.closest('[data-soc-alerts-root]'); + let boundsLeft = 0; + let boundsRight = document.documentElement.clientWidth; + if (pageRoot instanceof HTMLElement) { + boundsLeft = pageRoot.getBoundingClientRect().left + pageRoot.clientLeft; + boundsRight = boundsLeft + pageRoot.clientWidth; + } + const overflowsRight = rect.left + FILTER_PANEL_MIN_WIDTH > boundsRight - FILTER_PANEL_EDGE_GAP; + // Only flip when the flipped panel still starts inside the page: content + // pushed past the left edge of the scroll container cannot be scrolled to. + const fitsRightAligned = rect.right - FILTER_PANEL_MIN_WIDTH >= boundsLeft; + setAlignRight(overflowsRight && fitsRightAligned); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [open]); + const choices = options.filter((option) => option && option !== ALL_FILTER_VALUE); const visibleChoices = choices.filter((choice) => matchesFilterOptionSearch(config.key, choice, search, tr)); const selected = new Set(draft.map(normalized)); @@ -1164,7 +1200,7 @@ function FilterDropdown({ }; return ( -
+
{open && ( -
+
-
+ {/* max-h-[200px] is not in the host CSS; the inline cap keeps the footer within reach. */} +
{visibleChoices.map((choice) => { const checked = selected.has(normalized(choice)); return ( @@ -1212,7 +1252,7 @@ function FilterDropdown({
- +
@@ -1565,7 +1605,7 @@ export default function SocAlertsPage() { const timeline = useMemo(() => buildTimeline(filteredIncidents, resolveTimeWindow(appliedTimeFilter)), [appliedTimeFilter, filteredIncidents]); return ( -
+
{openMenu && -
+ onQuery={(next) => { + const nextFilters = cloneFilters({ ...selectedFilters, [config.key]: next }); + setSelectedFilters(nextFilters); + setAppliedKeyword(keyword); + setAppliedFilters(cloneFilters(nextFilters)); + setAppliedTimeFilter(timeFilter); + setOpenMenu(null); + setPage(1); + }} + tr={tr} + /> + ))}
-
+
+ +
}> + + + , + ); + await waitFor(() => expect(previewSuspension.attempts).toBeGreaterThan(0)); + expect(screen.queryByText('Loading preview translations')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'context.back' })).toBeVisible(); + previewSuspension.current = null; + await act(async () => translations.resolve()); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('report.md:# Report'); + expect(sessionApi.readContextFile.mock.calls.filter((call) => !(call[2] as AbortSignal).aborted)).toHaveLength(1); + }); + + it('uses snapshot metadata when present and opens non-text metadata without a content read', async () => { + const props = { sessionId: 'sess-1', snapshot, loading: false, onClose: vi.fn(), onRefresh: vi.fn(), onFocusMessage: vi.fn() }; + const view = render(); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('paper.pdf:'); + expect(sessionApi.getContextFile).not.toHaveBeenCalled(); + expect(sessionApi.readContextFile).not.toHaveBeenCalled(); + sessionApi.getContextFile.mockResolvedValue({ ...snapshot.contextFiles[0], resourceID: 'historical-pdf', displayName: 'earlier.pdf' }); + view.rerender(); + await waitFor(() => expect(screen.getByTestId('file-preview')).toHaveTextContent('earlier.pdf:')); + expect(sessionApi.readContextFile).not.toHaveBeenCalled(); + }); + + it('cancels stale metadata when another file is requested', async () => { + const metadata = deferred(); + sessionApi.getContextFile.mockReturnValue(metadata.promise); + const props = { sessionId: 'sess-1', snapshot, loading: false, onClose: vi.fn(), onRefresh: vi.fn(), onFocusMessage: vi.fn() }; + const view = render(); + const signal = sessionApi.getContextFile.mock.calls[0][2] as AbortSignal; + view.rerender(); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('report.md:# Report'); + expect(signal.aborted).toBe(true); + await act(async () => metadata.resolve({ ...snapshot.outputs[0], resourceID: 'historical', displayName: 'old.md' })); + expect(sessionApi.readContextFile).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('file-preview')).toHaveTextContent('report.md:# Report'); + }); + + it('keeps loading more available across empty filtered directory pages and appends entries', async () => { + sessionApi.listContextRoot + .mockResolvedValueOnce({ items: [], hasMore: true, nextOffset: 100 }) + .mockResolvedValueOnce({ items: [{ name: 'first.md', path: 'first.md', type: 'file' }], hasMore: true, nextOffset: 200 }) + .mockResolvedValueOnce({ items: [], hasMore: true, nextOffset: 300 }) + .mockResolvedValueOnce({ items: [{ name: 'last.md', path: 'last.md', type: 'file' }], hasMore: false, nextOffset: null }); + renderPanel(); + fireEvent.click(screen.getByText('Research')); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadMore' })); + expect(await screen.findByText('first.md')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'context.loadMore' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'context.loadMore' })).toBeEnabled()); + expect(screen.getByText('first.md')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'context.loadMore' })); + expect(await screen.findByText('last.md')).toBeInTheDocument(); + expect(screen.getByText('first.md')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'context.loadMore' })).not.toBeInTheDocument(); + expect(sessionApi.listContextRoot.mock.calls.map((call) => call[2].offset)).toEqual([0, 100, 200, 300]); + }); + + it('does not reopen a directory after returning while its listing is pending', async () => { + const page = deferred(); + sessionApi.listContextRoot.mockReturnValue(page.promise); + renderPanel(); + fireEvent.click(screen.getByText('Research')); + const signal = sessionApi.listContextRoot.mock.calls[0][3] as AbortSignal; + fireEvent.click(screen.getByRole('button', { name: 'context.back' })); + expect(signal.aborted).toBe(true); + await act(async () => page.resolve({ items: [{ name: 'late.md', path: 'late.md', type: 'file' }], hasMore: false, nextOffset: null })); + expect(screen.getByText('report.md')).toBeInTheDocument(); + expect(screen.queryByText('late.md')).not.toBeInTheDocument(); + }); + + it('does not replace a parent directory with an earlier child listing', async () => { + const child = deferred(); + sessionApi.listContextRoot + .mockResolvedValueOnce({ items: [{ name: 'child', path: 'child', type: 'directory' }], hasMore: false, nextOffset: null }) + .mockReturnValueOnce(child.promise) + .mockResolvedValueOnce({ items: [{ name: 'parent.md', path: 'parent.md', type: 'file' }], hasMore: false, nextOffset: null }); + renderPanel(); + fireEvent.click(screen.getByText('Research')); + fireEvent.click(await screen.findByText('child')); + const signal = sessionApi.listContextRoot.mock.calls[1][3] as AbortSignal; + fireEvent.click(screen.getByRole('button', { name: 'context.parentFolder' })); + expect(await screen.findByText('parent.md')).toBeInTheDocument(); + expect(signal.aborted).toBe(true); + await act(async () => child.resolve({ items: [{ name: 'late.md', path: 'child/late.md', type: 'file' }], hasMore: false, nextOffset: null })); + expect(screen.queryByText('late.md')).not.toBeInTheDocument(); + expect(screen.getByText('parent.md')).toBeInTheDocument(); + }); + + it('cancels pending directory pagination when opening a preview', async () => { + const page = deferred(); + sessionApi.listContextRoot + .mockResolvedValueOnce({ items: [{ name: 'chapter.md', path: 'chapter.md', type: 'file', isTextFile: true }], hasMore: true, nextOffset: 100 }) + .mockReturnValueOnce(page.promise); + renderPanel(); + fireEvent.click(screen.getByText('Research')); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadMore' })); + const signal = sessionApi.listContextRoot.mock.calls[1][3] as AbortSignal; + fireEvent.click(screen.getByText('chapter.md')); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('chapter.md:# Chapter'); + expect(signal.aborted).toBe(true); + await act(async () => page.resolve({ items: [], hasMore: false, nextOffset: null })); + expect(screen.getByTestId('file-preview')).toHaveTextContent('chapter.md:# Chapter'); + }); + + it('cancels root content when navigating back before it resolves', async () => { + const content = deferred<{ content: string }>(); + sessionApi.readContextRootFile.mockReturnValue(content.promise); + renderPanel(); + fireEvent.click(screen.getByText('Research')); + fireEvent.click(await screen.findByText('chapter.md')); + const signal = sessionApi.readContextRootFile.mock.calls[0][3] as AbortSignal; + fireEvent.click(screen.getByRole('button', { name: 'context.back' })); + expect(signal.aborted).toBe(true); + await act(async () => content.resolve({ content: 'Late' })); + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument(); + expect(screen.getByText('report.md')).toBeInTheDocument(); + }); + + it.each(['back', 'close', 'unmount'])('cancels a pending preview on %s and ignores late errors', async (action) => { + const content = deferred<{ content: string }>(); + sessionApi.readContextFile.mockReturnValue(content.promise); + const onClose = vi.fn(); + const view = renderPanel({ onClose }); + fireEvent.click(screen.getByText('report.md')); + const signal = sessionApi.readContextFile.mock.calls[0][2] as AbortSignal; + if (action === 'unmount') view.unmount(); + else fireEvent.click(screen.getByRole('button', { name: `context.${action}` })); + expect(signal.aborted).toBe(true); + await act(async () => content.reject({ response: { data: { detail: [{ msg: 'Stale failure' }] } } })); + expect(screen.queryByText('Stale failure')).not.toBeInTheDocument(); + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument(); + if (action === 'close') expect(onClose).toHaveBeenCalledOnce(); + }); + + it('isolates preview requests across A -> B -> A', async () => { + const first = deferred<{ content: string }>(); + const second = deferred<{ content: string }>(); + const latest = deferred<{ content: string }>(); + sessionApi.readContextFile.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise).mockReturnValueOnce(latest.promise); + const props = { snapshot, loading: false, onClose: vi.fn(), onRefresh: vi.fn(), onFocusMessage: vi.fn() }; + const view = render(); + fireEvent.click(screen.getByText('report.md')); + view.rerender(); + fireEvent.click(screen.getByText('report.md')); + view.rerender(); + fireEvent.click(screen.getByText('report.md')); + expect((sessionApi.readContextFile.mock.calls[0][2] as AbortSignal).aborted).toBe(true); + expect((sessionApi.readContextFile.mock.calls[1][2] as AbortSignal).aborted).toBe(true); + await act(async () => latest.resolve({ content: 'Latest A' })); + await act(async () => { first.resolve({ content: 'Old A' }); second.resolve({ content: 'Old B' }); }); + expect(screen.getByTestId('file-preview')).toHaveTextContent('report.md:Latest A'); + }); + + it('does not refresh or show a stale folder mutation error after switching sessions', async () => { + const mutation = deferred(); + sessionApi.addContextFolder.mockReturnValue(mutation.promise); + const onRefresh = vi.fn(); + const props = { snapshot, loading: false, onClose: vi.fn(), onRefresh, onFocusMessage: vi.fn() }; + const view = render(); + fireEvent.click(screen.getByTitle('context.addFolder')); + fireEvent.change(screen.getByPlaceholderText('context.folderPathPlaceholder'), { target: { value: '/work' } }); + fireEvent.click(screen.getByRole('button', { name: 'context.add' })); + view.rerender(); + await act(async () => mutation.resolve()); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('renders validation errors as strings and exposes earlier-history pagination', async () => { + sessionApi.readContextFile.mockRejectedValue({ response: { data: { detail: [{ msg: 'Invalid resource' }] } } }); + const onLoadMore = vi.fn(); + renderPanel({ snapshot: { ...snapshot, hasMore: true, nextBefore: 'msg-older' }, onLoadMore }); + fireEvent.click(screen.getByRole('button', { name: 'context.loadEarlier' })); + expect(onLoadMore).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByText('report.md')); + expect(await screen.findByText('Invalid resource')).toBeInTheDocument(); + }); +}); diff --git a/webui/src/pages/Session/SessionContextPanel.tsx b/webui/src/pages/Session/SessionContextPanel.tsx new file mode 100644 index 000000000..4ef9f1e96 --- /dev/null +++ b/webui/src/pages/Session/SessionContextPanel.tsx @@ -0,0 +1,679 @@ +import { Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, ChevronDown, Download, FileText, Folder, FolderPlus, + Maximize2, RefreshCw, Search, SlidersHorizontal, Sparkles, X, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { + sessionApi, + type SessionContextFile, + type SessionContextRoot, + type SessionContextRootNode, + type SessionContextSkill, + type SessionContextSnapshot, +} from '@/api/session'; +import { formatBytes, type WorkspaceNode } from '@/api/workspace'; +import { + FilePreviewRenderer, + PreviewModal, + type PreviewFileAccess, +} from '@/components/common/FilePreview'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; +import { TodoList } from '@/components/common/TodoList'; +import { extractErrorMessage } from '@/utils/error'; + +interface PreviewState { + node: WorkspaceNode; + fileAccess: PreviewFileAccess; + content: string | null; + truncated: boolean; + previewLimitBytes: number | null; + downloadUrl: string; +} + +export interface SessionContextPanelProps { + sessionId: string; + snapshot: SessionContextSnapshot | null; + loading: boolean; + loadingMore?: boolean; + onLoadMore?: () => Promise | void; + error?: string | null; + requestedResourceID?: string | null; + onRequestedResourceConsumed?: () => void; + onClose: () => void; + onRefresh: () => Promise | void; + onFocusMessage: (messageId: string) => void; +} + +type FileFilter = 'all' | 'outputs' | 'context'; +type FileSort = 'updated' | 'name' | 'size'; + +function fileTimestamp(file: SessionContextFile): number { + return file.modifiedAt || file.createdAt || 0; +} + +function toWorkspaceNode(file: SessionContextFile): WorkspaceNode { + return { + name: file.displayName, + path: file.resourceID, + type: 'file', + size: file.size ?? undefined, + modified_at: file.modifiedAt ? file.modifiedAt / 1000 : undefined, + is_text_file: file.isTextFile, + }; +} + +function Section({ + title, + count, + icon, + children, + defaultOpen = true, + actions, +}: { + title: string; + count?: number; + icon: React.ReactNode; + children: React.ReactNode; + defaultOpen?: boolean; + actions?: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+
+ + {actions} +
+ {open &&
{children}
} +
+ ); +} + +function EmptySection({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export default function SessionContextPanel({ + sessionId, + snapshot, + loading, + loadingMore = false, + onLoadMore, + error, + requestedResourceID, + onRequestedResourceConsumed, + onClose, + onRefresh, + onFocusMessage, +}: SessionContextPanelProps) { + const { t } = useTranslation('session'); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('updated'); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [root, setRoot] = useState(null); + const [rootPath, setRootPath] = useState(''); + const [rootItems, setRootItems] = useState([]); + const [rootLoading, setRootLoading] = useState(false); + const [rootHasMore, setRootHasMore] = useState(false); + const [rootNextOffset, setRootNextOffset] = useState(null); + const [showFolderInput, setShowFolderInput] = useState(false); + const [folderPath, setFolderPath] = useState(''); + const [folderBusy, setFolderBusy] = useState(false); + const [actionError, setActionError] = useState(null); + const scope = useMemo(() => ({ sessionId, active: true }), [sessionId]); + const scopeRef = useRef(scope); + scopeRef.current = scope; + const navigationRef = useRef(null); + const consumedResourceRef = useRef(null); + + const cancelNavigation = useCallback(() => { + navigationRef.current?.abort(); + navigationRef.current = null; + setPreviewLoading(false); + setRootLoading(false); + setFullscreen(false); + setActionError(null); + }, []); + + const beginNavigation = useCallback(() => { + if (scopeRef.current !== scope || !scope.active) return null; + cancelNavigation(); + const controller = new AbortController(); + navigationRef.current = controller; + return { + signal: controller.signal, + isCurrent: () => scopeRef.current === scope && scope.active + && navigationRef.current === controller && !controller.signal.aborted, + }; + }, [cancelNavigation, scope]); + + useEffect(() => { + scope.active = true; + consumedResourceRef.current = null; + cancelNavigation(); + setPreview(null); + setRoot(null); + setRootPath(''); + setRootItems([]); + setRootHasMore(false); + setRootNextOffset(null); + setFolderBusy(false); + setFolderPath(''); + setShowFolderInput(false); + return () => { + scope.active = false; + navigationRef.current?.abort(); + navigationRef.current = null; + }; + }, [cancelNavigation, scope]); + + const closePanel = () => { + cancelNavigation(); + onClose(); + }; + const backFromPreview = () => { + cancelNavigation(); + setPreview(null); + }; + const backFromRoot = () => { + cancelNavigation(); + setRoot(null); + setRootItems([]); + setRootPath(''); + setRootHasMore(false); + setRootNextOffset(null); + }; + + const files = useMemo(() => { + const all = [...(snapshot?.outputs || []), ...(snapshot?.contextFiles || [])]; + const normalizedQuery = query.trim().toLowerCase(); + const filtered = all.filter((file) => { + if (filter !== 'all' && file.section !== filter) return false; + return !normalizedQuery + || file.displayName.toLowerCase().includes(normalizedQuery) + || file.logicalPath.toLowerCase().includes(normalizedQuery) + || file.mimeType.toLowerCase().includes(normalizedQuery); + }); + return filtered.sort((left, right) => { + if (sort === 'name') return left.displayName.localeCompare(right.displayName); + if (sort === 'size') return (right.size || 0) - (left.size || 0); + return fileTimestamp(right) - fileTimestamp(left); + }); + }, [filter, query, snapshot, sort]); + const outputFiles = useMemo( + () => files.filter((file) => file.section === 'outputs'), + [files], + ); + const contextFiles = useMemo( + () => files.filter((file) => file.section === 'context'), + [files], + ); + + const openResource = useCallback(async (resource: SessionContextFile | string) => { + const request = beginNavigation(); + if (!request) return; + setPreviewLoading(true); + try { + const file = typeof resource === 'string' + ? await sessionApi.getContextFile(sessionId, resource, request.signal) + : resource; + if (!request.isCurrent()) return; + const node = toWorkspaceNode(file); + const fileAccess: PreviewFileAccess = { + previewUrl: (resourceId) => sessionApi.contextFilePreviewUrl(sessionId, resourceId), + downloadUrl: (resourceId) => sessionApi.contextFileDownloadUrl(sessionId, resourceId), + }; + const content = file.isTextFile && file.status !== 'missing' + ? await sessionApi.readContextFile(sessionId, file.resourceID, request.signal) + : null; + if (!request.isCurrent()) return; + setPreview({ + node, + fileAccess, + content: content?.content ?? null, + truncated: content?.truncated ?? false, + previewLimitBytes: content?.previewLimitBytes ?? null, + downloadUrl: fileAccess.downloadUrl(file.resourceID), + }); + setRoot(null); + } catch (error) { + if (request.isCurrent()) setActionError(extractErrorMessage(error, 'Request failed')); + } finally { + if (request.isCurrent()) { + navigationRef.current = null; + setPreviewLoading(false); + } + } + }, [beginNavigation, sessionId]); + + useEffect(() => { + if (!requestedResourceID) { + consumedResourceRef.current = null; + return; + } + if (consumedResourceRef.current === requestedResourceID) return; + consumedResourceRef.current = requestedResourceID; + const file = snapshot?.sessionID === sessionId + ? [...snapshot.outputs, ...snapshot.contextFiles].find((item) => item.resourceID === requestedResourceID) + : undefined; + onRequestedResourceConsumed?.(); + // Message cards may point outside the loaded history. Resolve metadata directly. + void openResource(file ?? requestedResourceID); + }, [onRequestedResourceConsumed, openResource, requestedResourceID, sessionId, snapshot]); + + const openRootFile = useCallback(async (selectedRoot: SessionContextRoot, item: SessionContextRootNode) => { + const request = beginNavigation(); + if (!request) return; + const node: WorkspaceNode = { + name: item.name, + path: item.path, + type: 'file', + size: item.size ?? undefined, + modified_at: item.modifiedAt ? item.modifiedAt / 1000 : undefined, + is_text_file: item.isTextFile, + }; + const fileAccess: PreviewFileAccess = { + previewUrl: (path) => sessionApi.contextRootPreviewUrl(sessionId, selectedRoot.id, path), + downloadUrl: (path) => sessionApi.contextRootDownloadUrl(sessionId, selectedRoot.id, path), + }; + setActionError(null); + setPreviewLoading(true); + try { + const content = item.isTextFile + ? await sessionApi.readContextRootFile(sessionId, selectedRoot.id, item.path, request.signal) + : null; + if (!request.isCurrent()) return; + setPreview({ + node, + fileAccess, + content: content?.content ?? null, + truncated: content?.truncated ?? false, + previewLimitBytes: content?.previewLimitBytes ?? null, + downloadUrl: fileAccess.downloadUrl(item.path), + }); + setFullscreen(false); + } catch (error) { + if (request.isCurrent()) setActionError(extractErrorMessage(error, 'Request failed')); + } finally { + if (request.isCurrent()) { + navigationRef.current = null; + setPreviewLoading(false); + } + } + }, [beginNavigation, sessionId]); + + const loadRoot = useCallback(async (selectedRoot: SessionContextRoot, path = '', offset = 0) => { + if (offset > 0 && navigationRef.current) return; + const request = beginNavigation(); + if (!request) return; + setRootLoading(true); + setRoot(selectedRoot); + setRootPath(path); + setPreview(null); + if (offset === 0) { + setRootItems([]); + setRootHasMore(false); + setRootNextOffset(null); + } + try { + const response = await sessionApi.listContextRoot(sessionId, selectedRoot.id, { path, offset }, request.signal); + if (!request.isCurrent()) return; + setRootItems((current) => offset === 0 ? response.items : [ + ...current, ...response.items.filter((item) => !current.some((loaded) => loaded.path === item.path)), + ]); + setRootHasMore(response.hasMore); + setRootNextOffset(response.nextOffset); + } catch (error) { + if (request.isCurrent()) setActionError(extractErrorMessage(error, 'Request failed')); + } finally { + if (request.isCurrent()) { + navigationRef.current = null; + setRootLoading(false); + } + } + }, [beginNavigation, sessionId]); + + const addFolder = useCallback(async () => { + const path = folderPath.trim(); + if (!path) return; + setActionError(null); + setFolderBusy(true); + try { + await sessionApi.addContextFolder(sessionId, path); + if (scopeRef.current !== scope || !scope.active) return; + setFolderPath(''); + setShowFolderInput(false); + await onRefresh(); + } catch (error) { + if (scopeRef.current === scope && scope.active) setActionError(extractErrorMessage(error, 'Request failed')); + } finally { + if (scopeRef.current === scope && scope.active) setFolderBusy(false); + } + }, [folderPath, onRefresh, scope, sessionId]); + + const removeFolder = useCallback(async (selectedRoot: SessionContextRoot) => { + setActionError(null); + try { + await sessionApi.removeContextFolder(sessionId, selectedRoot.id); + if (scopeRef.current !== scope || !scope.active) return; + if (root?.id === selectedRoot.id) { + cancelNavigation(); + setRoot(null); + setRootItems([]); + setRootPath(''); + setPreview(null); + } + await onRefresh(); + } catch (error) { + if (scopeRef.current === scope && scope.active) setActionError(extractErrorMessage(error, 'Request failed')); + } + }, [cancelNavigation, onRefresh, root?.id, scope, sessionId]); + + const previewPending = previewLoading && ( +
+ ); + + if (preview) { + return ( +
+
+ + {preview.node.name} + + + + + +
+ {actionError &&
{actionError}
} +
+ {/* Loading preview translations must not suspend and reset the Session panel. */} + }> + undefined} + /> + +
+ {previewPending} + {fullscreen && ( + + setFullscreen(false)} + /> + + )} +
+ ); + } + + if (root) { + const parentPath = rootPath.includes('/') ? rootPath.slice(0, rootPath.lastIndexOf('/')) : ''; + return ( +
+
+ +
+
{root.displayName}
+ {rootPath &&
{rootPath}
} +
+ +
+ {actionError &&
{actionError}
} +
+ {rootPath && ( + + )} + {rootLoading && rootItems.length === 0 ?
: rootItems.length === 0 && !rootHasMore ? ( + {t('context.emptyFolder')} + ) : ( +
+ {rootItems.map((item) => ( + + ))} +
+ )} + {rootHasMore && rootNextOffset != null && ( + + )} +
+ {previewPending} +
+ ); + } + + return ( +
+
+ {previewLoading ? ( + + ) : } + {t('context.title')} + + +
+ +
+
+ + setQuery(event.target.value)} + placeholder={t('context.searchPlaceholder')} + className="h-8 w-full rounded-lg border border-zinc-200 bg-zinc-50 pl-8 pr-2 text-xs outline-none focus:border-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100" + /> +
+ + + +
+ +
+ {(actionError || error) &&
{actionError || error}
} + {snapshot?.hasMore && snapshot?.nextBefore && onLoadMore && ( + + )} + {loading && !snapshot ?
: ( + <> +
} defaultOpen={Boolean(snapshot?.progress.length)}> + {snapshot?.progress.length ? : {t('context.noProgress')}} +
+ +
}> + {outputFiles.length === 0 ? {t('context.noOutputs')} : ( + + )} +
+ +
} + actions={snapshot?.canManageFolders ? ( + + ) : undefined} + > + {snapshot?.canManageFolders && showFolderInput && ( +
+ setFolderPath(event.target.value)} placeholder={t('context.folderPathPlaceholder')} className="h-8 min-w-0 flex-1 rounded-lg border border-zinc-200 px-2 text-xs outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100" /> + +
+ )} +
+ {(snapshot?.roots || []).map((item) => ( +
+ + {item.kind === 'folder' && ( + + )} +
+ ))} + {contextFiles.length > 0 && ( + + )} + {(snapshot?.roots.length || 0) === 0 && contextFiles.length === 0 && {t('context.noContext')}} +
+
+ + {(snapshot?.skills.length || 0) > 0 && ( +
} defaultOpen={false}> +
+ {snapshot!.skills.map((skill) => )} +
+
+ )} + + )} +
+ {previewPending} +
+ ); +} + +function SkillRow({ skill }: { skill: SessionContextSkill }) { + const { t } = useTranslation('session'); + const failed = skill.status === 'error'; + const error = failed ? skill.error : undefined; + const label = skill.status === 'loaded' ? t('context.loaded') + : skill.status === 'loading' ? t('context.loading') + : failed ? t('context.failed') : t('context.unknown'); + const rowClassName = 'flex items-center gap-2 rounded-lg px-2 py-2 text-xs text-zinc-700 dark:text-zinc-200'; + const row = ( + <> + + {skill.name} + {label} + + ); + if (!error) return
{row}
; + return {row}; +} + +function SkillError({ error, rowClassName, children }: { error: string; rowClassName: string; children: React.ReactNode }) { + const [open, setOpen] = useState(false); + const errorId = useId(); + return ( +
+ + +
+ ); +} + +function FileRows({ + files, + onOpen, + onFocusMessage, + sessionId, +}: { + files: SessionContextFile[]; + onOpen: (file: SessionContextFile) => Promise; + onFocusMessage: (messageId: string) => void; + sessionId: string; +}) { + const { t } = useTranslation('session'); + return ( +
+ {files.map((file) => ( +
+ + + + + +
+ ))} +
+ ); +} diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 9e8ed72a6..164515a79 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -2,10 +2,33 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import KeepAlivePanes from '@/components/layout/KeepAlivePanes'; +import { findActiveTabHref } from '@/utils/layoutTabs'; import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; import { formatRelativeTime } from '@/utils/time'; import SessionPage from './index'; +import type { SessionContextFile, SessionContextSnapshot } from '@/api/session'; +import type { SessionContextPanelProps } from './SessionContextPanel'; +import type { SSEChatEvent } from '@/features/session-chat/sseRouting'; + +const sessionChatSSERef = vi.hoisted(() => ({ current: undefined as ((event: SSEChatEvent) => void) | undefined })); +const contextPanelPropsRef = vi.hoisted(() => ({ current: null as SessionContextPanelProps | null })); +vi.mock('./SessionContextPanel', async (importOriginal) => { + const actual = await importOriginal(); + return { + default: (props: SessionContextPanelProps) => { + contextPanelPropsRef.current = props; + return ; + }, + }; +}); +vi.mock('@/components/common/FilePreview', () => ({ + FilePreviewRenderer: ({ node, content }: any) =>
{node.name}:{content}
, + PreviewModal: () => null, +})); + +const initialActionProbe = vi.hoisted(() => ({ enabled: false, delivered: vi.fn() })); const sessionStatusSSEOptionsRef = vi.hoisted(() => ({ current: null as null | { @@ -42,8 +65,19 @@ const { archive: vi.fn(), delete: vi.fn(), get: vi.fn(), + getContext: vi.fn(), + getContextFile: vi.fn(), getMessages: vi.fn(), moveToProject: vi.fn(), + readContextFile: vi.fn(), + contextFilePreviewUrl: vi.fn((sessionId: string, resourceId: string) => `/api/session/${sessionId}/context/files/${resourceId}/preview`), + contextFileDownloadUrl: vi.fn((sessionId: string, resourceId: string) => `/api/session/${sessionId}/context/files/${resourceId}/download`), + listContextRoot: vi.fn(), + readContextRootFile: vi.fn(), + contextRootPreviewUrl: vi.fn(), + contextRootDownloadUrl: vi.fn(), + addContextFolder: vi.fn(), + removeContextFolder: vi.fn(), update: vi.fn(), }, updateSessionTitle: vi.fn(), @@ -154,11 +188,14 @@ vi.mock('@/components/common/SessionChat', () => ({ centerToolbarSlot, welcomeContent, initialMessage, + onInitialMessageConsumed, initialDisplayText, initialOptimisticMessage, focusMessageId, onCreateAndSend, onSSEEvent, + onOpenContextFile, + onSseStatusChange, agentName, model, executionMode, @@ -168,6 +205,7 @@ vi.mock('@/components/common/SessionChat', () => ({ display, hideInput, }: { + onInitialMessageConsumed?: () => void; sessionId?: string | null; agentName?: string; mentionAgents?: Array<{ name: string }>; @@ -215,8 +253,18 @@ vi.mock('@/components/common/SessionChat', () => ({ executionModeOverride?: 'build' | 'plan' | 'goal', ) => Promise | unknown; onSSEEvent?: (event: { type: string; properties?: Record }) => void; + onOpenContextFile?: (resourceID: string) => void; + onSseStatusChange?: (status: 'connected' | 'disconnected') => void; }) { + sessionChatSSERef.current = onSSEEvent; const [input, setInput] = React.useState(''); + const delivered = React.useRef(false); + React.useEffect(() => { + if (!initialActionProbe.enabled || !initialMessage || delivered.current) return; + delivered.current = true; + initialActionProbe.delivered(sessionId, initialMessage); + onInitialMessageConsumed?.(); + }, [sessionId, initialMessage, onInitialMessageConsumed]); return (
({ data-focus-message={focusMessageId ?? ''} > {sessionId ?? 'no-session'} + + + + + @@ -377,12 +430,67 @@ const modelDefinitions = [ }, ]; +function SessionRoutes() { + return + } /> + } /> + ; +} + +function NavigationProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return <> + {location.pathname}{location.search} + + + + + + ; +} + +// The layout keeps one pane per tab alive: leaving the sessions tab hides +// the page instead of unmounting it. These routes mirror contentRoutes. +const KEEP_ALIVE_TAB_HREFS = ['/sessions', '/agents']; +const keepAliveRoutes = [ + { path: 'sessions/:sessionId?', element: }, + { path: 'agents', element:
}, + { path: '*', element:
}, +]; + +function KeepAliveTabs() { + const location = useLocation(); + return ( + ({ href }))} + activeHref={findActiveTabHref(KEEP_ALIVE_TAB_HREFS, location.pathname)} + location={location} + routes={keepAliveRoutes} + /> + ); +} + +function renderSessionPageInKeepAliveTabs(initialEntry = '/sessions') { + return render( + + + + , + ); +} + +function sessionsPaneState() { + return screen.getByTestId('session-chat').closest('[data-keep-alive-pane]')?.getAttribute('data-keep-alive-pane'); +} + function renderSessionPage( initialEntry: string | { pathname: string; state?: unknown } = '/sessions', ) { return render( - + + , ); } @@ -397,11 +505,55 @@ function deferred() { return { promise, resolve, reject }; } +function contextFile(resourceID: string, overrides: Partial = {}): SessionContextFile { + return { + resourceID, fileKey: resourceID, displayName: `${resourceID}.md`, logicalPath: `Outputs/${resourceID}.md`, + mimeType: 'text/markdown', status: 'ready', previewStatus: 'text', canPreview: true, isTextFile: true, + origin: 'agent_output', section: 'outputs', sourceMessageID: `msg-${resourceID}`, ...overrides, + }; +} + +function contextPage(overrides: Partial = {}): SessionContextSnapshot { + return { + sessionID: session.id, canManageFolders: true, hasMore: false, nextBefore: null, + outputs: [], contextFiles: [], progress: [], roots: [], skills: [], + counts: { total: 0, outputs: 0, contextFiles: 0, roots: 0, progress: 0 }, ...overrides, + }; +} + +function skillEvent( + tool: 'skill_load' | 'load_skill', + status: string, + { + sessionID = session.id, id = 'skill-part', messageID = 'skill-message', + input = { name: 'docx' }, error, output = '', + }: { + sessionID?: string; id?: string; messageID?: string; + input?: Record; error?: string; output?: string; + } = {}, +): SSEChatEvent { + return { + type: 'message.part.updated', + properties: { part: { id, sessionID, messageID, type: 'tool', tool, state: { status, input, error, output } } }, + }; +} + +function emitChatEvent(event: SSEChatEvent) { + act(() => sessionChatSSERef.current!(event)); +} + +async function advanceContextDebounce(ms = 250) { + await act(async () => { vi.advanceTimersByTime(ms); }); +} + describe('SessionPage session actions menu', () => { beforeEach(() => { vi.clearAllMocks(); + initialActionProbe.enabled = false; __resetChatModelResourcesForTesting(); sessionStatusSSEOptionsRef.current = null; + sessionChatSSERef.current = undefined; + contextPanelPropsRef.current = null; localStorage.clear(); sessionStorage.clear(); @@ -459,6 +611,11 @@ describe('SessionPage session actions menu', () => { client.patch.mockResolvedValue({ data: { id: 'prj_project2', worktree: '/tmp/labs', name: 'Renamed Project' } }); client.post.mockResolvedValue({ data: secondSession }); sessionApi.get.mockResolvedValue(session); + sessionApi.getContext.mockReset().mockResolvedValue(contextPage()); + sessionApi.getContextFile.mockReset().mockResolvedValue(contextFile('requested-file')); + sessionApi.readContextFile.mockReset().mockResolvedValue({ content: 'Requested content' }); + sessionApi.addContextFolder.mockReset().mockResolvedValue({}); + sessionApi.removeContextFolder.mockReset().mockResolvedValue({}); sessionApi.getMessages.mockResolvedValue([ { info: { @@ -476,6 +633,291 @@ describe('SessionPage session actions menu', () => { vi.stubGlobal('confirm', vi.fn(() => true)); }); + it('consumes a legacy action once across StrictMode, back and remount', async () => { + initialActionProbe.enabled = true; + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + const user = userEvent.setup(); + const view = render( + + ); + await waitFor(() => expect(initialActionProbe.delivered).toHaveBeenCalledTimes(1)); + expect(initialActionProbe.delivered).toHaveBeenCalledWith('session-1', 'once'); + await user.click(screen.getByText('open-second')); + await user.click(screen.getByText('history-back')); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + expect(initialActionProbe.delivered).toHaveBeenCalledTimes(1); + view.unmount(); + renderSessionPage('/sessions/session-1'); + expect(initialActionProbe.delivered).toHaveBeenCalledTimes(1); + }); + + it('keeps a new session empty after restoring an earlier session', async () => { + localStorage.setItem('flocks:last-selected-session', 'session-1'); + sessionStorage.setItem('flocks:sessions:visited', 'true'); + const user = userEvent.setup(); + renderSessionPage(); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-1'); + await user.click(screen.getByRole('button', { name: 'newSession' })); + expect(screen.getByTestId('session-location').textContent).toBe('/sessions'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); + await user.click(screen.getByText('history-back')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + await user.click(screen.getByText('history-forward')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); + }); + + it('does not navigate away when an earlier create-and-send request finishes', async () => { + const request = deferred<{ data: typeof secondSession }>(); + client.post.mockImplementation((url: string) => url === '/api/session' ? request.promise : Promise.resolve({ data: {} })); + const user = userEvent.setup(); + renderSessionPage(); + await user.click(screen.getByText('mock-create-and-send')); + await user.click(screen.getByText('Original Session')); + await act(async () => { request.resolve({ data: secondSession }); await request.promise; }); + await waitFor(() => expect(client.post).toHaveBeenCalledWith('/api/session/session-2/prompt_async', expect.anything())); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-1'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + }); + + describe.each(['create', 'create-and-send'] as const)('%s completion under StrictMode', (action) => { + it.each(['stay', 'leave', 'return'] as const)('respects navigation when users %s', async (destination) => { + const request = deferred<{ data: typeof secondSession }>(); + client.post.mockImplementation((url: string) => url === '/api/session' ? request.promise : Promise.resolve({ data: {} })); + const user = userEvent.setup(); + render( + + ); + await user.click(action === 'create' + ? screen.getByRole('button', { name: 'createTaskSession' }) + : screen.getByText('mock-create-and-send')); + expect(client.post).toHaveBeenCalledWith('/api/session', expect.anything()); + + if (destination !== 'stay') { + await user.click(screen.getByText('leave-session-page')); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + if (destination === 'return') { + await user.click(screen.getByText('return-to-first')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + } + } + const savedSelection = localStorage.getItem('flocks:last-selected-session'); + await act(async () => { request.resolve({ data: secondSession }); await request.promise; }); + await waitFor(() => expect(addSession).toHaveBeenCalledWith(secondSession)); + if (action === 'create-and-send') { + expect(client.post).toHaveBeenCalledWith('/api/session/session-2/prompt_async', expect.anything()); + } + expect(screen.getByTestId('session-location').textContent).toBe( + destination === 'stay' ? '/sessions/session-2' + : destination === 'leave' ? '/agents' : '/sessions/session-1', + ); + if (destination !== 'stay') { + expect(localStorage.getItem('flocks:last-selected-session')).toBe(savedSelection); + } + }); + }); + + describe.each(['archive', 'batch-archive'] as const)('%s completion under StrictMode', (action) => { + it.each(['stay', 'leave', 'return'] as const)('respects navigation when users %s', async (destination) => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + const request = deferred<{ id: string; status: string }>(); + sessionApi.archive.mockReturnValue(request.promise); + const user = userEvent.setup(); + render( + + ); + if (action === 'archive') { + await user.click(screen.getAllByRole('button', { name: 'moreActions' })[0]); + await user.click(screen.getByRole('button', { name: 'archiveAction' })); + } else { + await user.click(screen.getByRole('button', { name: 'selectMode' })); + await user.click(screen.getAllByText('Original Session')[0]); + await user.click(screen.getByRole('button', { name: 'archiveSelected' })); + } + expect(sessionApi.archive).toHaveBeenCalledWith(session.id); + if (destination !== 'stay') { + await user.click(screen.getByText('leave-session-page')); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + if (destination === 'return') { + await user.click(screen.getByText('open-second')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); + } + } + const savedSelection = localStorage.getItem('flocks:last-selected-session'); + await act(async () => { request.resolve({ id: session.id, status: 'archived' }); await request.promise; }); + expect(action === 'archive' ? removeSession : removeSessions).toHaveBeenCalledWith( + action === 'archive' ? session.id : [session.id], + ); + expect(screen.getByTestId('session-location').textContent).toBe( + destination === 'stay' ? '/sessions' + : destination === 'leave' ? '/agents' : '/sessions/session-2', + ); + if (destination !== 'stay') { + expect(localStorage.getItem('flocks:last-selected-session')).toBe(savedSelection); + } + }); + }); + + describe('inside a hidden keep-alive pane', () => { + it('does not navigate when a create-and-send request finishes after the user switched tabs', async () => { + const request = deferred<{ data: typeof secondSession }>(); + client.post.mockImplementation((url: string) => url === '/api/session' ? request.promise : Promise.resolve({ data: {} })); + const user = userEvent.setup(); + renderSessionPageInKeepAliveTabs(); + await user.click(screen.getByText('mock-create-and-send')); + expect(client.post).toHaveBeenCalledWith('/api/session', expect.anything()); + + await user.click(screen.getByText('leave-session-page')); + expect(screen.getByTestId('agents-page')).toBeInTheDocument(); + // The page is still mounted, only hidden. + expect(sessionsPaneState()).toBe('inactive'); + + await act(async () => { request.resolve({ data: secondSession }); await request.promise; }); + await waitFor(() => expect(addSession).toHaveBeenCalledWith(secondSession)); + expect(client.post).toHaveBeenCalledWith('/api/session/session-2/prompt_async', expect.anything()); + expect(screen.getByTestId('session-location').textContent).toBe('/agents'); + + // Coming back lands on the tab as it was left, not on the new session. + await user.click(screen.getByText('history-back')); + expect(sessionsPaneState()).toBe('active'); + expect(screen.getByTestId('session-location').textContent).toBe('/sessions'); + }); + + it('does not replace the current history entry when an archive finishes after the user switched tabs', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + const request = deferred<{ id: string; status: string }>(); + sessionApi.archive.mockReturnValue(request.promise); + const user = userEvent.setup(); + renderSessionPageInKeepAliveTabs('/sessions/session-1'); + await user.click(screen.getAllByRole('button', { name: 'moreActions' })[0]); + await user.click(screen.getByRole('button', { name: 'archiveAction' })); + expect(sessionApi.archive).toHaveBeenCalledWith(session.id); + + await user.click(screen.getByText('leave-session-page')); + expect(sessionsPaneState()).toBe('inactive'); + + await act(async () => { request.resolve({ id: session.id, status: 'archived' }); await request.promise; }); + expect(removeSession).toHaveBeenCalledWith(session.id); + expect(screen.getByTestId('session-location').textContent).toBe('/agents'); + + // `/agents` was not replaced by `/sessions`: back returns to where the tab was. + await user.click(screen.getByText('history-back')); + expect(screen.getByTestId('session-location').textContent).toBe('/sessions/session-1'); + }); + + it('defers the last-session restore until the tab is back on screen', async () => { + localStorage.setItem('flocks:last-selected-session', 'session-1'); + sessionStorage.setItem('flocks:sessions:visited', 'true'); + useSessions.mockReturnValue({ ...useSessions(), loading: true }); + const user = userEvent.setup(); + const view = renderSessionPageInKeepAliveTabs(); + expect(screen.getByTestId('session-location').textContent).toBe('/sessions'); + + await user.click(screen.getByText('leave-session-page')); + expect(sessionsPaneState()).toBe('inactive'); + + // The session list arrives while the tab is hidden. + useSessions.mockReturnValue({ ...useSessions(), loading: false }); + view.rerender( + + + + , + ); + expect(screen.getByTestId('session-location').textContent).toBe('/agents'); + + await user.click(screen.getByText('history-back')); + await waitFor(() => expect(screen.getByTestId('session-location').textContent).toBe('/sessions/session-1')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + }); + }); + + it('does not leave the current session when another session finishes archiving', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + const request = deferred<{ id: string; status: string }>(); + sessionApi.archive.mockReturnValue(request.promise); + const user = userEvent.setup(); + renderSessionPage('/sessions/session-1'); + await user.click(screen.getAllByRole('button', { name: 'moreActions' })[0]); + await user.click(screen.getByRole('button', { name: 'archiveAction' })); + await user.click(screen.getByText('open-second')); + await act(async () => { request.resolve({ id: session.id, status: 'archived' }); await request.promise; }); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-2'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); + }); + + it('keeps a canonical URL through selection, back and forward without sending', async () => { + const user = userEvent.setup(); + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + renderSessionPage('/sessions/session-1'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + await user.click(screen.getByText('Second Session')); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-2'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); + await user.click(screen.getByText('history-back')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + await user.click(screen.getByText('history-forward')); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('normalizes legacy links and retains only navigation parameters', async () => { + renderSessionPage('/sessions?session=session-1&focusMessage=msg-1&message=hello&display=label'); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-1?focusMessage=msg-1'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', 'hello'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-focus-message', 'msg-1'); + }); + + it('never sends a conflicting query action to the path session', async () => { + renderSessionPage('/sessions/session-1?session=session-2&message=wrong'); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + expect(toast.error).toHaveBeenCalledWith('linkTargetMismatch'); + }); + + it('loads direct links independently of a slow sidebar and discards stale responses', async () => { + const request = deferred(); + useSessions.mockReturnValue({ ...useSessions(), sessions: [], loading: true }); + sessionApi.get.mockImplementation((id: string) => id === session.id ? request.promise : Promise.resolve(secondSession)); + const user = userEvent.setup(); + renderSessionPage('/sessions/session-1'); + expect(sessionApi.get).toHaveBeenCalledWith('session-1'); + await user.click(screen.getByText('open-second')); + await waitFor(() => expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2')); + await act(async () => { request.resolve(session); await request.promise; }); + expect(screen.getByTestId('session-chat')).toHaveTextContent('session-2'); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-2'); + }); + + it('revalidates a list-external session after navigating away and back', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [secondSession] }); + sessionApi.get.mockResolvedValueOnce(session).mockRejectedValueOnce({ response: { status: 404 } }); + const user = userEvent.setup(); + renderSessionPage('/sessions/session-1'); + await waitFor(() => expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1')); + await user.click(screen.getByText('open-second')); + await user.click(screen.getByText('history-back')); + expect(await screen.findByRole('alert')).toHaveTextContent('sessionAccess.unavailable'); + expect(sessionApi.get).toHaveBeenCalledTimes(2); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + }); + + it('shows a retryable network error instead of a blank composer', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [] }); + sessionApi.get.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(session); + const user = userEvent.setup(); + renderSessionPage('/sessions/session-1'); + expect(await screen.findByRole('alert')).toHaveTextContent('sessionAccess.failed'); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + await user.click(screen.getByText('sessionAccess.retry')); + await waitFor(() => expect(screen.getByTestId('session-chat')).toHaveTextContent('session-1')); + }); + + it('does not auto-send a legacy action to a read-only session', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [{ ...session, canWrite: false }] }); + renderSessionPage('/sessions?session=session-1&message=do-not-send'); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + }); + it('renders Build by default and keeps Agent in the Add menu', async () => { renderSessionPage(); @@ -489,6 +931,804 @@ describe('SessionPage session actions menu', () => { expect(agentIconContainer?.className).not.toContain('shadow-'); }); + it('opens the Session Context panel from the header', async () => { + const user = userEvent.setup(); + renderSessionPage('/sessions?session=session-1'); + + const button = await screen.findByRole('button', { name: 'context.title' }); + expect(sessionApi.getContext).not.toHaveBeenCalled(); + await user.click(button); + + expect(sessionApi.getContext).toHaveBeenCalledWith('session-1', {}, expect.any(AbortSignal)); + expect(screen.getAllByText('context.outputs').length).toBeGreaterThan(0); + expect(screen.getAllByText('context.contextFiles').length).toBeGreaterThan(0); + }); + + it('opens requested metadata immediately when a closed panel has no snapshot yet', async () => { + const head = deferred(); + sessionApi.getContext.mockReturnValue(head.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'mock-open-context-file' })); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('requested-file.md:Requested content'); + expect(sessionApi.getContextFile).toHaveBeenCalledWith('session-1', 'requested-file', expect.any(AbortSignal)); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + await act(async () => head.resolve(contextPage())); + expect(screen.getByTestId('file-preview')).toHaveTextContent('requested-file.md:Requested content'); + }); + + it.each([ + ['text/markdown', false], ['application/pdf', false], + ['text/markdown', true], ['application/pdf', true], + ] as const)('keeps a closed-panel card request alive through StrictMode mount replay (%s, cached=%s)', async (mimeType, cached) => { + const head = deferred(); + const metadata = deferred(); + sessionApi.getContext.mockReturnValue(head.promise); + if (cached) sessionApi.getContext.mockResolvedValueOnce(contextPage()); + sessionApi.getContextFile.mockImplementation((_sessionId: string, _id: string, signal: AbortSignal) => ( + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('Canceled')), { once: true }); + metadata.promise.then(resolve, reject); + }) + )); + render( + + + + + , + ); + if (cached) { + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + } + fireEvent.click(await screen.findByRole('button', { name: 'mock-open-context-file' })); + await waitFor(() => expect(sessionApi.getContextFile).toHaveBeenCalled()); + await act(async () => metadata.resolve(contextFile('requested-file', { + displayName: 'earlier-file', mimeType, isTextFile: mimeType.startsWith('text/'), + }))); + expect(await screen.findByTestId('file-preview')).toHaveTextContent('earlier-file'); + await act(async () => head.resolve(contextPage())); + expect(screen.getByTestId('file-preview')).toHaveTextContent('earlier-file'); + expect(contextPanelPropsRef.current?.requestedResourceID).toBeNull(); + const signals = sessionApi.getContextFile.mock.calls.map((call) => call[2] as AbortSignal); + expect(signals.some((signal) => !signal.aborted)).toBe(true); + }); + + it('coalesces refresh, SSE, and reconnect bursts into one single-flight trailing read', async () => { + const first = deferred(); + const trailing = deferred(); + sessionApi.getContext.mockReturnValueOnce(first.promise).mockReturnValueOnce(trailing.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + const refresh = screen.getByTitle('context.refresh'); + fireEvent.click(screen.getByRole('button', { name: 'mock-connected' })); + for (let i = 0; i < 5; i += 1) { + fireEvent.click(refresh); + fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + } + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + expect((sessionApi.getContext.mock.calls[0][2] as AbortSignal).aborted).toBe(false); + await act(async () => first.resolve(contextPage({ outputs: [contextFile('initial')] }))); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + expect(contextPanelPropsRef.current?.loading).toBe(true); + expect(screen.getByText('initial.md')).toBeInTheDocument(); + await act(async () => trailing.resolve(contextPage({ outputs: [contextFile('latest')] }))); + expect(screen.getByText('latest.md')).toBeInTheDocument(); + expect(contextPanelPropsRef.current?.loading).toBe(false); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + }); + + it('debounces idle SSE bursts and ignores events for other sessions', async () => { + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('button', { name: 'mock-other-context-updated' })); + await act(async () => { vi.advanceTimersByTime(300); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + for (let i = 0; i < 5; i += 1) fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + await act(async () => { vi.advanceTimersByTime(249); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + await act(async () => { vi.advanceTimersByTime(1); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it.each(['skill_load', 'load_skill'] as const)('refreshes skill-only %s lifecycle events and clears retry errors', async (tool) => { + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + vi.useFakeTimers(); + try { + let requests = 1; + for (const status of ['pending', 'running', 'error', 'running', 'completed'] as const) { + const error = status === 'error' ? 'Skill dependency missing' : undefined; + const skill = { + name: 'docx', description: null, + status: status === 'completed' ? 'loaded' as const : status === 'error' ? 'error' as const : 'loading' as const, + ...(error ? { error } : {}), + }; + sessionApi.getContext.mockResolvedValueOnce(contextPage({ skills: [skill] })); + emitChatEvent(skillEvent(tool, status, { input: tool === 'skill_load' ? { name: 'docx' } : { skill: 'docx' }, error })); + await advanceContextDebounce(249); + expect(sessionApi.getContext).toHaveBeenCalledTimes(requests); + await advanceContextDebounce(1); + requests += 1; + expect(sessionApi.getContext).toHaveBeenCalledTimes(requests); + expect(sessionApi.getContext).toHaveBeenLastCalledWith('session-1', {}, expect.any(AbortSignal)); + expect(contextPanelPropsRef.current?.snapshot?.skills).toEqual([skill]); + } + // No write/todo/context-updated or streaming-done event is needed above. + for (let i = 0; i < 5; i += 1) emitChatEvent(skillEvent(tool, 'completed', { output: `chunk ${i}` })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(requests); + expect(contextPanelPropsRef.current?.snapshot?.skills[0]).not.toHaveProperty('error'); + } finally { + vi.useRealTimers(); + } + }); + + it('deduplicates skill descriptors by part, including newly available names and changed errors', async () => { + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + vi.useFakeTimers(); + try { + emitChatEvent(skillEvent('skill_load', 'unknown')); + const unrelated = skillEvent('skill_load', 'running'); + unrelated.properties!.part.tool = 'read'; + emitChatEvent(unrelated); + emitChatEvent({ ...skillEvent('skill_load', 'running'), type: 'message.updated' }); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + + emitChatEvent(skillEvent('skill_load', 'running', { input: {} })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + emitChatEvent(skillEvent('skill_load', 'running', { input: {}, output: 'partial' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + emitChatEvent(skillEvent('skill_load', 'running', { input: { skill: 'docx' } })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + emitChatEvent(skillEvent('skill_load', 'running', { input: { name: 'docx' }, output: 'more' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + + emitChatEvent(skillEvent('skill_load', 'error', { error: 'First reason' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(4); + emitChatEvent(skillEvent('skill_load', 'error', { error: 'First reason', output: 'ignored output' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(4); + emitChatEvent(skillEvent('skill_load', 'error', { error: 'Full reason' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(5); + emitChatEvent(skillEvent('skill_load', 'error', { error: 'Full reason', id: 'another-part' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(6); + emitChatEvent(skillEvent('skill_load', 'error', { error: 'Full reason', messageID: 'another-message' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(7); + } finally { + vi.useRealTimers(); + } + }); + + it('coalesces skill bursts into one debounced request and one dirty trailing read', async () => { + const refresh = deferred(); + const trailing = deferred(); + sessionApi.getContext.mockResolvedValueOnce(contextPage()).mockReturnValueOnce(refresh.promise).mockReturnValueOnce(trailing.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + vi.useFakeTimers(); + try { + emitChatEvent(skillEvent('skill_load', 'pending')); + emitChatEvent(skillEvent('skill_load', 'running')); + emitChatEvent(skillEvent('load_skill', 'pending', { id: 'second-part' })); + await advanceContextDebounce(249); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + await advanceContextDebounce(1); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + for (const status of ['error', 'running', 'completed']) { + emitChatEvent(skillEvent('skill_load', status)); + } + emitChatEvent(skillEvent('load_skill', 'completed', { id: 'second-part' })); + await advanceContextDebounce(500); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + await act(async () => refresh.resolve(contextPage({ skills: [{ name: 'docx', status: 'loading' }] }))); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + expect(contextPanelPropsRef.current?.loading).toBe(true); + // Repeated output-only updates during the trailing flight must not dirty it again. + for (let i = 0; i < 5; i += 1) emitChatEvent(skillEvent('skill_load', 'completed', { output: `chunk ${i}` })); + await act(async () => trailing.resolve(contextPage({ skills: [{ name: 'docx', status: 'loaded' }] }))); + await advanceContextDebounce(500); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + expect(contextPanelPropsRef.current?.loading).toBe(false); + expect(contextPanelPropsRef.current?.snapshot?.skills).toEqual([{ name: 'docx', status: 'loaded' }]); + } finally { + vi.useRealTimers(); + } + }); + + it('resets skill deduplication on close/reopen and A -> B -> A without caching foreign or stale events', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + sessionApi.getContext.mockImplementation((sessionId: string) => Promise.resolve(contextPage({ sessionID: sessionId }))); + renderSessionPage('/sessions?session=session-1'); + await screen.findByRole('button', { name: 'context.title' }); + const closedHandler = sessionChatSSERef.current!; + emitChatEvent(skillEvent('skill_load', 'running')); + expect(sessionApi.getContext).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + const firstAHandler = sessionChatSSERef.current!; + vi.useFakeTimers(); + try { + emitChatEvent(skillEvent('skill_load', 'running', { sessionID: 'session-2' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + emitChatEvent(skillEvent('skill_load', 'running')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + emitChatEvent(skillEvent('skill_load', 'running')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByRole('button', { name: 'context.title' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + act(() => { closedHandler(skillEvent('skill_load', 'running')); firstAHandler(skillEvent('skill_load', 'running')); }); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + emitChatEvent(skillEvent('skill_load', 'running')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(4); + + // A scheduled descriptor change is discarded on switch, not carried into B. + emitChatEvent(skillEvent('skill_load', 'completed')); + fireEvent.click(screen.getByText('Second Session')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(5); + emitChatEvent(skillEvent('skill_load', 'running')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(5); + emitChatEvent(skillEvent('skill_load', 'running', { sessionID: 'session-2' })); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(6); + fireEvent.click(screen.getByText('Original Session')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(7); + act(() => firstAHandler(skillEvent('skill_load', 'running'))); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(7); + emitChatEvent(skillEvent('skill_load', 'running')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(8); + emitChatEvent(skillEvent('skill_load', 'completed')); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + await advanceContextDebounce(); + expect(sessionApi.getContext.mock.calls.map((call) => call[0])).toEqual([ + 'session-1', 'session-1', 'session-1', 'session-1', 'session-2', 'session-2', 'session-1', 'session-1', + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps newer failed skill attempts above older loaded pages and refreshes retries from skill SSE alone', async () => { + const older = deferred(); + const refresh = deferred(); + const failed = { name: 'docx', status: 'error' as const, error: 'Latest attempt failed' }; + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ hasMore: true, nextBefore: 'old-cursor', messageIDs: ['head'], skills: [failed] })) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValueOnce(contextPage({ skills: [{ name: 'docx', status: 'loaded' }] })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + emitChatEvent(skillEvent('load_skill', 'running')); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + await act(async () => older.resolve(contextPage({ skills: [ + { name: 'docx', status: 'loaded' }, { name: 'older-skill', status: 'loaded' }, + ] }))); + expect(contextPanelPropsRef.current?.snapshot?.skills).toEqual([failed, { name: 'older-skill', status: 'loaded' }]); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + expect(sessionApi.getContext.mock.calls[2][1]).toEqual({}); + await act(async () => refresh.resolve(contextPage({ skills: [{ name: 'docx', status: 'loading' }] }))); + expect(contextPanelPropsRef.current?.snapshot?.skills[0]).toEqual({ name: 'docx', status: 'loading' }); + vi.useFakeTimers(); + try { + emitChatEvent(skillEvent('load_skill', 'completed')); + await advanceContextDebounce(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(4); + expect(contextPanelPropsRef.current?.snapshot?.skills).toEqual([ + { name: 'docx', status: 'loaded' }, { name: 'older-skill', status: 'loaded' }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels scheduled SSE refreshes on session switch and close', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + sessionApi.getContext.mockImplementation((sessionId: string) => Promise.resolve(contextPage({ sessionID: sessionId }))); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + fireEvent.click(screen.getByText('Second Session')); + await act(async () => { vi.advanceTimersByTime(300); }); + expect(sessionApi.getContext.mock.calls.map((call) => call[0])).toEqual(['session-1', 'session-2']); + fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + await act(async () => { vi.advanceTimersByTime(300); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('retries a dirty failed flight once and renders validation errors as strings', async () => { + const first = deferred(); + const retry = deferred(); + sessionApi.getContext.mockReturnValueOnce(first.promise).mockReturnValueOnce(retry.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(screen.getByTitle('context.refresh')); + await act(async () => first.reject({ response: { data: { detail: [{ msg: 'Invalid cursor' }] } } })); + expect(screen.getByText('Invalid cursor')).toBeInTheDocument(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + await act(async () => retry.resolve(contextPage({ outputs: [contextFile('recovered')] }))); + expect(screen.getByText('recovered.md')).toBeInTheDocument(); + expect(screen.queryByText('Invalid cursor')).not.toBeInTheDocument(); + expect(contextPanelPropsRef.current?.loading).toBe(false); + }); + + it('isolates A -> B -> A requests and rejects stale refresh callbacks without invalidating B', async () => { + useSessions.mockReturnValue({ ...useSessions(), sessions: [session, secondSession] }); + const firstA = deferred(); + const b = deferred(); + const latestA = deferred(); + sessionApi.getContext.mockReturnValueOnce(firstA.promise).mockReturnValueOnce(b.promise).mockReturnValueOnce(latestA.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + const staleRefresh = contextPanelPropsRef.current!.onRefresh; + const firstSignal = sessionApi.getContext.mock.calls[0][2] as AbortSignal; + fireEvent.click(screen.getByText('Second Session')); + await waitFor(() => expect(sessionApi.getContext).toHaveBeenCalledTimes(2)); + const bSignal = sessionApi.getContext.mock.calls[1][2] as AbortSignal; + expect(firstSignal.aborted).toBe(true); + await act(async () => { await staleRefresh(); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + expect(bSignal.aborted).toBe(false); + await act(async () => b.resolve(contextPage({ sessionID: 'session-2', outputs: [contextFile('B')] }))); + expect(screen.getByText('B.md')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Original Session')); + await waitFor(() => expect(sessionApi.getContext).toHaveBeenCalledTimes(3)); + await act(async () => { await staleRefresh(); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + await act(async () => latestA.resolve(contextPage({ outputs: [contextFile('latest-A')] }))); + await act(async () => firstA.resolve(contextPage({ outputs: [contextFile('stale-A')] }))); + expect(screen.getByText('latest-A.md')).toBeInTheDocument(); + expect(screen.queryByText('stale-A.md')).not.toBeInTheDocument(); + expect(screen.queryByText('B.md')).not.toBeInTheDocument(); + expect(contextPanelPropsRef.current?.loading).toBe(false); + }); + + it('aborts a dirty request on close and ignores stale callbacks after reopening', async () => { + const old = deferred(); + const reopened = deferred(); + sessionApi.getContext.mockReturnValueOnce(old.promise).mockReturnValueOnce(reopened.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + const staleRefresh = contextPanelPropsRef.current!.onRefresh; + fireEvent.click(screen.getByTitle('context.refresh')); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + expect((sessionApi.getContext.mock.calls[0][2] as AbortSignal).aborted).toBe(true); + await act(async () => { await staleRefresh(); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'context.title' })); + await waitFor(() => expect(sessionApi.getContext).toHaveBeenCalledTimes(2)); + await act(async () => { await staleRefresh(); old.reject(new Error('Stale close error')); }); + expect((sessionApi.getContext.mock.calls[1][2] as AbortSignal).aborted).toBe(false); + expect(contextPanelPropsRef.current?.loading).toBe(true); + expect(screen.queryByText('Stale close error')).not.toBeInTheDocument(); + await act(async () => reopened.resolve(contextPage({ outputs: [contextFile('reopened')] }))); + expect(screen.getByText('reopened.md')).toBeInTheDocument(); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + }); + + it('cancels context requests on unmount and does not let retained callbacks restart them', async () => { + const pending = deferred(); + sessionApi.getContext.mockReturnValue(pending.promise); + const view = renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + const staleRefresh = contextPanelPropsRef.current!.onRefresh; + const signal = sessionApi.getContext.mock.calls[0][2] as AbortSignal; + view.unmount(); + expect(signal.aborted).toBe(true); + await act(async () => { await staleRefresh(); pending.resolve(contextPage()); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(1); + }); + + it('serializes older pages with refresh and retains history, stable identities, and latest state', async () => { + const older = deferred(); + const refresh = deferred(); + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + hasMore: true, nextBefore: 'cursor-1', messageIDs: ['msg-head'], + outputs: [contextFile('new', { fileKey: 'same-file' }), contextFile('legacy', { fileKey: '' })], + progress: [{ id: 'todo', content: 'Latest progress', status: 'in_progress' }], + skills: [{ name: 'docx', status: 'loaded', description: 'Latest skill' }], + })) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(refresh.promise) + .mockResolvedValueOnce(contextPage({ outputs: [contextFile('oldest')], hasMore: false, nextBefore: null })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + expect(sessionApi.getContext.mock.calls[1][1]).toEqual({ before: 'cursor-1' }); + fireEvent.click(screen.getByTitle('context.refresh')); + fireEvent.click(screen.getByRole('button', { name: 'mock-context-updated' })); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + await act(async () => older.resolve(contextPage({ + hasMore: true, nextBefore: 'cursor-2', + outputs: [contextFile('historic'), contextFile('legacy', { fileKey: '', displayName: 'stale-legacy.md' })], + contextFiles: [contextFile('stale', { fileKey: 'same-file', section: 'context' })], + progress: [{ id: 'todo', content: 'Old progress', status: 'pending' }], + skills: [{ name: 'docx', status: 'loading', description: 'Old skill' }, { name: 'older-skill', status: 'loaded' }], + }))); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + expect(sessionApi.getContext.mock.calls[2][1]).toEqual({}); + expect(screen.getByText('new.md')).toBeInTheDocument(); + expect(screen.getByText('historic.md')).toBeInTheDocument(); + expect(screen.queryByText('stale.md')).not.toBeInTheDocument(); + expect(screen.queryByText('stale-legacy.md')).not.toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.progress[0].content).toBe('Latest progress'); + expect(contextPanelPropsRef.current?.snapshot?.skills.find((skill) => skill.name === 'docx')?.description).toBe('Latest skill'); + await act(async () => refresh.resolve(contextPage({ + hasMore: true, nextBefore: 'new-head-cursor', messageIDs: ['msg-head', 'msg-fresh'], + outputs: [contextFile('updated', { fileKey: 'same-file' }), contextFile('fresh')], + progress: [{ id: 'todo', content: 'Refreshed progress', status: 'completed' }], + skills: [{ name: 'docx', status: 'loaded', description: 'Refreshed skill' }], + }))); + expect(screen.getByText('updated.md')).toBeInTheDocument(); + expect(screen.getByText('historic.md')).toBeInTheDocument(); + expect(screen.getByText('legacy.md')).toBeInTheDocument(); + expect(screen.queryByText('new.md')).not.toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.nextBefore).toBe('cursor-2'); + expect(contextPanelPropsRef.current?.snapshot?.skills).toHaveLength(2); + expect(contextPanelPropsRef.current?.snapshot?.progress[0].content).toBe('Refreshed progress'); + fireEvent.click(screen.getByRole('button', { name: 'context.loadEarlier' })); + await screen.findByText('oldest.md'); + expect(sessionApi.getContext.mock.calls[3][1]).toEqual({ before: 'cursor-2' }); + expect(screen.getByText('historic.md')).toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.hasMore).toBe(false); + expect(screen.queryByRole('button', { name: 'context.loadEarlier' })).not.toBeInTheDocument(); + }); + + it('reconciles deleted and replaced files in a refreshed message while preserving older history', async () => { + const renamed = contextFile('renamed', { fileKey: 'old-path', displayName: 'old-name.md', sourceMessageID: 'msg-head' }); + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-head'], hasMore: true, nextBefore: 'msg-head', + outputs: [contextFile('deleted-output', { sourceMessageID: 'msg-head' }), renamed], + contextFiles: [contextFile('deleted-upload', { section: 'context', origin: 'user_upload', sourceMessageID: 'msg-head' })], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-old'], outputs: [contextFile('historical', { sourceMessageID: 'msg-old' })], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-head'], hasMore: true, nextBefore: 'msg-head', + outputs: [{ ...renamed, fileKey: 'new-path', displayName: 'new-name.md' }], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + await screen.findByText('historical.md'); + fireEvent.click(screen.getByTitle('context.refresh')); + await screen.findByText('new-name.md'); + expect(screen.queryByText('deleted-output.md')).not.toBeInTheDocument(); + expect(screen.queryByText('deleted-upload.md')).not.toBeInTheDocument(); + expect(screen.queryByText('old-name.md')).not.toBeInTheDocument(); + expect(screen.getByText('historical.md')).toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.counts).toEqual({ + total: 2, outputs: 2, contextFiles: 0, roots: 0, progress: 0, + }); + }); + + it('reconciles a reloaded historical interval without dropping newer files', async () => { + const newer = contextFile('newer', { sourceMessageID: 'msg-new' }); + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ messageIDs: ['msg-new'], hasMore: true, nextBefore: 'msg-new', outputs: [newer] })) + .mockResolvedValueOnce(contextPage({ messageIDs: ['msg-old'], outputs: [contextFile('removed-old', { sourceMessageID: 'msg-old' })] })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-latest'], hasMore: true, nextBefore: 'msg-latest', + outputs: [contextFile('latest', { sourceMessageID: 'msg-latest' })], + })) + .mockResolvedValueOnce(contextPage({ messageIDs: ['msg-new', 'msg-old'], outputs: [newer] })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + await screen.findByText('removed-old.md'); + fireEvent.click(screen.getByTitle('context.refresh')); + await screen.findByText('latest.md'); + fireEvent.click(screen.getByRole('button', { name: 'context.loadEarlier' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + expect(screen.queryByText('removed-old.md')).not.toBeInTheDocument(); + expect(screen.getByText('newer.md')).toBeInTheDocument(); + expect(screen.getByText('latest.md')).toBeInTheDocument(); + }); + + it.each([false, true])('replaces cached files and pagination when a refreshed head covers all remaining messages (empty=%s)', async (empty) => { + const retained = contextFile('retained', { sourceMessageID: 'msg-retained' }); + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-deleted', 'msg-retained'], hasMore: true, nextBefore: 'msg-deleted', + outputs: [contextFile('deleted-message', { sourceMessageID: 'msg-deleted' }), retained], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: empty ? [] : ['msg-retained'], outputs: empty ? [] : [retained], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await screen.findByText('deleted-message.md'); + fireEvent.click(screen.getByTitle('context.refresh')); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + expect(contextPanelPropsRef.current?.snapshot?.outputs).toEqual(empty ? [] : [retained]); + expect(contextPanelPropsRef.current?.snapshot?.hasMore).toBe(false); + expect(contextPanelPropsRef.current?.snapshot?.nextBefore).toBeNull(); + expect(screen.queryByRole('button', { name: 'context.loadEarlier' })).not.toBeInTheDocument(); + }); + + it.each([0, 1, 3])('counts %s Todo items as at most one Progress resource', async (count) => { + sessionApi.getContext.mockResolvedValue(contextPage({ + outputs: [contextFile('output')], + contextFiles: [contextFile('upload', { section: 'context', origin: 'user_upload' })], + roots: [{ id: 'project', kind: 'project', displayName: 'Project', status: 'available' }], + progressKnown: true, + progress: Array.from({ length: count }, (_, index) => ({ id: `todo-${index}`, content: `Task ${index}`, status: 'pending' as const })), + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await screen.findByText('output.md'); + expect(contextPanelPropsRef.current?.snapshot?.counts).toEqual({ + total: 3 + Number(count > 0), outputs: 1, contextFiles: 1, roots: 1, progress: Number(count > 0), + }); + expect(contextPanelPropsRef.current?.snapshot?.progress).toHaveLength(count); + }); + + it.each([null, 'previous-cursor'])('reopens pagination across disjoint head pages after the previous boundary %s', async (previousCursor) => { + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-1'], hasMore: previousCursor !== null, nextBefore: previousCursor, + outputs: [contextFile('first')], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: Array.from({ length: 100 }, (_, index) => `msg-${index + 3}`), + hasMore: true, nextBefore: 'msg-3', outputs: [contextFile('latest')], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-1', 'msg-2'], outputs: [contextFile('gap'), contextFile('first')], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await screen.findByText('first.md'); + fireEvent.click(screen.getByRole('button', { name: 'context.close' })); + fireEvent.click(screen.getByRole('button', { name: 'context.title' })); + await screen.findByText('latest.md'); + expect(screen.getByText('first.md')).toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.nextBefore).toBe('msg-3'); + fireEvent.click(screen.getByRole('button', { name: 'context.loadEarlier' })); + await screen.findByText('gap.md'); + expect(sessionApi.getContext.mock.calls[2][1]).toEqual({ before: 'msg-3' }); + expect(contextPanelPropsRef.current?.snapshot?.outputs).toHaveLength(3); + expect(screen.queryByRole('button', { name: 'context.loadEarlier' })).not.toBeInTheDocument(); + }); + + it('replaces a retained old file with a newer revision loaded from a previously missed interval', async () => { + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-1'], outputs: [contextFile('old-revision', { fileKey: 'report', createdAt: 1 })], + })) + .mockResolvedValueOnce(contextPage({ messageIDs: ['msg-102'], hasMore: true, nextBefore: 'msg-3' })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-1', 'msg-2'], + outputs: [contextFile('new-revision', { fileKey: 'report', createdAt: 2 })], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await screen.findByText('old-revision.md'); + fireEvent.click(screen.getByTitle('context.refresh')); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + await screen.findByText('new-revision.md'); + expect(screen.queryByText('old-revision.md')).not.toBeInTheDocument(); + expect(contextPanelPropsRef.current?.snapshot?.outputs).toHaveLength(1); + }); + + it('honors an explicitly cleared Progress on refresh and does not revive it from older pages', async () => { + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-2'], hasMore: true, nextBefore: 'msg-2', progressKnown: true, + progress: [{ id: 'todo', content: 'Previous task', status: 'in_progress' }], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-2', 'msg-3'], hasMore: true, nextBefore: 'msg-2', progressKnown: true, progress: [], + })) + .mockResolvedValueOnce(contextPage({ + messageIDs: ['msg-1'], progressKnown: true, + progress: [{ id: 'todo', content: 'Previous task', status: 'pending' }], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + await waitFor(() => expect(contextPanelPropsRef.current?.snapshot?.progress).toHaveLength(1)); + fireEvent.click(screen.getByTitle('context.refresh')); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + expect(contextPanelPropsRef.current?.snapshot?.progress).toEqual([]); + expect(contextPanelPropsRef.current?.snapshot?.progressKnown).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'context.loadEarlier' })); + await waitFor(() => expect(contextPanelPropsRef.current?.loading).toBe(false)); + expect(contextPanelPropsRef.current?.snapshot?.progress).toEqual([]); + expect(sessionApi.getContext).toHaveBeenCalledTimes(3); + }); + + it('fills Progress from history only when the newer page has no known Todo state', async () => { + sessionApi.getContext + .mockResolvedValueOnce(contextPage({ hasMore: true, nextBefore: 'msg-2', progressKnown: false })) + .mockResolvedValueOnce(contextPage({ + progressKnown: true, progress: [{ id: 'todo', content: 'Historical task', status: 'pending' }], + })); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + await waitFor(() => expect(contextPanelPropsRef.current?.snapshot?.progress).toHaveLength(1)); + expect(contextPanelPropsRef.current?.snapshot?.progressKnown).toBe(true); + }); + + it('keeps an empty history page pageable and prevents duplicate load-more requests', async () => { + const older = deferred(); + sessionApi.getContext.mockResolvedValueOnce(contextPage({ hasMore: true, nextBefore: 'empty-cursor' })).mockReturnValueOnce(older.promise); + renderSessionPage('/sessions?session=session-1'); + fireEvent.click(await screen.findByRole('button', { name: 'context.title' })); + fireEvent.click(await screen.findByRole('button', { name: 'context.loadEarlier' })); + act(() => { void contextPanelPropsRef.current?.onLoadMore?.(); }); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + await act(async () => older.resolve(contextPage({ hasMore: true, nextBefore: 'next-empty-cursor' }))); + expect(screen.getByRole('button', { name: 'context.loadEarlier' })).toBeEnabled(); + expect(contextPanelPropsRef.current?.snapshot?.nextBefore).toBe('next-empty-cursor'); + expect(sessionApi.getContext).toHaveBeenCalledTimes(2); + }); + + it('shows full execution-mode titles in a portaled menu and preserves dismissal', async () => { + const user = userEvent.setup(); + renderSessionPage(); + + const trigger = await screen.findByRole('button', { name: 'executionMode.title' }); + await user.click(trigger); + const menu = screen.getByRole('menu', { name: 'executionMode.title' }); + const plan = within(menu).getByRole('menuitemradio', { name: /executionMode.options.plan.label/ }); + + expect(menu.parentElement).toBe(document.body); + expect(menu).toHaveAttribute('data-execution-mode-selector'); + expect(menu).toHaveStyle({ width: '520px' }); + await user.hover(plan); + expect(plan).toHaveAttribute('title', 'executionMode.options.plan.label\nexecutionMode.options.plan.description'); + expect(plan).toHaveAttribute('aria-checked', 'false'); + await user.click(plan); + expect(screen.queryByRole('menu', { name: 'executionMode.title' })).not.toBeInTheDocument(); + expect(screen.getByTestId('session-chat')).toHaveAttribute('data-execution-mode', 'plan'); + + await user.click(trigger); + expect(screen.getByRole('menuitemradio', { name: /executionMode.options.plan.label/ })).toHaveAttribute('aria-checked', 'true'); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu', { name: 'executionMode.title' })).not.toBeInTheDocument(); + await user.click(trigger); + await user.click(document.body); + expect(screen.queryByRole('menu', { name: 'executionMode.title' })).not.toBeInTheDocument(); + }); + + it('uses the same width for execution, model and security menus with full hover text', async () => { + const user = userEvent.setup(); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + client.get.mockImplementation((url: string) => Promise.resolve({ + data: url === '/api/flockspro/license/status' + ? { pro_enabled: true } + : [{ id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true, pathStatus: 'available', sessionCount: 1 }], + })); + renderSessionPage(); + + await user.click(await screen.findByRole('button', { name: 'executionMode.title' })); + const executionMenu = screen.getByRole('menu', { name: 'executionMode.title' }); + const menuWidth = executionMenu.style.width; + expect(menuWidth).toBe('520px'); + await user.click(document.body); + + await user.click(await screen.findByRole('button', { name: /GPT-4o/i })); + const modelMenu = screen.getByText('modelPicker.title').closest('[data-model-selector]') as HTMLElement; + expect(modelMenu.parentElement).toBe(document.body); + expect(modelMenu.style.width).toBe(menuWidth); + expect(within(modelMenu).getByText('OpenAI')).toHaveAttribute('title', 'OpenAI'); + expect(within(modelMenu).getByText('modelPicker.hint')).toHaveAttribute('title', 'modelPicker.hint'); + const modelOption = within(modelMenu).getByRole('button', { name: /MiniMax M3/i }); + await user.hover(modelOption); + expect(modelOption.getAttribute('title')).toContain('MiniMax M3\nMiniMax / minimax-m3'); + const info = modelOption.querySelector('.lucide-info')?.parentElement; + expect(info).not.toBeNull(); + await user.hover(info as HTMLElement); + expect(screen.getByRole('tooltip')).toHaveTextContent('MiniMax M3'); + expect(within(modelMenu).getByRole('button', { name: 'modelPicker.addModel' })).toBeInTheDocument(); + await user.click(document.body); + expect(screen.queryByText('modelPicker.title')).not.toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: 'permissionMode.requireConfirm' })); + const securityMenu = screen.getByText('permissionMode.runtimeTitle').closest('[data-permission-mode-selector]') as HTMLElement; + expect(securityMenu.parentElement).toBe(document.body); + expect(securityMenu.style.width).toBe(menuWidth); + for (const [label, description] of [ + ['permissionMode.runtimeExe', 'permissionMode.runtimeExeDesc'], + ['permissionMode.networkAutoDenyAll', 'permissionMode.networkAutoDenyAllDesc'], + ['permissionMode.autoAllowAll', 'permissionMode.autoAllowAllDesc'], + ]) { + const option = within(securityMenu).getByRole('button', { name: new RegExp(label) }); + await user.hover(option); + expect(option).toHaveAttribute('title', `${label}\n${description}`); + } + await user.click(document.body); + expect(screen.queryByText('permissionMode.runtimeTitle')).not.toBeInTheDocument(); + }); + + it.each([ + ['permissionMode.requireConfirm', 'permissionMode.viewDetails', '/settings/security-config'], + ['GPT-4o', 'modelPicker.addModel', '/models'], + ])('keeps navigation from the %s popup working', async (triggerName, actionName, destination) => { + const user = userEvent.setup(); + useProviders.mockReturnValue({ + providers: modelProviders, + connectedIds: ['openai', 'minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: 'openai', model_id: 'gpt-4o' } }); + modelV2API.listDefinitions.mockResolvedValue({ data: { models: modelDefinitions } }); + client.get.mockImplementation((url: string) => Promise.resolve({ + data: url === '/api/flockspro/license/status' + ? { pro_enabled: true } + : [{ id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true, pathStatus: 'available', sessionCount: 1 }], + })); + function LocationProbe() { + return {useLocation().pathname}; + } + render( + + + + , + ); + + await user.click(await screen.findByRole('button', { name: triggerName })); + await user.click(await screen.findByRole('button', { name: actionName })); + expect(screen.getByTestId('location')).toHaveTextContent(destination); + expect(screen.queryByRole('button', { name: actionName })).not.toBeInTheDocument(); + }); + it('persists Plan per session', async () => { const user = userEvent.setup(); renderSessionPage('/sessions?session=session-1'); @@ -2319,7 +3559,7 @@ describe('SessionPage session actions menu', () => { render( - + , ); @@ -2372,7 +3612,7 @@ describe('SessionPage session actions menu', () => { }); }); - it('clears the selected session after confirming it no longer exists', async () => { + it('keeps the target URL and shows an error when the session no longer exists', async () => { useSessions.mockReturnValue({ sessions: [], loading: false, @@ -2389,7 +3629,9 @@ describe('SessionPage session actions menu', () => { await waitFor(() => { expect(sessionApi.get).toHaveBeenCalledWith('session-deleted'); - expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); + expect(screen.getByRole('alert')).toHaveTextContent('sessionAccess.unavailable'); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-deleted'); }); }); @@ -2402,8 +3644,9 @@ describe('SessionPage session actions menu', () => { await waitFor(() => { expect(sessionApi.get).toHaveBeenCalledWith('session-deleted'); - expect(screen.getByTestId('session-chat')).toHaveTextContent('no-session'); - expect(screen.getByTestId('session-chat')).toHaveAttribute('data-initial-message', ''); + expect(screen.getByRole('alert')).toHaveTextContent('sessionAccess.unavailable'); + expect(screen.queryByTestId('session-chat')).not.toBeInTheDocument(); + expect(screen.getByTestId('session-location')).toHaveTextContent('/sessions/session-deleted'); }); await user.click(screen.getByText('Original Session')); @@ -3127,7 +4370,7 @@ describe('SessionPage session actions menu', () => { expect(screen.getByText('chat.addMenu.selectSkill')).toBeInTheDocument(); }); - it('updates permission and runtime independently with the current revision', async () => { + it('updates permission, runtime and network independently from portaled menus with the current revision', async () => { const user = userEvent.setup(); client.get.mockImplementation((url: string) => { if (url === '/api/flockspro/license/status') { @@ -3179,6 +4422,17 @@ describe('SessionPage session actions menu', () => { entry: 'webui', revision: 9, }, + }) + .mockResolvedValueOnce({ + data: { + permissionMode: 'readonly', + runtimeMode: 'exe-mode', + networkMode: 'auto-deny-all', + networkModeDefault: 'require-confirm', + networkModeOverridden: true, + entry: 'webui', + revision: 10, + }, }); renderSessionPage('/sessions?session=session-1'); @@ -3189,7 +4443,7 @@ describe('SessionPage session actions menu', () => { return element as HTMLElement; }); await user.click(within(selector).getByRole('button', { name: /permissionMode\.requireConfirm/ })); - await user.click(within(selector).getByRole('button', { name: /permissionMode\.readonly/ })); + await user.click(screen.getByRole('button', { name: /permissionMode\.readonlyDesc/ })); await waitFor(() => { expect(client.patch).toHaveBeenNthCalledWith( @@ -3200,7 +4454,7 @@ describe('SessionPage session actions menu', () => { }); await user.click(within(selector).getByRole('button', { name: /permissionMode\.readonly/ })); - await user.click(within(selector).getByRole('button', { name: /permissionMode\.runtimeExe/ })); + await user.click(screen.getByRole('button', { name: /permissionMode\.runtimeExeDesc/ })); await waitFor(() => { expect(client.patch).toHaveBeenNthCalledWith( @@ -3209,5 +4463,17 @@ describe('SessionPage session actions menu', () => { { runtimeMode: 'exe-mode', revision: 8 }, ); }); + + await user.click(within(selector).getByRole('button', { name: /permissionMode\.readonly/ })); + await user.click(screen.getByRole('button', { name: /permissionMode\.networkAutoDenyAllDesc/ })); + + await waitFor(() => { + expect(client.patch).toHaveBeenNthCalledWith( + 3, + '/api/flockspro/policy/sessions/session-1/execution-settings', + { networkMode: 'auto-deny-all', revision: 9 }, + ); + }); + expect(screen.queryByText('permissionMode.runtimeTitle')).not.toBeInTheDocument(); }); }); diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 3baa83a12..a64c14837 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; +import { memo, useContext, useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; import { Plus, Trash2, Archive, ChevronDown, ChevronLeft, ChevronRight, Sparkles, Shield, Search, AlertTriangle, @@ -9,12 +9,17 @@ import { Hammer, ClipboardList, Target, UserRound, UsersRound, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { getAnchoredMenuLeftOffset } from '@/components/common/ChatPromptSelectors'; +import { useLocation, useNavigate, useSearchParams, useParams } from 'react-router-dom'; +import SessionComposerMenu, { SessionComposerMenuHeader, SessionModeOption } from './SessionComposerMenu'; +import { sessionPath } from '@/utils/sessionUrl'; +import { PaneActiveContext } from '@/components/layout/PaneActiveContext'; +import CopyButton from '@/components/common/CopyButton'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import ChannelIcon from '@/components/common/ChannelIcon'; import { useToast } from '@/components/common/Toast'; import SessionChat, { buildInstructionDisplayText, type PromptDisplayOptions, type SSEChatEvent, type SSEConnectionStatus } from '@/components/common/SessionChat'; +import SessionContextPanel from './SessionContextPanel'; +import { getInitialSidePanelWidth, getMaxSidePanelWidth, SIDE_PANEL_MIN_WIDTH } from '@/components/common/sidePanelSizing'; import { useSSE } from '@/hooks/useSSE'; import SuiteInstallProgressPanel, { applySuiteInstallProgressEvent, @@ -22,7 +27,7 @@ import SuiteInstallProgressPanel, { failSuiteInstallProgress, type SuiteInstallProgressState, } from '@/components/hub/SuiteInstallProgressPanel'; -import { sessionApi } from '@/api/session'; +import { sessionApi, type SessionContextFile, type SessionContextSnapshot } from '@/api/session'; import { flocksproPolicyApi, isSessionExecutionSettingsUnsupported, @@ -44,13 +49,14 @@ import { } from '@/hooks/useChatModelResources'; import client, { getApiBase } from '@/api/client'; import { useDefaultModelVision } from '@/hooks/useDefaultModelVision'; -import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; +import { buildPromptParts, type FilePartData } from '@/utils/imageUpload'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; import { formatRelativeTime, formatSessionDate } from '@/utils/time'; import { getWorkflowDisplayName } from '@/utils/workflowDisplay'; import { formatPricingPerMillion, isPricingFree } from '@/utils/modelPricing'; import type { Message, ModelDefinitionV2, Session } from '@/types'; import { createMessageId } from '@/utils/messageId'; +import { extractErrorMessage } from '@/utils/error'; import { useAuth } from '@/contexts/AuthContext'; import { DEFAULT_SESSION_EXECUTION_MODE, @@ -655,6 +661,64 @@ function SessionChatSkeleton() { ); } +function mergeSessionContext( + current: SessionContextSnapshot | null, + incoming: SessionContextSnapshot, + older: boolean, +): SessionContextSnapshot { + const newest = older && current ? current : incoming; + const previous = older ? incoming : current; + const incomingIDs = new Set(incoming.messageIDs); + const incomingFiles = [...incoming.outputs, ...incoming.contextFiles]; + // A fetched page replaces descriptors for its messages, including deletions. + // A complete head is authoritative for the whole Session, even when empty. + const retainedFiles = current && (older || incoming.hasMore) + ? [...current.outputs, ...current.contextFiles].filter((file) => !incomingIDs.has(file.sourceMessageID)) + : []; + const filesByKey = new Map(); + for (const file of (older ? [...retainedFiles, ...incomingFiles] : [...incomingFiles, ...retainedFiles])) { + const key = file.fileKey || file.resourceID; + const existing = filesByKey.get(key); + // A page filling a gap can be newer than retained historical descriptors. + if (!existing || ( + typeof file.createdAt === 'number' && typeof existing.createdAt === 'number' + && file.createdAt > existing.createdAt + )) filesByKey.set(key, file); + } + const files = [...filesByKey.values()]; + const outputs = files.filter((file) => file.section === 'outputs'); + const contextFiles = files.filter((file) => file.section === 'context'); + const progressSource = (newest.progressKnown ?? newest.progress.length > 0) ? newest : previous; + const progress = progressSource?.progress ?? []; + const progressKnown = progressSource?.progressKnown ?? progress.length > 0; + const progressCount = Number(progress.length > 0); + const skills = [...new Map([ + ...(previous?.skills ?? []).map((skill) => [skill.name, skill] as const), + ...newest.skills.map((skill) => [skill.name, skill] as const), + ]).values()]; + // Retain the old boundary only for an overlapping partial head; otherwise + // use the fetched page's boundary to fill gaps or finish a complete refresh. + const overlapsHead = current?.messageIDs?.some((id) => incomingIDs.has(id)); + const pagination = older || !current || !incoming.hasMore || !overlapsHead ? incoming : current; + return { + ...newest, + outputs, + contextFiles, + progress, + progressKnown, + skills, + hasMore: pagination.hasMore, + nextBefore: pagination.nextBefore ?? null, + counts: { + total: outputs.length + contextFiles.length + newest.roots.length + progressCount, + outputs: outputs.length, + contextFiles: contextFiles.length, + roots: newest.roots.length, + progress: progressCount, + }, + }; +} + export default function SessionPage() { const { t, i18n } = useTranslation('session'); const { user } = useAuth(); @@ -663,8 +727,44 @@ export default function SessionPage() { const projectsSectionCollapsedStorageKey = `flocks:sessions:projects-section-collapsed:${user?.id ?? 'anonymous'}`; const location = useLocation(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - const [selectedSessionId, setSelectedSessionId] = useState(null); + const [searchParams] = useSearchParams(); + const { sessionId: routeSessionId } = useParams(); + const selectedSessionId = routeSessionId || searchParams.get('session') || null; + const navigationRef = useRef({ key: location.key, sessionId: selectedSessionId }); + navigationRef.current = { key: location.key, sessionId: selectedSessionId }; + const mountedRef = useRef(false); + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + // Once this page leaves the route, its pending mutations may finish, + // but must not navigate or overwrite the next page's selection. + mountedRef.current = false; + }; + }, []); + // Inside the layout this page lives in a keep-alive pane: switching tabs + // hides it instead of unmounting it, and its location stays pinned, so the + // unmount / location-key guards above never fire. A hidden pane must not + // navigate either, or a finishing create/archive would drag the whole app + // back to the session it belongs to. + const paneActive = useContext(PaneActiveContext); + const paneActiveRef = useRef(paneActive); + paneActiveRef.current = paneActive; + const restoreAttemptRef = useRef(null); + const legacyNavigationRef = useRef(null); + const previousActionSessionRef = useRef(selectedSessionId); + const [sessionLoadError, setSessionLoadError] = useState<{ sessionId: string; kind: 'unavailable' | 'forbidden' | 'failed' } | null>(null); + const [sessionLoadAttempt, setSessionLoadAttempt] = useState(0); + const [pendingInitialSessionId, setPendingInitialSessionId] = useState(null); + const selectSession = useCallback((id: string | null, replace = false) => { + if (!mountedRef.current || !paneActiveRef.current) return; + if (!id) writeLastSelectedSessionId(null); + if (id && navigationRef.current.sessionId === id) return; + navigate(id ? sessionPath(id) : '/sessions', { + replace, + state: id ? null : { skipLastSelectedSessionRestore: true }, + }); + }, [navigate]); + const legacyNavigationPending = Boolean(selectedSessionId) && (searchParams.has('session') || searchParams.has('message') || searchParams.has('display')); const [pendingFocusMessageId, setPendingFocusMessageId] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [selectedAgent, setSelectedAgent] = useState('rex'); @@ -678,6 +778,7 @@ export default function SessionPage() { () => readSessionExecutionMode(null), ); const [showExecutionModeOptions, setShowExecutionModeOptions] = useState(false); + const executionModeSelectorRef = useRef(null); const executionModeHandoffRef = useRef<{ sessionId: string; mode: SessionExecutionMode; @@ -686,8 +787,8 @@ export default function SessionPage() { const [selectedModelKey, setSelectedModelKey] = useState(null); const [showModelOptions, setShowModelOptions] = useState(false); const modelSelectorRef = useRef(null); - const [modelMenuLeftOffset, setModelMenuLeftOffset] = useState(0); const [showPermissionModeOptions, setShowPermissionModeOptions] = useState(false); + const permissionModeSelectorRef = useRef(null); const [proPolicyEnabled, setProPolicyEnabled] = useState(false); const [sessionPermissionMode, setSessionPermissionMode] = useState(null); const [draftPermissionMode, setDraftPermissionMode] = useState('require-confirm'); @@ -708,6 +809,30 @@ export default function SessionPage() { const [pendingInitialMessage, setPendingInitialMessage] = useState(null); const [pendingInitialDisplayText, setPendingInitialDisplayText] = useState(null); const [pendingOptimisticMessage, setPendingOptimisticMessage] = useState(null); + const [contextPanelOpen, setContextPanelOpen] = useState(false); + const [contextPanelWidth, setContextPanelWidth] = useState(() => getInitialSidePanelWidth()); + const [contextSnapshot, setContextSnapshot] = useState(null); + const [contextLoading, setContextLoading] = useState(false); + const [contextLoadingMore, setContextLoadingMore] = useState(false); + const [contextError, setContextError] = useState(null); + const [contextResetVersion, setContextResetVersion] = useState(0); + const contextScope = useMemo(() => ({ + sessionId: selectedSessionId, + open: contextPanelOpen, + resetVersion: contextResetVersion, + active: false, + skillSignatures: new Map(), + }), [selectedSessionId, contextPanelOpen, contextResetVersion]); + const contextScopeRef = useRef(contextScope); + contextScopeRef.current = contextScope; + const contextSnapshotRef = useRef(null); + const contextFlightRef = useRef<{ + scope: typeof contextScope; + controller: AbortController; + dirty: boolean; + promise: Promise; + } | null>(null); + const [requestedContextResource, setRequestedContextResource] = useState<{ sessionId: string | null; resourceID: string } | null>(null); const [selectMode, setSelectMode] = useState(false); const [checkedIds, setCheckedIds] = useState>(new Set()); const [projects, setProjects] = useState([]); @@ -756,20 +881,16 @@ export default function SessionPage() { const [agentSourceFilter, setAgentSourceFilter] = useState('all'); const [selectedSessionFallback, setSelectedSessionFallback] = useState(null); const [selectorTooltip, setSelectorTooltip] = useState(null); - const updateModelMenuLeftOffset = useCallback(() => { - const selector = modelSelectorRef.current; - if (!selector) return; - setModelMenuLeftOffset(getAnchoredMenuLeftOffset( - selector.getBoundingClientRect().left, - window.innerWidth, - )); - }, []); const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); const projectSubmitInFlightRef = useRef(false); const folderBrowserRequestIdRef = useRef(0); const folderBrowserInputPathRef = useRef(null); const sessionUpdateRefetchTimerRef = useRef(null); + const contextRefetchTimerRef = useRef(null); + const selectedSessionIdRef = useRef(selectedSessionId); + selectedSessionIdRef.current = selectedSessionId; + const previousSseStatusRef = useRef(null); const sessionStatusEventVersionRef = useRef(0); const projectListRequestSeqRef = useRef(0); const composerResourcesLoadedRef = useRef(false); @@ -931,8 +1052,9 @@ export default function SessionPage() { ); const selectedSession = listedSelectedSession ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); - const activeChatSessionId = selectedSession ? selectedSessionId : null; - const resolvingSelectedSession = Boolean(selectedSessionId && !selectedSession); + const activeSessionError = sessionLoadError?.sessionId === selectedSessionId ? sessionLoadError : null; + const activeChatSessionId = selectedSession && !activeSessionError ? selectedSessionId : null; + const resolvingSelectedSession = legacyNavigationPending || Boolean(selectedSessionId && !selectedSession && !activeSessionError); const pinnedModelKey = selectedSession?.model_pinned && selectedSession.provider && selectedSession.model ? makeModelKey(selectedSession.provider, selectedSession.model) : null; @@ -1159,6 +1281,76 @@ export default function SessionPage() { } }, []); + const fetchSessionContext = useCallback((older = false): Promise => { + const { sessionId, open } = contextScope; + // A stale callback must not invalidate another session, including A -> B -> A. + if (contextScopeRef.current !== contextScope || !contextScope.active || !sessionId || !open) return Promise.resolve(); + if (!older && contextRefetchTimerRef.current !== null) { + window.clearTimeout(contextRefetchTimerRef.current); + contextRefetchTimerRef.current = null; + } + const existing = contextFlightRef.current; + if (existing?.scope === contextScope) { + if (!older) existing.dirty = true; + return existing.promise; + } + const snapshot = contextSnapshotRef.current; + let before = older ? snapshot?.nextBefore : undefined; + if (older && (!snapshot?.hasMore || !before)) return Promise.resolve(); + const flight = { + scope: contextScope, + controller: new AbortController(), + dirty: false, + promise: Promise.resolve(), + }; + const isCurrent = () => contextScopeRef.current === contextScope + && contextFlightRef.current === flight && !flight.controller.signal.aborted; + contextFlightRef.current = flight; + setContextLoading(true); + setContextLoadingMore(older); + flight.promise = (async () => { + try { + do { + flight.dirty = false; + try { + const page = await sessionApi.getContext(sessionId, before ? { before } : {}, flight.controller.signal); + if (!isCurrent()) return; + const merged = mergeSessionContext(contextSnapshotRef.current, page, Boolean(before)); + contextSnapshotRef.current = merged; + setContextSnapshot(merged); + setContextError(null); + } catch (error) { + if (!isCurrent()) return; + setContextError(extractErrorMessage(error, 'Failed to load Session Context')); + } + // All refreshes received during this flight coalesce into one trailing head read. + before = undefined; + if (isCurrent()) setContextLoadingMore(false); + } while (isCurrent() && flight.dirty); + } finally { + if (isCurrent()) { + contextFlightRef.current = null; + setContextLoading(false); + setContextLoadingMore(false); + } + } + })(); + return flight.promise; + }, [contextScope]); + + const scheduleContextRefetch = useCallback(() => { + if (contextScopeRef.current !== contextScope || !contextScope.active || !contextScope.open || !contextScope.sessionId) return; + if (contextFlightRef.current?.scope === contextScope) { + void fetchSessionContext(); + return; + } + if (contextRefetchTimerRef.current !== null) return; + contextRefetchTimerRef.current = window.setTimeout(() => { + contextRefetchTimerRef.current = null; + void fetchSessionContext(); + }, 250); + }, [contextScope, fetchSessionContext]); + const scheduleSessionListRefetch = useCallback(() => { if (sessionUpdateRefetchTimerRef.current !== null) return; sessionUpdateRefetchTimerRef.current = window.setTimeout(() => { @@ -1175,9 +1367,77 @@ export default function SessionPage() { window.clearTimeout(sessionUpdateRefetchTimerRef.current); sessionUpdateRefetchTimerRef.current = null; } + if (contextRefetchTimerRef.current !== null) { + window.clearTimeout(contextRefetchTimerRef.current); + contextRefetchTimerRef.current = null; + } }, []); const handleSSEEvent = useCallback((event: SSEChatEvent) => { + const eventSessionId = event.properties?.sessionID + || event.properties?.part?.sessionID + || event.properties?.info?.sessionID; + if ( + event.type === 'session.cleared' + && contextScopeRef.current === contextScope + && contextScope.active + && eventSessionId === contextScope.sessionId + ) { + // Clearing history invalidates every message-backed resource, even when + // the panel is closed. Stop old reads before starting a fresh scope. + contextScope.active = false; + if (contextRefetchTimerRef.current !== null) { + window.clearTimeout(contextRefetchTimerRef.current); + contextRefetchTimerRef.current = null; + } + contextFlightRef.current?.controller.abort(); + contextFlightRef.current = null; + contextScope.skillSignatures.clear(); + contextSnapshotRef.current = null; + setContextSnapshot(null); + setContextError(null); + setRequestedContextResource(null); + // Remount the panel to cancel previews/folder navigation and clear local + // errors. The scope effect reloads retained roots if the panel is open. + setContextResetVersion((version) => version + 1); + return; + } + if ( + contextScopeRef.current === contextScope + && contextScope.active + && contextScope.open + && eventSessionId === contextScope.sessionId + ) { + const updatedPart = event.properties?.part; + let contextPartUpdated = event.type === 'message.part.updated' && ( + updatedPart?.type === 'file' + || ( + updatedPart?.type === 'tool' + && updatedPart?.tool === 'write' + && (updatedPart?.state?.status === 'completed' || updatedPart?.state?.status === 'error') + ) + ); + if ( + event.type === 'message.part.updated' + && updatedPart?.type === 'tool' + && (updatedPart.tool === 'skill_load' || updatedPart.tool === 'load_skill') + && ['pending', 'running', 'completed', 'error'].includes(updatedPart.state?.status) + ) { + const { input, status, error } = updatedPart.state; + const partID = updatedPart.id || event.properties?.partID; + const key = JSON.stringify([updatedPart.messageID || event.properties?.messageID, partID]); + const signature = JSON.stringify([input?.name || input?.skill || null, status, error ?? null]); + // Only descriptor changes matter, not streamed output. Keep this cache + // inside the open/session scope so closed, foreign, or stale events cannot seed it. + if (!partID || contextScope.skillSignatures.get(key) !== signature) { + if (partID) contextScope.skillSignatures.set(key, signature); + contextPartUpdated = true; + } + } + if (contextPartUpdated || event.type === 'todo.updated' || event.type === 'session.context.updated') { + scheduleContextRefetch(); + } + } if ( event.type === 'session.execution_mode.changed' && event.properties?.sessionID === selectedSessionId @@ -1211,6 +1471,8 @@ export default function SessionPage() { scheduleSessionListRefetch(); } }, [ + contextScope, + scheduleContextRefetch, scheduleSessionListRefetch, selectedSessionId, t, @@ -1218,6 +1480,48 @@ export default function SessionPage() { updateSessionTitle, ]); + useEffect(() => { + // Opening the panel must not consume the resource ID set by the message card. + setRequestedContextResource(null); + contextSnapshotRef.current = null; + setContextSnapshot(null); + setContextError(null); + }, [selectedSessionId]); + + useEffect(() => { + contextScope.active = true; + setContextLoading(false); + setContextLoadingMore(false); + if (contextScope.open && contextScope.sessionId) void fetchSessionContext(); + return () => { + contextScope.active = false; + contextScope.skillSignatures.clear(); + if (contextRefetchTimerRef.current !== null) { + window.clearTimeout(contextRefetchTimerRef.current); + contextRefetchTimerRef.current = null; + } + const flight = contextFlightRef.current; + if (flight?.scope === contextScope) { + flight.controller.abort(); + contextFlightRef.current = null; + } + }; + }, [contextScope, fetchSessionContext]); + + useEffect(() => { + const previous = previousSseStatusRef.current; + previousSseStatusRef.current = sseStatus; + if ( + contextPanelOpen + && previous + && previous !== 'connected' + && sseStatus === 'connected' + && selectedSessionId + ) { + void fetchSessionContext(); + } + }, [contextPanelOpen, fetchSessionContext, selectedSessionId, sseStatus]); + useEffect(() => { void fetchProjects(undefined, searchQuery); }, [fetchProjects, searchQuery]); @@ -1278,48 +1582,55 @@ export default function SessionPage() { return () => window.removeEventListener('click', closeMenu); }, [openProjectMenuId]); - // Keep the selected session in sync with URL query params (e.g. onboarding - // or other in-app navigation to `/sessions?session=...`). Clear the params - // after consuming them so refreshes don't re-send the initial message. + // A pending action belongs only to its original session. Clear it before + // consuming a new legacy navigation; canonicalization keeps the same ID. useEffect(() => { - const sessionParam = searchParams.get('session'); - const messageParam = searchParams.get('message'); - const focusMessageParam = searchParams.get('focusMessage'); - const displayParam = searchParams.get('display'); - if (!sessionParam) return; - - if (sessionParam !== selectedSessionId) { - setSelectedSessionId(sessionParam); + if (previousActionSessionRef.current === selectedSessionId) return; + previousActionSessionRef.current = selectedSessionId; + setPendingInitialMessage(null); + setPendingInitialDisplayText(null); + setPendingInitialSessionId(null); + }, [selectedSessionId]); + + useEffect(() => { + if (!selectedSessionId || !legacyNavigationPending || legacyNavigationRef.current === location.key) return; + legacyNavigationRef.current = location.key; + const legacyId = searchParams.get('session'); + const conflictingTarget = Boolean(routeSessionId && legacyId && routeSessionId !== legacyId); + const message = searchParams.get('message'); + if (message && !conflictingTarget) { + setPendingInitialSessionId(selectedSessionId); + setPendingInitialMessage(message); + const display = searchParams.get('display'); + setPendingInitialDisplayText(display ? buildInstructionDisplayText(display) : null); } - if (sessionParam) { - if (messageParam) { - setPendingInitialMessage(messageParam); - setPendingInitialDisplayText(displayParam ? buildInstructionDisplayText(displayParam) : null); - } else { - setPendingInitialMessage(null); - setPendingInitialDisplayText(null); - } - setPendingFocusMessageId(focusMessageParam || null); - setSearchParams({}, { replace: true }); + if (!message || conflictingTarget) { + setPendingInitialSessionId(null); + setPendingInitialMessage(null); + setPendingInitialDisplayText(null); } - }, [searchParams, selectedSessionId, setSearchParams]); + if (conflictingTarget) toast.error(t('linkTargetMismatch')); + const next = new URLSearchParams(searchParams); + next.delete('session'); + next.delete('message'); + next.delete('display'); + navigate({ pathname: sessionPath(selectedSessionId), search: next.toString(), hash: location.hash }, { replace: true }); + }, [legacyNavigationPending, location.key, location.hash, navigate, routeSessionId, searchParams, selectedSessionId, t, toast]); useEffect(() => { - if (loadingSessions) return; + setPendingFocusMessageId(searchParams.get('focusMessage')); + }, [selectedSessionId, searchParams]); + useEffect(() => { + // A hidden pane waits: the restore runs once the tab is back on screen. + if (!paneActive || loadingSessions || restoreAttemptRef.current === location.key) return; + restoreAttemptRef.current = location.key; const alreadyVisited = hasVisitedSessionPage(); markSessionPageVisited(); - - if (selectedSessionId) return; - if (searchParams.get('session')) return; - if (!alreadyVisited) return; - if (shouldSkipLastSelectedSessionRestore(location.state)) return; - + if (selectedSessionId || !alreadyVisited || shouldSkipLastSelectedSessionRestore(location.state)) return; const lastSelectedSessionId = readLastSelectedSessionId(); - if (lastSelectedSessionId) { - setSelectedSessionId(lastSelectedSessionId); - } - }, [loadingSessions, location.state, searchParams, selectedSessionId]); + if (lastSelectedSessionId) selectSession(lastSelectedSessionId, true); + }, [loadingSessions, location.key, location.state, paneActive, selectedSessionId, selectSession]); useEffect(() => { if (!selectedSessionId || selectedSession?.id !== selectedSessionId) return; @@ -1337,38 +1648,40 @@ export default function SessionPage() { }, [selectedSessionId]); useEffect(() => { - if (!selectedSessionId) { - setSelectedSessionFallback(null); - return; - } + // Do not reuse a list-external detail after leaving its route (it may + // have been archived or its sharing permissions may have changed). + setSelectedSessionFallback(current => current?.id === selectedSessionId ? current : null); + }, [selectedSessionId]); + + useEffect(() => { + setSessionLoadError(null); + // Keep a newly created session cached while React commits navigation. + // Clearing it on the old route can discard the creation handoff. + if (!selectedSessionId) return; if (listedSelectedSession) { - setSelectedSessionFallback(null); + if (selectedSessionFallback?.id === selectedSessionId) setSelectedSessionFallback(null); return; } if (selectedSessionFallback?.id === selectedSessionId) return; - if (loadingSessions) return; - + // Direct links must not wait for a failed/slow sidebar request. let cancelled = false; sessionApi.get(selectedSessionId) .then((session) => { if (cancelled) return; + if (session.id !== selectedSessionId) throw new Error('Unexpected session response'); setSelectedSessionFallback(session as unknown as Session); }) .catch((err: any) => { if (cancelled) return; - const statusCode = err?.response?.status ?? err?.status; - if (statusCode === 403 || statusCode === 404) { - setSelectedSessionId((current) => (current === selectedSessionId ? null : current)); - setSelectedSessionFallback(null); - setPendingInitialMessage(null); - setPendingInitialDisplayText(null); - writeLastSelectedSessionId(null); - } + const status = err?.response?.status ?? err?.status; + setSelectedSessionFallback(null); + setSessionLoadError({ sessionId: selectedSessionId, kind: status === 403 ? 'forbidden' : status === 404 ? 'unavailable' : 'failed' }); + setPendingInitialMessage(null); + setPendingInitialDisplayText(null); + if (status === 403 || status === 404) writeLastSelectedSessionId(null); }); - return () => { - cancelled = true; - }; - }, [listedSelectedSession, loadingSessions, selectedSessionFallback?.id, selectedSessionId]); + return () => { cancelled = true; }; + }, [listedSelectedSession, selectedSessionId, selectedSessionFallback?.id, sessionLoadAttempt]); // Close agent dropdown on outside click useEffect(() => { @@ -1410,13 +1723,6 @@ export default function SessionPage() { return () => document.removeEventListener('mousedown', handle); }, [showProjectOptions]); - useEffect(() => { - if (!showModelOptions) return; - updateModelMenuLeftOffset(); - window.addEventListener('resize', updateModelMenuLeftOffset); - return () => window.removeEventListener('resize', updateModelMenuLeftOffset); - }, [showModelOptions, updateModelMenuLeftOffset]); - useEffect(() => { if (!showModelOptions) return; const handle = (e: MouseEvent) => { @@ -1453,8 +1759,10 @@ export default function SessionPage() { setSessionExecutionRevision(null); return; } + let cancelled = false; void flocksproPolicyApi.getSessionExecutionSettings(selectedSessionId) .then((result) => { + if (cancelled) return; setSessionPermissionMode(result.permissionMode); setSessionRuntimeMode(result.runtimeMode); setSessionNetworkMode(result.networkMode); @@ -1464,6 +1772,7 @@ export default function SessionPage() { setSessionExecutionRevision(result.revision); }) .catch(() => { + if (cancelled) return; setSessionPermissionMode(null); setSessionRuntimeMode(null); setSessionNetworkMode(null); @@ -1472,6 +1781,7 @@ export default function SessionPage() { setSessionEntry('unknown'); setSessionExecutionRevision(null); }); + return () => { cancelled = true; }; }, [proPolicyEnabled, selectedSessionId]); const handlePermissionModeChange = useCallback(async (permissionMode: PermissionMode) => { @@ -1736,25 +2046,58 @@ export default function SessionPage() { ); }, [selectedSessionId]); + const handleOpenContextFile = useCallback((resourceId: string) => { + setRequestedContextResource({ sessionId: selectedSessionIdRef.current, resourceID: resourceId }); + setContextPanelOpen(true); + }, []); + + const handleResizeContextPanel = useCallback((event: React.PointerEvent) => { + if (window.innerWidth < 1024) return; + const startX = event.clientX; + const startWidth = contextPanelWidth; + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + const onMove = (moveEvent: PointerEvent) => { + const next = startWidth + startX - moveEvent.clientX; + setContextPanelWidth(Math.min(getMaxSidePanelWidth(), Math.max(SIDE_PANEL_MIN_WIDTH, next))); + }; + const onEnd = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onEnd); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onEnd, { once: true }); + }, [contextPanelWidth]); + const handleStartNewSession = useCallback(() => { writeLastSelectedSessionId(null); - setSelectedSessionId(null); + selectSession(null); setSelectedSessionFallback(null); setPendingInitialMessage(null); setPendingInitialDisplayText(null); + setContextPanelOpen(false); + setContextSnapshot(null); + setRequestedContextResource(null); setSelectedAgent('rex'); - setSelectedModelKey(null); + // Starting another blank draft keeps the session ID null, so the model + // initialization effect will not run again. Restore the default explicitly. + setSelectedModelKey(resolvedDefaultModelInitialized ? defaultSelectionKey : null); setSseStatus('disconnected'); setShowAgentOptions(false); setShowModelOptions(false); setShowProjectOptions(false); - }, []); + }, [defaultSelectionKey, resolvedDefaultModelInitialized, selectSession]); const handleCreateSession = useCallback(async (projectIdOverride?: string) => { if (creating) return; const targetGroupId = projectIdOverride ?? selectedProjectId ?? TASK_SESSION_GROUP_ID; const projectID = targetGroupId === TASK_SESSION_GROUP_ID ? null : targetGroupId; const carryAutoSelection = !selectedSessionId && selectedModelAuto; + const navigationKey = navigationRef.current.key; setCreating(true); try { const response = await client.post('/api/session', { @@ -1764,6 +2107,7 @@ export default function SessionPage() { }); addSession(response.data); await fetchProjects(undefined, searchQuery); + if (!mountedRef.current || navigationRef.current.key !== navigationKey) return; setSelectedSessionFallback(response.data); setSelectedProjectId(targetGroupId); setCollapsedProjectIds(prev => { @@ -1784,13 +2128,13 @@ export default function SessionPage() { setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); setShowExecutionModeOptions(false); setSelectedModelKey(carryAutoSelection ? AUTO_MODEL_KEY : null); - setSelectedSessionId(response.data.id); + selectSession(response.data.id); } catch (err: any) { toast.error(t('createFailed'), err.message); } finally { setCreating(false); } - }, [creating, selectedProjectId, selectedSessionId, selectedModelAuto, addSession, fetchProjects, searchQuery, toast, t]); + }, [creating, selectedProjectId, selectedSessionId, selectedModelAuto, addSession, fetchProjects, searchQuery, toast, t, selectSession]); const handleCreateSessionInProject = useCallback((projectId: string) => { void handleCreateSession(projectId); @@ -1837,12 +2181,13 @@ export default function SessionPage() { const handleCreateAndSend = useCallback(async ( text: string, - imageParts?: ImagePartData[], + fileParts?: FilePartData[], agentOverride?: string, modelOverride?: { providerID: string; modelID: string } | null, options?: PromptDisplayOptions, executionModeOverride?: SessionExecutionMode, ) => { + const navigationKey = navigationRef.current.key; try { const effectiveExecutionMode = executionModeOverride || selectedExecutionMode; const response = await client.post('/api/session', { @@ -1862,18 +2207,18 @@ export default function SessionPage() { ...(options?.displayText ? { metadata: { displayText: options.displayText } } : {}), }); } - imageParts?.forEach((image, index) => { + fileParts?.forEach((file, index) => { optimisticParts.push({ - id: `temp-${messageId}-img-${index}`, + id: file.id || `temp-${messageId}-file-${index}`, type: 'file', - url: image.url, - mime: image.mime, - filename: image.filename, + url: file.url, + mime: file.mime, + filename: file.filename, }); }); const payload: Record = { - parts: buildPromptParts(text, imageParts), + parts: buildPromptParts(text, fileParts), messageID: messageId, }; if (effectiveAgent) payload.agent = effectiveAgent; @@ -1888,13 +2233,15 @@ export default function SessionPage() { runtimeMode: draftRuntimeMode, networkMode: draftNetworkMode, }); - setSessionPermissionMode(updated.permissionMode); - setSessionRuntimeMode(updated.runtimeMode); - setSessionNetworkMode(updated.networkMode); - setSessionNetworkModeDefault(updated.networkModeDefault); - setSessionNetworkModeOverridden(updated.networkModeOverridden); - setSessionEntry(updated.entry); - setSessionExecutionRevision(updated.revision); + if (mountedRef.current && navigationRef.current.key === navigationKey) { + setSessionPermissionMode(updated.permissionMode); + setSessionRuntimeMode(updated.runtimeMode); + setSessionNetworkMode(updated.networkMode); + setSessionNetworkModeDefault(updated.networkModeDefault); + setSessionNetworkModeOverridden(updated.networkModeOverridden); + setSessionEntry(updated.entry); + setSessionExecutionRevision(updated.revision); + } } catch (error: unknown) { if (isSessionExecutionSettingsUnsupported(error)) { // Backward compatibility: older backends may not expose execution-settings yet. @@ -1910,6 +2257,7 @@ export default function SessionPage() { addSession(response.data); void fetchProjects(undefined, searchQuery).catch(() => {}); + if (!mountedRef.current || navigationRef.current.key !== navigationKey) return; setSelectedSessionFallback(response.data); executionModeHandoffRef.current = { sessionId: newSessionId, @@ -1938,7 +2286,7 @@ export default function SessionPage() { timestamp: Date.now(), agent: effectiveAgent, }); - setSelectedSessionId(newSessionId); + selectSession(newSessionId); if (effectiveExecutionMode === 'goal') { setSelectedExecutionMode(DEFAULT_SESSION_EXECUTION_MODE); writeSessionExecutionMode( @@ -1951,6 +2299,7 @@ export default function SessionPage() { throw err; } }, [ + selectSession, addSession, fetchProjects, searchQuery, @@ -2042,7 +2391,8 @@ export default function SessionPage() { } // The mutation is complete. A secondary project-count refresh must not // turn a successful archive into an error or re-enable the removed row. - if (selectedSessionId === sessionId) setSelectedSessionId(null); + if (navigationRef.current.sessionId === sessionId) selectSession(null, true); + setSelectedSessionFallback(current => current?.id === sessionId ? null : current); removeSession(sessionId); toast.success(t('archiveSuccess')); try { @@ -2050,7 +2400,7 @@ export default function SessionPage() { } catch { // SSE/reconnect refreshes will reconcile project counts later. } - }, [fetchProjects, removeSession, searchQuery, selectedSessionId, toast, t]); + }, [fetchProjects, removeSession, searchQuery, selectedSessionId, toast, t, selectSession]); const handleStartRename = useCallback((sessionId: string, currentTitle: string) => { setOpenMenuSessionId(null); @@ -2398,9 +2748,9 @@ export default function SessionPage() { if (selectMode) { handleToggleCheck(sessionId); } else { - setSelectedSessionId(sessionId); + selectSession(sessionId); } - }, [handleToggleCheck, selectMode]); + }, [handleToggleCheck, selectMode, selectSession]); const handleCloseSessionSearch = useCallback(() => { setSessionSearchOpen(false); setSearchQuery(''); @@ -2447,9 +2797,10 @@ export default function SessionPage() { } })); if (succeeded.length > 0) { + setSelectedSessionFallback(current => current && succeeded.includes(current.id) ? null : current); removeSessions(succeeded); - if (selectedSessionId && succeeded.includes(selectedSessionId)) { - setSelectedSessionId(null); + if (navigationRef.current.sessionId && succeeded.includes(navigationRef.current.sessionId)) { + selectSession(null, true); } try { await fetchProjects(undefined, searchQuery); @@ -2467,12 +2818,12 @@ export default function SessionPage() { } finally { setBatchArchiving(false); } - }, [batchArchiving, checkedIds, fetchProjects, removeSessions, searchQuery, selectedSessionId, toast, t]); + }, [batchArchiving, checkedIds, fetchProjects, removeSessions, searchQuery, selectedSessionId, toast, t, selectSession]); const renderSessionListItem = (session: Session) => (
selectMode ? handleToggleCheck(session.id) : setSelectedSessionId(session.id)} + onClick={() => selectMode ? handleToggleCheck(session.id) : selectSession(session.id)} className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ !selectMode && selectedSessionId === session.id ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' @@ -3105,13 +3456,49 @@ export default function SessionPage() {
+ {selectedSessionId && selectedSession && !activeSessionError && ( + + )} + {workbenchRefreshing && ( )} + +
+ +
+
{/* Chat — powered by unified SessionChat */} - {resolvingSelectedSession ? ( + {activeSessionError ? ( +
+

{t(`sessionAccess.${activeSessionError.kind}`)}

+
+ + +
+
+ ) : resolvingSelectedSession ? ( ) : ( setPendingFocusMessageId(null)} + onOpenContextFile={handleOpenContextFile} + onOpenContext={() => setContextPanelOpen(true)} onInitialMessageConsumed={() => { setPendingInitialMessage(null); setPendingInitialDisplayText(null); @@ -3356,7 +3745,7 @@ export default function SessionPage() { )} toolbarSlot={
-
+
{showExecutionModeOptions && ( -
+ )} + contentClassName="space-y-0.5 p-1.5" > -
-
- {t('executionMode.title')} -
-
- {t('executionMode.hint')} -
-
-
- {SESSION_EXECUTION_MODES.map((mode) => { - const selected = selectedExecutionMode === mode; - return ( - - ); - })} -
-
+ {SESSION_EXECUTION_MODES.map((mode) => ( + } + onClick={() => handleSelectExecutionMode(mode)} + /> + ))} + )}
{!activeChatSessionId && ( @@ -3521,7 +3884,6 @@ export default function SessionPage() { {showModelOptions && ( -
+ )} + contentClassName="p-1.5" + footer={( +
+ +
+ )} > -
-
{t('modelPicker.title')}
-
{t('modelPicker.hint')}
-
-
{loadingProviders || loadingEnabledModels ? (
{t('loading')}
) : ( @@ -3563,6 +3944,7 @@ export default function SessionPage() {
{groupedChatModelOptions.length > 0 ? groupedChatModelOptions.map((group) => (
- {group.providerName} + {group.providerName} {t('modelPicker.count', { count: group.models.length })} @@ -3605,7 +3987,8 @@ export default function SessionPage() { key={option.key} type="button" onClick={() => void handleSelectModel(option)} - className={`w-full rounded-md border px-2 py-1.5 text-left transition-colors ${ + title={`${option.label}\n${option.providerName} / ${option.modelID}\n${option.pricingLabel}\n${option.contextLabel}`} + className={`w-full rounded-md border px-2 py-1.5 text-left transition-colors ${ selectedModelOption?.key === option.key ? 'border-blue-300 bg-blue-50 text-zinc-950 shadow-sm dark:border-blue-500/60 dark:bg-blue-500/15 dark:text-zinc-50' : 'border-transparent text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50' @@ -3633,7 +4016,7 @@ export default function SessionPage() {
- {selectedModelOption?.key === option.key && } +
))} @@ -3644,26 +4027,11 @@ export default function SessionPage() { )} )} -
-
- -
-
+ )}
{proPolicyEnabled && ( -
+
{showPermissionModeOptions && ( -
-
-
-
-
+ +
+
+
{t('permissionMode.runtimeTitle', '平台开发模式')}
-
- {runtimeModeOptions.map(({ value: mode, label, description }) => ( - - ))} +
+ {runtimeModeOptions.map(({ value: mode, label, description }) => ( + { + void handleRuntimeModeChange(mode); + setShowPermissionModeOptions(false); + }} + /> + ))}
-
-
-
- {t('permissionMode.networkTitle', '网络访问模式')} -
-
- {networkModeOptions.map(({ value: mode, label, description }) => ( - - ))} -
-
-
-
- {t('permissionMode.title')} -
-
- {permissionModeOptions.map(({ value: mode, label, description }) => ( - - ))} -
-
-
-
+
+
+
+ {t('permissionMode.networkTitle', '网络访问模式')} +
+
+ {networkModeOptions.map(({ value: mode, label, description }) => ( + { + void handleNetworkModeChange(mode); + setShowPermissionModeOptions(false); + }} + /> + ))} +
+
+
+
+ {t('permissionMode.title')} +
+
+ {permissionModeOptions.map(({ value: mode, label, description }) => ( + { + void handlePermissionModeChange(mode); + setShowPermissionModeOptions(false); + }} + /> + ))} +
+
+ )}
)} @@ -3799,6 +4137,39 @@ export default function SessionPage() { } /> )} + {contextPanelOpen && activeChatSessionId && ( + <> +
+ + + )} +
{projectDialogMode && ( @@ -3993,6 +4364,7 @@ export default function SessionPage() { {selectorTooltip && (
diff --git a/webui/src/pages/Settings/index.test.tsx b/webui/src/pages/Settings/index.test.tsx index 836328e7c..add372284 100644 --- a/webui/src/pages/Settings/index.test.tsx +++ b/webui/src/pages/Settings/index.test.tsx @@ -3,7 +3,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import SettingsPage from './index'; -import { ThemeContext, type Theme } from '@/contexts/ThemeContext'; +import { ThemeContext, type Theme, type ThemeMode } from '@/contexts/ThemeContext'; import { ToastProvider } from '@/components/common/Toast'; const { changeLanguage, flocksproUsersApi, setTheme, toolFailureConfigApi, useAuth } = vi.hoisted(() => ({ @@ -78,12 +78,13 @@ function LocationProbe() { return
{`${location.pathname}${location.search}${location.hash}`}
; } -function renderSettings(path: string, theme: Theme = 'light', state?: Record) { +function renderSettings(path: string, theme: Theme = 'light', state?: Record, mode: ThemeMode = theme) { return render( { expect(setTheme).toHaveBeenCalledWith('dark'); }); + it('marks "follow system" as the selected theme and lets the user pick it', async () => { + const user = userEvent.setup(); + + // Resolved dark (the OS is dark) but the stored choice is "system". + renderSettings('/settings/preferences', 'dark', undefined, 'system'); + + expect(screen.getByRole('button', { name: 'systemTheme' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'darkTheme' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'lightTheme' })).toHaveAttribute('aria-pressed', 'false'); + + await user.click(screen.getByRole('button', { name: 'lightTheme' })); + expect(setTheme).toHaveBeenCalledWith('light'); + await user.click(screen.getByRole('button', { name: 'systemTheme' })); + expect(setTheme).toHaveBeenCalledWith('system'); + }); + it('loads and updates repeated tool failure auto-disable', async () => { const user = userEvent.setup(); toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: false }); @@ -171,59 +188,27 @@ describe('SettingsPage', () => { expect(await screen.findByText('channels page')).toBeInTheDocument(); }); - it('returns to the page captured before opening settings', async () => { - const user = userEvent.setup(); - - renderSettings('/settings/system-logs', 'light', { - from: { - pathname: '/contracts/webui/workspaces/scene_workspace', - search: '?view=posture', - hash: '#top', - }, - }); - - expect(await screen.findByText('system logs page')).toBeInTheDocument(); - await user.click(screen.getAllByRole('link', { name: 'accountManagement' })[0]); - expect(await screen.findByText('account page')).toBeInTheDocument(); - - await user.click(screen.getAllByRole('button', { name: 'settingsBack' })[0]); - - expect(await screen.findByTestId('location')).toHaveTextContent( - '/contracts/webui/workspaces/scene_workspace?view=posture#top', - ); - }); - - it('keeps return and section navigation available outside the desktop sidebar', async () => { - renderSettings('/settings/system-logs'); + it('renders only the section content: the layout sidebar owns the settings menu', async () => { + const { container } = renderSettings('/settings/system-logs'); expect(await screen.findByText('system logs page')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'settingsBack' })).toHaveLength(2); - - const mobileNav = screen.getByRole('navigation', { name: 'settingsTitle' }); - expect(within(mobileNav).getByRole('link', { name: 'accountManagement' })).toHaveAttribute('href', '/settings/account'); - expect(within(mobileNav).getByRole('link', { name: 'securityConfig' })).toHaveAttribute('href', '/settings/security-config'); - expect(within(mobileNav).getByRole('link', { name: 'auditLogs' })).toHaveAttribute('href', '/settings/audit-logs'); - expect(within(mobileNav).queryByRole('link', { name: 'models' })).not.toBeInTheDocument(); - expect(within(mobileNav).queryByRole('link', { name: 'channels' })).not.toBeInTheDocument(); + expect(container.querySelector('aside')).toBeNull(); + expect(screen.queryByRole('button', { name: 'settingsBack' })).not.toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'settingsTitle' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'accountManagement' })).not.toBeInTheDocument(); }); it('renders security config in settings for Flocks Pro admins', async () => { renderSettings('/settings/security-config'); expect(await screen.findByText('security config page')).toBeInTheDocument(); - expect(screen.getAllByRole('link', { name: 'securityConfig' })[0]).toHaveAttribute('href', '/settings/security-config'); - const links = screen.getAllByRole('link'); - const accountIndex = links.findIndex((item) => item.textContent === 'accountManagement'); - const securityIndex = links.findIndex((item) => item.textContent === 'securityConfig'); - expect(accountIndex).toBeGreaterThanOrEqual(0); - expect(securityIndex).toBeGreaterThan(accountIndex); + expect(flocksproUsersApi.hasCapability).toHaveBeenCalled(); }); it('renders audit logs in settings for Flocks Pro admins', async () => { renderSettings('/settings/audit-logs'); expect(await screen.findByText('audit logs page')).toBeInTheDocument(); - expect(screen.getAllByRole('link', { name: 'auditLogs' })[0]).toHaveAttribute('href', '/settings/audit-logs'); expect(flocksproUsersApi.hasCapability).toHaveBeenCalled(); }); @@ -231,10 +216,6 @@ describe('SettingsPage', () => { renderSettings('/settings/archived-data'); expect(await screen.findByText('archived data page')).toBeInTheDocument(); - expect(screen.getAllByRole('link', { name: 'archivedData' })[0]).toHaveAttribute( - 'href', - '/settings/archived-data', - ); }); it('hides audit logs when Flocks Pro capability is unavailable', async () => { diff --git a/webui/src/pages/Settings/index.tsx b/webui/src/pages/Settings/index.tsx index be626315b..f0de5d521 100644 --- a/webui/src/pages/Settings/index.tsx +++ b/webui/src/pages/Settings/index.tsx @@ -1,34 +1,31 @@ import { Suspense, lazy, useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEvent, ComponentType, ReactNode } from 'react'; -import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { Navigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { - ArrowLeft, - ArrowUpCircle, - Archive, Check, ImageIcon, Languages, + Monitor, Moon, RotateCcw, - ScrollText, Save, - Settings as SettingsIcon, ShieldCheck, - Shield, Sun, TextCursorInput, Upload, - UserCog, type LucideIcon, } from 'lucide-react'; import RoutePageSkeleton from '@/components/common/RoutePageSkeleton'; import { ThemeContext } from '@/contexts/ThemeContext'; -import { useAuth } from '@/contexts/AuthContext'; import { useProductName } from '@/contexts/ProductNameContext'; import { useToast } from '@/components/common/Toast'; -import { flocksproUsersApi } from '@/api/flocksproUsers'; import { toolFailureConfigApi } from '@/api/toolFailureConfig'; +import { + isSettingsSectionId, + useSettingsSectionGroups, + type SettingsSectionId, +} from '@/utils/settingsSections'; import { preloadI18nNamespaces } from '@/i18nResources'; type LazySettingsModule = { default: ComponentType }; @@ -51,61 +48,6 @@ const ArchivedDataPage = lazySettingsPage(() => import('./ArchivedDataPanel'), [ const SecurityConfigPage = lazySettingsPage(() => import('@/pages/SecurityConfig'), ['flockspro']); -type SettingsSectionId = 'preferences' | 'archived-data' | 'account' | 'security-config' | 'system-logs' | 'audit-logs' | 'flockspro'; - -interface ReturnLocation { - pathname: string; - search: string; - hash: string; -} - -interface SettingsLocationState { - from?: Partial; -} - -interface SettingsSection { - id: SettingsSectionId; - name: string; - icon: LucideIcon; - adminOnly?: boolean; - requiresFlockspro?: boolean; -} - -interface SettingsGroup { - name: string; - items: SettingsSection[]; -} - -function isSettingsSectionId(value: string | undefined): value is SettingsSectionId { - return ( - value === 'preferences' || - value === 'archived-data' || - value === 'account' || - value === 'security-config' || - value === 'system-logs' || - value === 'audit-logs' || - value === 'flockspro' - ); -} - -function sanitizeReturnLocation(state: unknown): ReturnLocation { - const from = (state as SettingsLocationState | null)?.from; - const pathname = typeof from?.pathname === 'string' ? from.pathname : ''; - if (!pathname.startsWith('/') || pathname.startsWith('/settings')) { - return { pathname: '/', search: '', hash: '' }; - } - - return { - pathname, - search: typeof from?.search === 'string' && from.search.startsWith('?') ? from.search : '', - hash: typeof from?.hash === 'string' && from.hash.startsWith('#') ? from.hash : '', - }; -} - -function buildReturnPath(location: ReturnLocation): string { - return `${location.pathname}${location.search}${location.hash}`; -} - function PreferenceRow({ icon: Icon, title, @@ -118,7 +60,7 @@ function PreferenceRow({ children: ReactNode; }) { return ( -
+
@@ -151,7 +93,7 @@ function SegmentedOption({ type="button" aria-pressed={active} onClick={onClick} - className={`inline-flex h-9 min-w-0 flex-1 items-center justify-center gap-2 rounded-md px-3 text-sm font-semibold transition-colors ${ + className={`inline-flex h-9 min-w-0 flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-semibold transition-colors ${ active ? 'bg-zinc-950 text-white dark:bg-zinc-100 dark:text-zinc-950' : 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-950 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white' @@ -198,7 +140,7 @@ function PreferenceSwitch({ function PreferencesPanel() { const { t, i18n } = useTranslation('nav'); - const { theme, setTheme } = useContext(ThemeContext); + const { theme, mode: themeMode, setTheme } = useContext(ThemeContext); const { productName, configuredDisplayName, @@ -443,16 +385,23 @@ function PreferencesPanel() { title={t('theme')} description={t('themeDescription')} > -
+
+ setTheme('system')} + > + {t('systemTheme')} + setTheme('light')} > {t('lightTheme')} setTheme('dark')} > @@ -495,97 +444,12 @@ function SettingsContent({ sectionId }: { sectionId: SettingsSectionId }) { export default function SettingsPage() { const params = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation('nav'); - const { user } = useAuth(); - const { proProductName } = useProductName(); - const isAdmin = user?.role === 'admin'; const sectionId = params.sectionId; - const [flocksproCapabilityReady, setFlocksproCapabilityReady] = useState(false); - const [hasFlocksproCapability, setHasFlocksproCapability] = useState(false); - const returnLocation = useMemo(() => sanitizeReturnLocation(location.state), [location.state]); - const settingsRouteState = useMemo(() => ({ from: returnLocation }), [returnLocation]); - - useEffect(() => { - let cancelled = false; - if (!isAdmin) { - setHasFlocksproCapability(false); - setFlocksproCapabilityReady(true); - return () => { - cancelled = true; - }; - } - - setFlocksproCapabilityReady(false); - const refreshCapability = () => { - void flocksproUsersApi.hasCapability() - .then((ok) => { - if (!cancelled) { - setHasFlocksproCapability(ok); - } - }) - .catch(() => { - if (!cancelled) { - setHasFlocksproCapability(false); - } - }) - .finally(() => { - if (!cancelled) { - setFlocksproCapabilityReady(true); - } - }); - }; - - refreshCapability(); - window.addEventListener('flockspro-license-status-changed', refreshCapability); - return () => { - cancelled = true; - window.removeEventListener('flockspro-license-status-changed', refreshCapability); - }; - }, [isAdmin]); - - const groups = useMemo( - () => [ - { - name: t('settingsGroupPreferences'), - items: [ - { id: 'preferences', name: t('settingsPreferences'), icon: SettingsIcon }, - ], - }, - { - name: t('settingsGroupData'), - items: [ - { id: 'archived-data', name: t('archivedData'), icon: Archive }, - ], - }, - { - name: t('settingsGroupSystem'), - items: [ - { id: 'account', name: t('accountManagement'), icon: UserCog }, - { id: 'security-config', name: t('securityConfig'), icon: Shield, adminOnly: true, requiresFlockspro: true }, - { id: 'system-logs', name: t('systemLog'), icon: ScrollText }, - { id: 'audit-logs', name: t('auditLogs'), icon: ShieldCheck, adminOnly: true, requiresFlockspro: true }, - { id: 'flockspro', name: proProductName, icon: ArrowUpCircle, adminOnly: true }, - ], - }, - ], - [proProductName, t], - ); - - const visibleGroups = groups - .map((group) => ({ - ...group, - items: group.items.filter((item) => { - if (item.adminOnly && !isAdmin) return false; - if (item.requiresFlockspro && flocksproCapabilityReady && !hasFlocksproCapability) return false; - return true; - }), - })) - .filter((group) => group.items.length > 0); + // Same source as the layout sidebar, so menu and page can never disagree. + const { groups } = useSettingsSectionGroups(); if (!sectionId) { - return ; + return ; } if (sectionId === 'models') { @@ -597,103 +461,13 @@ export default function SettingsPage() { } if (!isSettingsSectionId(sectionId)) { - return ; + return ; } - const currentSection = visibleGroups.flatMap((group) => group.items).find((item) => item.id === sectionId); - - if (!currentSection) { - return ; + const visible = groups.flatMap((group) => group.items).some((item) => item.id === sectionId); + if (!visible) { + return ; } - return ( -
- - -
-
- -
-

{t('settingsTitle')}

-

{currentSection.name}

-
- -
- -
- -
-
-
- ); + return ; } diff --git a/webui/src/pages/Skill/SkillSheet.test.tsx b/webui/src/pages/Skill/SkillSheet.test.tsx index 505a9f5be..76e372c32 100644 --- a/webui/src/pages/Skill/SkillSheet.test.tsx +++ b/webui/src/pages/Skill/SkillSheet.test.tsx @@ -2,12 +2,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; import SkillSheet from '@/pages/Skill/SkillSheet'; +import { skillAPI } from '@/api/skill'; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- -vi.mock('@/api/skill', () => ({ +vi.mock('@/api/skill', async (importOriginal) => ({ + ...await importOriginal(), skillAPI: { create: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}), @@ -101,6 +103,15 @@ describe('SkillSheet', () => { vi.clearAllMocks(); }); + it.each(['project', 'flocks'])('keeps all built-in definition fields read-only for source %s', (source) => { + render(); + expect(screen.getByText('sheet.readonlyNote')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('my-skill')).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'sheet.edit' })).not.toBeInTheDocument(); + expect(skillAPI.update).not.toHaveBeenCalled(); + }); + describe('Create mode', () => { it('should show "创建 技能" title', () => { render(); diff --git a/webui/src/pages/Skill/SkillSheet.tsx b/webui/src/pages/Skill/SkillSheet.tsx index c1df862c5..19cdc0145 100644 --- a/webui/src/pages/Skill/SkillSheet.tsx +++ b/webui/src/pages/Skill/SkillSheet.tsx @@ -10,7 +10,7 @@ import { lazy, Suspense, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { BookOpen, Lock, Pencil, Eye, Save, Loader2, Trash2 } from 'lucide-react'; -import { skillAPI, Skill } from '@/api/skill'; +import { isSkillDefinitionReadOnly, skillAPI, Skill } from '@/api/skill'; import { useToast } from '@/components/common/Toast'; import EntitySheet from '@/components/common/EntitySheet'; import { buildGuidedCreateGroups } from '@/components/common/GuidedCreatePanel'; @@ -95,8 +95,8 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill const { t } = useTranslation('skill'); const toast = useToast(); const isEdit = !!skill; - // Custom skills (source !== 'project') are editable and deletable - const isUserSkill = isEdit && skill.source !== 'project'; + // User definitions remain editable; bundled/core definitions are always read-only. + const isUserSkill = isEdit && !isSkillDefinitionReadOnly(skill); const isReadonly = isEdit && !isUserSkill; // Strip YAML front matter — name/description are already shown as separate fields @@ -138,7 +138,7 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill }; const handleDelete = async () => { - if (!skill || skill.source === 'project') return; + if (!skill || isSkillDefinitionReadOnly(skill)) return; if (!confirm(t('sheet.deleteConfirm', { name: skill.name }))) return; try { setDeleting(true); @@ -152,7 +152,7 @@ export default function SkillSheet({ skill, onClose, onSaved, onDeleted }: Skill }; const handleSaveContent = async () => { - if (!isEdit || !skill) return; + if (!isEdit || !skill || isReadonly) return; try { setSaving(true); await skillAPI.update(skill.name, formData); diff --git a/webui/src/pages/Skill/index.test.tsx b/webui/src/pages/Skill/index.test.tsx index 32bfeabd2..ea32c5715 100644 --- a/webui/src/pages/Skill/index.test.tsx +++ b/webui/src/pages/Skill/index.test.tsx @@ -1,10 +1,13 @@ import type { ReactNode } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import SkillPage from './index'; +import zhSkill from '@/locales/zh-CN/skill.json'; -const { statusMock, listMock, refreshMock, toastErrorMock, toastSuccessMock, tMock } = vi.hoisted(() => ({ +const { statusMock, listMock, refreshMock, updateGroupMock, getMock, toggleMock, installDepsMock, toastErrorMock, toastSuccessMock, toastWarningMock, tMock } = vi.hoisted(() => ({ + toastWarningMock: vi.fn(), + updateGroupMock: vi.fn(), getMock: vi.fn(), toggleMock: vi.fn(), installDepsMock: vi.fn(), statusMock: vi.fn(), listMock: vi.fn(), refreshMock: vi.fn(), @@ -23,6 +26,7 @@ vi.mock('@/components/common/Toast', () => ({ useToast: () => ({ error: toastErrorMock, success: toastSuccessMock, + warning: toastWarningMock, }), })); @@ -35,8 +39,10 @@ vi.mock('@/api/skill', async () => { status: statusMock, list: listMock, refresh: refreshMock, - get: vi.fn(), - installDeps: vi.fn(), + get: getMock, + updateGroup: updateGroupMock, + toggle: toggleMock, + installDeps: installDepsMock, delete: vi.fn(), }, }; @@ -82,9 +88,187 @@ function makeUiHiddenSkill(name: string) { }; } +vi.mock('./SkillSheet', () => ({ default: () =>
})); + describe('SkillPage', () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); + const labels: Record = { 'table.type': zhSkill.table.type, 'table.name': zhSkill.table.name, + 'table.source': zhSkill.table.source, 'table.enabled': zhSkill.table.enabled, 'table.actions': zhSkill.table.actions }; + tMock.mockImplementation((key: string) => labels[key] ?? key); + vi.spyOn(window, 'confirm').mockReturnValue(true); + updateGroupMock.mockResolvedValue({ data: {} }); + refreshMock.mockResolvedValue({ data: {} }); + }); + + it.each(['project', 'flocks'])('blocks builtin %s group writes and mixed-group operations, preserving the default table', async (source) => { + let inventory = [ + { ...makeSkill('builtin'), source, group: 'Mixed', group_readonly: false }, + { ...makeSkill('custom'), group: 'Mixed' }, + { ...makeUiHiddenSkill('invisible'), group: 'Hidden' }, + ]; + statusMock.mockImplementation(async () => ({ data: inventory })); + listMock.mockImplementation(async () => ({ data: inventory })); + updateGroupMock.mockImplementation(async (name, group) => { + inventory = inventory.map((skill) => skill.name === name ? { ...skill, group } : skill); + return { data: {} }; + }); + render(); + await screen.findByText('builtin'); + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['类型', '名称', '来源', '启用', '操作']); + expect(screen.queryByText('Hidden')).not.toBeInTheDocument(); + const builtin = screen.getByText('builtin').closest('tr')!; + const custom = screen.getByText('custom').closest('tr')!; + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(builtin, { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(builtin).getByRole('button', { name: 'editGroup' })); + expect(toastWarningMock).toHaveBeenCalledTimes(2); + expect(toastWarningMock).toHaveBeenLastCalledWith('pluginGroups:readOnly.builtinSkill'); + expect(updateGroupMock).not.toHaveBeenCalled(); + expect(listMock).not.toHaveBeenCalled(); + expect(within(builtin).getByText('table.builtin')).toBeInTheDocument(); + expect(within(custom).getByText('table.custom')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + fireEvent.keyDown(builtin, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(within(custom).getByRole('button', { name: 'editGroup' })); + expect(getMock).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(updateGroupMock).toHaveBeenCalledExactlyOnceWith('custom', null)); + expect(refreshMock).not.toHaveBeenCalled(); + expect(screen.getAllByRole('switch')).toHaveLength(2); + fireEvent.click(screen.getByRole('button', { name: 'view.cards' })); + const builtinCard = screen.getByText('builtin').closest('article')!; + expect(fireEvent.dragStart(builtinCard, { dataTransfer })).toBe(false); + fireEvent.click(within(builtinCard).getByRole('button', { name: 'editGroup' })); + expect(toastWarningMock).toHaveBeenLastCalledWith('pluginGroups:readOnly.builtinSkill'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(within(screen.getByText('custom').closest('article')!).getByRole('button', { name: 'editGroup' })); + expect(screen.getByRole('combobox')).toHaveValue(''); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(screen.getByText('custom description')).toBeInTheDocument(); + expect(screen.getAllByRole('switch')).toHaveLength(2); + }); + + it('honors the server shipped flag without relying on a source label', async () => { + statusMock.mockResolvedValue({ data: [{ ...makeSkill('shipped'), group: 'System', group_readonly: true }] }); + render(); + await screen.findByText('shipped'); + const row = screen.getByText('shipped').closest('tr')!; + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(row, { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(row).getByRole('button', { name: 'editGroup' })); + expect(toastWarningMock).toHaveBeenLastCalledWith('pluginGroups:readOnly.system'); + expect(updateGroupMock).not.toHaveBeenCalled(); + fireEvent.keyDown(row, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'create' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + }); + + it.each(['move', 'create'])('rechecks selected Skill ownership before %s', async (operation) => { + const custom = { ...makeSkill('custom'), group: 'Ops', group_readonly: false }; + statusMock.mockResolvedValue({ data: [custom] }); + listMock.mockResolvedValue({ data: [{ ...custom, group_readonly: true }] }); + render(); + await screen.findByText('custom'); + if (operation === 'move') fireEvent.keyDown(screen.getByText('custom').closest('tr')!, { key: 'm', altKey: true }); + else { + fireEvent.click(screen.getByRole('button', { name: 'create' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'New' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'custom' } }); + } + fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(updateGroupMock).not.toHaveBeenCalled(); + }); + + it('keeps the original list columns readable and horizontally scrollable on narrow screens', async () => { + statusMock.mockResolvedValue({ data: [makeSkill('long-readable-skill-name')] }); + render(); + await screen.findByText('long-readable-skill-name'); + const table = screen.getByRole('table'); + expect(table).toHaveClass('min-w-[max(100%,40rem)]'); + expect(table.parentElement).toHaveClass('overflow-x-auto'); + expect(table.parentElement).not.toHaveClass('overflow-hidden'); + expect(within(table).getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['类型', '名称', '来源', '启用', '操作']); + expect(within(table).getByRole('button', { name: 'table.edit' })).toBeEnabled(); + }); + + it.each(['create', 'rename'])('checks fresh skill group names before %s writes', async (operation) => { + const skill = { ...makeSkill('custom'), group: 'Ops' }; + statusMock.mockResolvedValue({ data: [skill] }); + listMock.mockResolvedValue({ data: [skill, { ...makeSkill('new'), group: 'Existing' }] }); + render(); + await screen.findByText('custom'); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : 'renameNamed' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Existing' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'custom' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:validation.duplicate')); + expect(updateGroupMock).not.toHaveBeenCalled(); + }); + + it.each(['rename', 'delete'])('prechecks newly discovered readonly group members before %s writes', async (operation) => { + const custom = { ...makeSkill('custom'), group: 'Ops' }; + statusMock.mockResolvedValue({ data: [custom] }); + listMock.mockResolvedValue({ data: [custom, { ...makeSkill('builtin'), source: 'flocks', group: 'Ops' }] }); + render(); + await screen.findByText('custom'); + fireEvent.click(screen.getByRole('button', { name: `${operation}Named` })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Renamed' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(updateGroupMock).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('retains click, enable and dependency-install actions in the alternate view', async () => { + const skill = { ...makeSkill('needs-deps'), eligible: false, missing: ['binary'], install_specs: [{ kind: 'pip', package: 'demo' }] }; + statusMock.mockResolvedValue({ data: [skill] }); + getMock.mockResolvedValue({ data: skill }); + toggleMock.mockResolvedValue({ data: { disabled: true } }); + installDepsMock.mockResolvedValue({ data: { results: [{ success: true }] } }); + render(); + await screen.findByText('needs-deps'); + fireEvent.click(screen.getByRole('button', { name: 'view.cards' })); + const card = screen.getByText('needs-deps').closest('article')!; + fireEvent.click(within(card).getByRole('switch')); + await waitFor(() => expect(toggleMock).toHaveBeenCalledWith('needs-deps')); + fireEvent.click(within(card).getByRole('button', { name: 'eligibility.installDeps' })); + await waitFor(() => expect(installDepsMock).toHaveBeenCalledWith('needs-deps')); + fireEvent.click(within(card).getByRole('button', { name: 'table.edit' })); + await waitFor(() => expect(getMock).toHaveBeenCalledWith('needs-deps')); + expect(await screen.findByTestId('skill-sheet')).toBeInTheDocument(); + }); + + it('loads the original full list before whole-group edits and reports exact partial failures', async () => { + let inventory = [{ ...makeSkill('first'), group: 'Ops' as string | null }, { ...makeSkill('failed'), group: 'Ops' as string | null }]; + statusMock.mockImplementation(async () => ({ data: inventory })); + listMock.mockImplementation(async () => ({ data: inventory })); + updateGroupMock.mockImplementation(async (name, group) => { + if (name === 'failed') throw new Error('write denied'); + inventory = inventory.map((skill) => skill.name === name ? { ...skill, group } : skill); + return { data: {} }; + }); + render(); + await screen.findByText('first'); + fireEvent.click(screen.getByRole('button', { name: 'deleteNamed' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('failed: write denied')); + expect(listMock).toHaveBeenCalledOnce(); + expect(updateGroupMock).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Ops 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'ungrouped 1' })).toBeInTheDocument(); + expect(refreshMock).not.toHaveBeenCalled(); }); it('refreshes the list when the window regains focus', async () => { diff --git a/webui/src/pages/Skill/index.tsx b/webui/src/pages/Skill/index.tsx index 50e2bab9a..4c9cf3d4d 100644 --- a/webui/src/pages/Skill/index.tsx +++ b/webui/src/pages/Skill/index.tsx @@ -19,10 +19,15 @@ import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; import { useToast } from '@/components/common/Toast'; -import { skillAPI, Skill } from '@/api/skill'; +import { isSkillDefinitionReadOnly, skillAPI, Skill } from '@/api/skill'; import { EnabledBadge } from '@/pages/Tool/components/badges'; import SkillSheet from './SkillSheet'; import SkillInstallDialog from './SkillInstallDialog'; +import GroupNav, { useGroupDrag, type GroupDrag } from '@/components/plugin-groups/GroupNav'; +import { deriveGroupNav, matchesGroup, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import { usePluginViewMode } from '@/hooks/usePluginViewMode'; +import PluginViewToggle from '@/components/plugin-groups/PluginViewToggle'; +import PluginGroupButton from '@/components/plugin-groups/PluginGroupButton'; const PAGE_SIZE = 25; @@ -33,7 +38,7 @@ export default function SkillPage() { const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [installingDeps, setInstallingDeps] = useState>({}); - const { error: showErrorToast, success: showSuccessToast } = useToast(); + const { error: showErrorToast, success: showSuccessToast, warning: showWarningToast } = useToast(); const [sheetSkill, setSheetSkill] = useState(null); const [showCreateSheet, setShowCreateSheet] = useState(false); @@ -49,6 +54,7 @@ export default function SkillPage() { // the Tool list page's column-filter behavior. const [enabledFilter, setEnabledFilter] = useState>(new Set()); const [sourceColFilter, setSourceColFilter] = useState>(new Set()); + const [viewMode, setViewMode] = usePluginViewMode('skill', 'list'); // Throttle anchor for `refreshSkillsAndFetch` — visibility/focus listeners // can fire several times in a single second; without this guard the page // would hammer the backend every time the tab gets focus. @@ -103,6 +109,37 @@ export default function SkillPage() { [skills], ); + const [groupSelection, setGroupSelection] = useState(null); + const asGroupItem = (skill: Skill) => ({ + key: skill.name, name: skill.name, group: skill.group, + readOnlyReason: skill.group_readonly ? t('pluginGroups:readOnly.system') + : isSkillDefinitionReadOnly(skill) ? t('pluginGroups:readOnly.builtinSkill') : undefined, + }); + const groupItems = visibleSkills.map(asGroupItem); + const groupDrag = useGroupDrag(groupItems, showWarningToast); + const refreshGroupingInventory = useCallback(async () => { + if (!await fetchSkills({ silent: true })) throw new Error('Skill inventory refresh failed'); + }, [fetchSkills]); + const saveGroup = (name: string, group: string | null) => skillAPI.updateGroup(name, group); + const loadGroupItems = async () => { + const { data } = await skillAPI.list(); + return data.filter((skill) => !skill.ui_hidden && skill.category !== 'system').map(asGroupItem); + }; + const moveGroup = async (key: string, group: string | null) => { + const inventory = await loadGroupItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, refreshGroupingInventory, t); + }; + const createGroup = async (key: string, group: string) => { + const inventory = await loadGroupItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, refreshGroupingInventory, t); + }; + const changeGroup = async (from: string, to: string | null) => { + const inventory = await loadGroupItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => matchesGroup(item.group, from)), to, saveGroup, refreshGroupingInventory, t, true); + }; + const enabledCount = useMemo( () => visibleSkills.filter(s => !s.disabled).length, [visibleSkills], @@ -118,6 +155,7 @@ export default function SkillPage() { const filteredSkills = useMemo(() => { const q = searchQuery.toLowerCase(); return visibleSkills.filter(skill => { + if (!matchesGroup(skill.group, groupSelection)) return false; const isUser = isUserManaged(skill); if (sourceFilter === 'builtin' && isUser) return false; if (sourceFilter === 'custom' && !isUser) return false; @@ -132,12 +170,12 @@ export default function SkillPage() { (skill.description || '').toLowerCase().includes(q) ); }); - }, [visibleSkills, searchQuery, sourceFilter, enabledFilter, sourceColFilter]); + }, [visibleSkills, searchQuery, sourceFilter, enabledFilter, sourceColFilter, groupSelection]); const hasColumnFilter = enabledFilter.size > 0 || sourceColFilter.size > 0; // Reset to first page whenever any filter changes - useEffect(() => { setPage(1); }, [searchQuery, sourceFilter, enabledFilter, sourceColFilter]); + useEffect(() => { setPage(1); }, [searchQuery, sourceFilter, enabledFilter, sourceColFilter, groupSelection]); const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE)); @@ -326,6 +364,7 @@ export default function SkillPage() { {/* 右侧操作:刷新 + 安装 + 创建 */}
+
+
+ changeGroup(name, null)} {...groupDrag} /> +
{visibleSkills.length === 0 ? ( // Truly empty inventory — show big EmptyState with CTAs @@ -387,6 +429,8 @@ export default function SkillPage() { // as a single empty row inside the table so the column headers // and filter funnels stay visible and operable. setSourceColFilter(new Set())} - hasActiveFilter={sourceFilter !== 'all' || hasColumnFilter || !!searchQuery} + hasActiveFilter={sourceFilter !== 'all' || hasColumnFilter || !!searchQuery || groupSelection !== null} onClearAllFilters={() => { setSourceFilter('all'); setEnabledFilter(new Set()); setSourceColFilter(new Set()); setSearchQuery(''); + setGroupSelection(null); }} onSelect={handleSelectSkill} onInstallDeps={handleInstallDeps} @@ -432,6 +477,8 @@ export default function SkillPage() { onPageChange={setPage} /> )} +
+
{sheetSkill && ( ; @@ -499,6 +548,7 @@ interface SkillTableProps { } function SkillTable({ + viewMode = 'list', grouping, skills, selectedSkill, installingDeps, togglingSkills, enabledFilter, onToggleEnabledFilter, onClearEnabledFilter, allSources, sourceColFilter, onToggleSourceColFilter, onClearSourceColFilter, @@ -507,9 +557,45 @@ function SkillTable({ }: SkillTableProps) { const { t } = useTranslation('skill'); + if (viewMode === 'cards') { + return ( + <> +
+ + value === 'enabled' ? t('filter.enabled') : t('filter.disabled')} + /> +
+ {skills.length > 0 ? ( +
+ {skills.map((skill) => ( + + ))} +
+ ) : ( +
+ +
{t('emptyState.noResults')}
+ {hasActiveFilter && } +
+ )} + + ); + } + return ( -
- +
+
@@ -554,6 +640,7 @@ function SkillTable({ onSelect={onSelect} onInstallDeps={onInstallDeps} onToggle={onToggle} + grouping={grouping} /> ))} {skills.length === 0 && ( @@ -584,6 +671,7 @@ function SkillTable({ // ─── SkillRow ───────────────────────────────────────────────────────────────── interface SkillRowProps { + grouping?: GroupDrag; skill: Skill; isSelected: boolean; installingDeps: boolean; @@ -593,7 +681,7 @@ interface SkillRowProps { onToggle: (skill: Skill, e: React.MouseEvent) => void; } -function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onInstallDeps, onToggle }: SkillRowProps) { +function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onInstallDeps, onToggle, grouping }: SkillRowProps) { const { t } = useTranslation('skill'); const isUser = isUserManaged(skill); const hasMissingDeps = skill.eligible === false && (skill.install_specs?.length ?? 0) > 0; @@ -605,6 +693,7 @@ function SkillRow({ skill, isSelected, installingDeps, toggling, onSelect, onIns // buttons — stays independently clickable without nested-button warnings. return ( {/* 启用开关列:控制 skill 是否注入 Agent System Prompt */} -
+
+ {grouping && } {hasMissingDeps && ( + {hasMissingDeps &&
+ + {t('eligibility.missingDepsInline', { list: (skill.missing ?? []).join(', ') })} +
} +
+ {t('table.source')} + {skill.source ?? '-'} +
+
+ {grouping && } + {hasMissingDeps && } + +
+ + ); +} + // ─── Sub-components ─────────────────────────────────────────────────────────── function SkillEnabledControl({ enabled, loading, title, onChange }: { diff --git a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx index 7da6bdc61..3ea4c505c 100644 --- a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx +++ b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx @@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToolDetailDrawer } from './index'; +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: Record) => { diff --git a/webui/src/pages/Tool/ToolGroups.test.tsx b/webui/src/pages/Tool/ToolGroups.test.tsx new file mode 100644 index 000000000..94c06f433 --- /dev/null +++ b/webui/src/pages/Tool/ToolGroups.test.tsx @@ -0,0 +1,633 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createInstance } from 'i18next'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import ToolPage from './index'; +import { __resetToolsResourceForTesting } from '@/hooks/useTools'; +import type { Tool } from '@/api/tool'; +import zhTool from '@/locales/zh-CN/tool.json'; +import enTool from '@/locales/en-US/tool.json'; +import zhGroups from '@/locales/zh-CN/pluginGroups.json'; +import enGroups from '@/locales/en-US/pluginGroups.json'; + +const mocks = vi.hoisted(() => ({ + listPage: vi.fn(), get: vi.fn(), patch: vi.fn(), refresh: vi.fn(), listServices: vi.fn(), listMcp: vi.fn(), + auth: { role: 'admin' }, toastWarning: vi.fn(), updateService: vi.fn(), deleteService: vi.fn(), +})); +vi.mock('@/api/client', () => ({ default: { + get: (url: string, config?: { params?: Record }) => { + if (url === '/api/tools/page') return mocks.listPage(config?.params); + if (url === '/api/mcp') return mocks.listMcp(); + if (url.endsWith('/fixtures')) return Promise.resolve({ data: [] }); + return mocks.get(url); + }, + patch: (...args: unknown[]) => mocks.patch(...args), + post: (...args: unknown[]) => mocks.refresh(...args), +} })); +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: mocks.auth }) })); +vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: mocks.listServices, updateApiService: mocks.updateService, deleteApiService: mocks.deleteService } })); +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ error: vi.fn(), warning: mocks.toastWarning, success: vi.fn() }), +})); + +let inventory: Tool[]; +const nativeRow = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}(?: |$)`) }).closest('[draggable]') as HTMLElement; + +async function mount(language = 'zh-CN') { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ + lng: language, + resources: { + 'zh-CN': { tool: zhTool, pluginGroups: zhGroups }, + 'en-US': { tool: enTool, pluginGroups: enGroups }, + }, + interpolation: { escapeValue: false }, + }); + const result = render(); + await screen.findByRole('button', { name: 'tool_00' }); + return result; +} + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + __resetToolsResourceForTesting(); + mocks.auth.role = 'admin'; + vi.spyOn(window, 'confirm').mockReturnValue(true); + inventory = Array.from({ length: 61 }, (_, index) => ({ + name: `tool_${String(index).padStart(2, '0')}`, + description: `Complete description ${index}`, + category: index % 2 ? 'file' : 'custom', + source: 'plugin_py', source_name: 'Native provider', enabled: true, + parameters: [], requires_confirmation: false, + group: index < 30 ? 'Alpha' : index < 60 ? 'Beta' : null, + })); + mocks.listServices.mockResolvedValue({ data: [] }); + mocks.listMcp.mockResolvedValue({ data: {} }); + mocks.get.mockImplementation(async (url: string) => ({ data: inventory.find((tool) => url === `/api/tools/${tool.name}`) })); + mocks.listPage.mockImplementation(async (params: Record) => { + const eligible = inventory.filter((tool) => (!params.q || tool.name.includes(params.q)) + && (!params.source || params.source.split(',').includes(tool.source)) + && (!params.category || params.category.split(',').includes(tool.category)) + && (!params.enabled || params.enabled.split(',').includes(String(tool.enabled)))); + const group: Record = {}; + eligible.forEach((tool) => { const name = tool.group?.trim() || ''; group[name] = (group[name] ?? 0) + 1; }); + const selected = eligible.filter((tool) => params.group === undefined || (tool.group?.trim() || '') === params.group); + return { data: { + items: selected.slice(params.offset ?? 0, (params.offset ?? 0) + (params.limit ?? 25)), + total: selected.length, offset: params.offset ?? 0, limit: params.limit ?? 25, + facets: { group, category: { custom: 31, file: 30 }, source: { plugin_py: selected.length }, source_groups: {}, source_name: { 'Native provider': selected.length }, enabled: { true: selected.length } }, + } }; + }); + mocks.patch.mockImplementation(async (url: string, request: { group: string | null }) => { + const item = inventory.find((tool) => url === `/api/tools/${encodeURIComponent(tool.name)}`)!; + item.group = request.group; + return { data: item }; + }); +}); + +describe('Tools native group attributes and original view preservation', () => { + it.each([ + ['zh-CN', ['工具名称', '来源', '供应商', '状态', '操作'], '管理'], + ['en-US', ['Tool Name', 'Source', 'Provider', 'Status', 'Actions'], 'Manage'], + ] as const)('keeps all native list columns, grids and actions in %s', async (language, labels, manage) => { + await mount(language); + const header = screen.getByText(labels[0]).parentElement!; + expect(Array.from(header.children).map((cell) => cell.textContent)).toEqual(['', ...labels]); + expect(header.style.gridTemplateColumns).toBe('32px minmax(220px, 3fr) minmax(80px, 1fr) minmax(140px, 1.6fr) minmax(80px, 1fr) minmax(90px, 1fr)'); + const row = nativeRow('tool_00'); + expect(row.children).toHaveLength(6); + expect(row.style.gridTemplateColumns).toBe(header.style.gridTemplateColumns); + expect(within(row).getByText('Native provider')).toBeInTheDocument(); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + expect(screen.getAllByText('Alpha')).toHaveLength(1); + fireEvent.click(within(row).getByRole('button', { name: manage })); + await waitFor(() => expect(mocks.get).toHaveBeenCalledWith('/api/tools/tool_00')); + expect(await screen.findByText('Complete description 0')).toBeInTheDocument(); + }); + + it('uses full facets, scalar group filters, distinct All/Ungrouped cache keys and offset zero', async () => { + await mount(); + const nav = screen.getByRole('complementary', { name: '业务分组' }); + expect(within(nav).getByRole('button', { name: '全部 61' })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '未分组 1' })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'tool_' }))); + fireEvent.click(screen.getByRole('button', { name: '2' })); + await screen.findByRole('button', { name: 'tool_25' }); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByRole('button', { name: 'tool_30' }); + const betaCalls = mocks.listPage.mock.calls.filter(([params]) => params.group === 'Beta'); + expect(betaCalls).toHaveLength(1); + expect(betaCalls[0][0]).toEqual(expect.objectContaining({ group: 'Beta', offset: 0, q: 'tool_', sort_by: 'source', sort_dir: 'asc' })); + fireEvent.click(screen.getByRole('button', { name: '2' })); + await screen.findByRole('button', { name: 'tool_55' }); + const originalText = nativeRow('tool_55').textContent; + const requests = mocks.listPage.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + expect(nativeRow('tool_55').textContent).toBe(originalText); + expect(mocks.listPage).toHaveBeenCalledTimes(requests); + fireEvent.click(screen.getByRole('button', { name: '全部 61' })); + await screen.findByRole('button', { name: 'tool_00' }); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: undefined, offset: 0, q: 'tool_' })); + fireEvent.click(screen.getByRole('button', { name: '未分组 1' })); + await screen.findByRole('button', { name: 'tool_60' }); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: '', offset: 0, q: 'tool_' })); + }); + + it.each(['tool_30', 'no-matching-tools'])('retains Alpha when search facets only show Beta or none (%s)', async (query) => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: query } }); + const selected = await screen.findByRole('button', { name: 'Alpha 0' }); + expect(selected).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('button', { name: 'tool_30' })).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: query })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: '' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + expect(await screen.findByRole('button', { name: 'tool_00' })).toBeInTheDocument(); + }); + + it('retains Alpha when a column filter only matches Beta', async () => { + inventory.forEach((tool) => { tool.enabled = tool.group !== 'Alpha'; }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + const header = screen.getByRole('button', { name: '状态' }).parentElement!; + fireEvent.click(within(header).getAllByRole('button')[1]); + fireEvent.click(screen.getByRole('checkbox', { name: zhTool.table.enabledLabel })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', enabled: 'true' })); + }); + + it('retains the selected group across Local category and zero-match query filters', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: /^tool_00 / }); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.click(screen.getByRole('button', { name: `${zhTool.category.file} (12)` })); + expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.queryByRole('button', { name: /^tool_00 / })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_30' } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: 'tool_30' })); + }); + + it('preserves a selected group after its first page request fails and retries that group', async () => { + await mount(); + const originalPage = mocks.listPage.getMockImplementation()!; + mocks.listPage.mockImplementation((params) => params.group === 'Beta' ? Promise.reject(new Error('group page offline')) : originalPage(params)); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByText('group page offline'); + expect(screen.queryByRole('button', { name: 'tool_00' })).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Beta' })); + mocks.listPage.mockImplementation(originalPage); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: inventory.length, message: '', stages: {}, errors: [] } }); + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await screen.findByRole('button', { name: 'tool_30' }); + expect(screen.getByRole('button', { name: 'Beta 30' })).toHaveAttribute('aria-pressed', 'true'); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Beta', offset: 0 })); + }); + + it('creates a group only after query-independent name validation with a single-field native save', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New native group' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: 'New native group' })); + await screen.findByRole('button', { name: 'New native group 1' }); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, offset: 0, q: undefined })); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it.each(['create', 'rename'])('rejects %s collisions outside filtered facets before any native writes', async (operation) => { + await mount(); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'tool_00' }))); + await screen.findByRole('button', { name: 'Alpha 1' }); + expect(screen.queryByRole('button', { name: 'Beta 30' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? '新建分组' : '重命名分组 Alpha' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'Beta' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(zhGroups.validation.duplicate)); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 1, offset: 0, q: undefined, source: undefined, group: undefined })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it.each(['api', 'mcp'])('keeps the native %s group selected while a child-tool search loads', async (tab) => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'native-a', name: 'Native service A', group: 'Services', enabled: true, status: 'connected', tool_count: 0 }, + { id: 'native-b', name: 'Native service B', group: null, enabled: true, status: 'connected', tool_count: 0 }, + ] }); + mocks.listMcp.mockResolvedValue({ data: { + 'native-a': { status: 'connected', group: 'Services', tools: [], resources: [] }, + 'native-b': { status: 'connected', group: null, tools: [], resources: [] }, + } }); + const originalGet = mocks.get.getMockImplementation()!; + mocks.get.mockImplementation((url: string) => { + if (url.startsWith('/api/mcp/catalog/')) return Promise.resolve({ data: url.endsWith('/categories') ? {} : [] }); + return originalGet(url); + }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: tab === 'api' ? /^API 集成/ : /^MCP 服务/ })); + const groupButton = await screen.findByRole('button', { name: 'Services 1' }); + fireEvent.click(groupButton); + await waitFor(() => expect(screen.getByRole('button', { name: 'Services 1' })).toHaveAttribute('aria-pressed', 'true')); + const originalPage = mocks.listPage.getMockImplementation()!; + let resolvePage!: (result: unknown) => void; + mocks.listPage.mockImplementation((params) => params.q ? new Promise((resolve) => { resolvePage = resolve; }) : originalPage(params)); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'native' } }); + await waitFor(() => expect(resolvePage).toBeDefined()); + expect(screen.getByRole('button', { name: 'Services 1' })).toBe(groupButton); + expect(groupButton).toHaveAttribute('aria-pressed', 'true'); + resolvePage(await originalPage({ q: 'native', source: tab, offset: 0, limit: 25 })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Services 1' })).toHaveAttribute('aria-pressed', 'true')); + expect(screen.queryByText(tab === 'api' ? 'Native service B' : 'native-b')).not.toBeInTheDocument(); + }); + + it('counts all ten API services, excludes devices, and stays synchronized after native changes', async () => { + let services = Array.from({ length: 26 }, (_, index) => ({ + id: `api-${index}`, name: `API service ${index}`, enabled: index < 7 || index >= 10, + integration_type: index >= 10 ? 'device' : undefined, group: index < 5 ? 'Ops' : null, + status: 'unknown', tool_count: 0, builtin: false, verify_ssl: false, + })); + mocks.listServices.mockImplementation(async () => ({ data: services })); + mocks.updateService.mockImplementation(async (id, body) => { + services = services.map((service) => service.id === id ? { ...service, ...body } : service); + return { data: services.find((service) => service.id === id) }; + }); + mocks.deleteService.mockImplementation(async (id) => { + services = services.filter((service) => service.id !== id); + return { data: {} }; + }); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: 61, message: '', stages: {}, errors: [] } }); + await mount(); + fireEvent.click(await screen.findByRole('button', { name: /^API 集成\s+10$/ })); + await screen.findByText('API service 9'); + const nav = screen.getByRole('complementary'); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + expect(screen.queryByText('API service 10')).not.toBeInTheDocument(); + const row = (index: number) => screen.getByText(`API service ${index}`).closest('[draggable]') as HTMLElement; + fireEvent.click(within(row(0)).getByTitle(zhTool.detail.disableServer)); + await waitFor(() => expect(mocks.updateService).toHaveBeenCalledWith('api-0', { enabled: false })); + await waitFor(() => expect(within(row(0)).getByRole('button', { name: zhTool.detail.enableServer })).toBeEnabled()); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + fireEvent.click(within(row(7)).getByRole('button', { name: zhTool.detail.enableServer })); + await waitFor(() => expect(mocks.updateService).toHaveBeenCalledWith('api-7', { enabled: true })); + await waitFor(() => expect(within(row(7)).getByTitle(zhTool.detail.disableServer)).toBeEnabled()); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + fireEvent.click(within(row(9)).getByTitle(zhTool.button.delete)); + await waitFor(() => expect(screen.getByRole('button', { name: /^API 集成\s+9$/ })).toBeInTheDocument()); + await waitFor(() => expect(within(nav).getByRole('button', { name: '全部 9' })).toBeInTheDocument()); + services.push({ ...services[0], id: 'added', name: 'Added API' }); + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await screen.findByText('Added API'); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + fireEvent.click(within(nav).getByRole('button', { name: 'Ops 6' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'zz-no-match-counts' } }); + await screen.findByText(zhTool.api.noTools); + expect(screen.getByRole('button', { name: /^API 集成\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + }); + + it('counts eleven MCP services including zero-tool and inactive rows, without changing tool totals', async () => { + const statuses = ['connected', 'connected', 'connected', 'connected', 'connected', 'connected', 'connected', 'error', 'disabled', 'disabled', 'disconnected']; + let servers = Object.fromEntries(statuses.map((status, index) => [index === 6 ? 'gridinsoft' : `mcp-${index}`, { + status, tools_count: index < 6 ? [6, 6, 6, 1, 15, 15][index] : 0, tools: [], resources: [], group: 'MCP group', + }])); + const catalog = Object.keys(servers).filter((id) => id !== 'mcp-10').map((id) => ({ + id, name: id === 'gridinsoft' ? 'GridinSoft' : id, description: 'Native catalog', category: 'intel', tool_type: 'mcp', + github: '', language: 'python', license: 'MIT', stars: 0, transport: 'stdio', install: {}, env_vars: {}, + system_deps: [], tags: [], official: false, requires_auth: false, group: 'MCP group', + })); + mocks.listMcp.mockImplementation(async () => ({ data: servers })); + mocks.get.mockImplementation(async (url) => ({ data: url.endsWith('/entries') ? catalog : url.endsWith('/categories') ? {} : [] })); + const originalPage = mocks.listPage.getMockImplementation()!; + mocks.listPage.mockImplementation((params) => params.source === 'mcp' ? Promise.resolve({ data: { + items: [], total: params.q ? 0 : 49, offset: 0, limit: 25, + facets: { group: {}, category: {}, source: { mcp: params.q ? 0 : 49 }, source_groups: { mcp: params.q ? 0 : 6 }, source_name: {}, enabled: { true: 46, false: 3 } }, + } }) : originalPage(params)); + mocks.refresh.mockResolvedValue({ data: { status: 'success', tool_count: 49, message: '', stages: {}, errors: [] } }); + await mount(); + fireEvent.click(await screen.findByRole('button', { name: /^MCP 服务\s+11$/ })); + await screen.findByText('GridinSoft'); + const nav = screen.getByRole('complementary'); + expect(within(nav).getByRole('button', { name: '全部 11' })).toBeInTheDocument(); + const header = screen.getByRole('heading', { name: zhTool.pageTitle }).parentElement!; + expect(header).toHaveTextContent(`46 ${zhTool.statusBadge.active}`); + expect(header).toHaveTextContent(`3 ${zhTool.statusBadge.inactive}`); + fireEvent.click(within(nav).getByRole('button', { name: 'MCP group 11' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'GridinSoft' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'GridinSoft' }))); + expect(screen.getByRole('button', { name: /^MCP 服务\s+11$/ })).toBeInTheDocument(); + // A removed catalog-backed configuration still has its original catalog row. + delete servers['mcp-9']; + fireEvent.click(screen.getByTitle(zhTool.button.refreshList)); + await waitFor(() => expect(screen.getByTitle(zhTool.button.refreshDone)).toBeEnabled()); + expect(screen.getByRole('button', { name: /^MCP 服务\s+11$/ })).toBeInTheDocument(); + delete servers['mcp-10']; + fireEvent.click(screen.getByTitle(zhTool.button.refreshDone)); + await waitFor(() => expect(screen.getByRole('button', { name: /^MCP 服务\s+10$/ })).toBeInTheDocument()); + await waitFor(() => expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument()); + mocks.listMcp.mockRejectedValue(new Error('native inventory offline')); + await waitFor(() => expect(screen.getByTitle(zhTool.button.refreshDone)).toBeEnabled()); + fireEvent.click(screen.getByTitle(zhTool.button.refreshDone)); + await screen.findByText('native inventory offline'); + expect(screen.getByRole('button', { name: /^MCP 服务\s+10$/ })).toBeInTheDocument(); + expect(within(nav).getByRole('button', { name: '全部 10' })).toBeInTheDocument(); + }); + + it('filters API service names with no child tools and keeps the business group when search is cleared', async () => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'qa-alpha', name: 'QA Alpha', description: 'PR765 Alpha fixture', group: 'E2E', enabled: false, tool_count: 0 }, + { id: 'qa-beta', name: 'QA Beta', description: 'PR765 Beta fixture', group: 'E2E', enabled: false, tool_count: 0 }, + { id: 'other-alpha', name: 'Other Alpha', group: 'Other', enabled: false, tool_count: 0 }, + ] }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^API 集成/ })); + await screen.findByText('QA Beta'); + fireEvent.click(screen.getByRole('button', { name: 'E2E 2' })); + const search = screen.getByPlaceholderText(zhTool.search.placeholder); + fireEvent.change(search, { target: { value: 'Alpha' } }); + expect(screen.getByText('QA Alpha')).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('Other Alpha')).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ source: 'api', q: 'Alpha' }))); + fireEvent.change(search, { target: { value: 'zz-no-match-765' } }); + expect(await screen.findByText(zhTool.api.noTools)).toBeInTheDocument(); + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'E2E 2' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.change(search, { target: { value: '' } }); + expect(screen.getByText('QA Alpha')).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Other Alpha')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'E2E 2' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('matches API child keywords through complete service facets beyond the first 25 tools', async () => { + mocks.listServices.mockResolvedValue({ data: [ + { id: 'alpha_v1', name: 'QA Alpha', group: 'Services', enabled: true, tool_count: 25 }, + { id: 'beta_v2', name: 'QA Beta', group: 'Services', enabled: true, tool_count: 1 }, + { id: 'gamma_v1', name: 'QA Gamma', group: 'Services', enabled: true, tool_count: 0 }, + ] }); + const toolRows = Array.from({ length: 26 }, (_, index) => ({ ...inventory[0], + name: `child_${index}`, description: 'needle keyword', source: 'api' as const, + source_name: index < 25 ? 'alpha_v1' : 'beta_v2' })); + const apiPage = (params: Record) => { + const matches = toolRows.filter((tool) => !params.q || `${tool.name} ${tool.description} ${tool.source_name}`.toLowerCase().includes(params.q.toLowerCase())); + const sourceNames: Record = {}; + matches.forEach((tool) => { sourceNames[tool.source_name] = (sourceNames[tool.source_name] ?? 0) + 1; }); + return { data: { items: matches.slice(0, 25), total: matches.length, offset: 0, limit: 25, + facets: { group: {}, category: {}, source: { api: matches.length }, source_groups: { api: Object.keys(sourceNames).length }, source_name: sourceNames, enabled: {} } } }; + }; + const originalPage = mocks.listPage.getMockImplementation()!; + let resolveSearch!: (result: ReturnType) => void; + mocks.listPage.mockImplementation((params) => { + if (params.source !== 'api') return originalPage(params); + if (params.q === 'needle') return new Promise((resolve) => { resolveSearch = resolve; }); + return Promise.resolve(apiPage(params)); + }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: /^API 集成/ })); + await screen.findByText('QA Beta'); + const search = screen.getByPlaceholderText(zhTool.search.placeholder); + fireEvent.change(search, { target: { value: 'needle' } }); + // The old unfiltered facets must not leak into the debounce/loading window. + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText(zhTool.api.noTools)).not.toBeInTheDocument(); + await waitFor(() => expect(resolveSearch).toBeDefined()); + expect(screen.getByRole('complementary').parentElement).toHaveAttribute('aria-busy', 'true'); + resolveSearch(apiPage({ q: 'needle' })); + expect(await screen.findByText('QA Alpha')).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('QA Gamma')).not.toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ source: 'api', q: 'needle', offset: 0, limit: 25 })); + expect(mocks.get).not.toHaveBeenCalled(); + fireEvent.change(search, { target: { value: 'zz-no-match-765' } }); + expect(screen.queryByText('QA Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(await screen.findByText(zhTool.api.noTools)).toBeInTheDocument(); + }); + + it.each([ + ['all', 'list'], ['all', 'cards'], ['local', 'list'], ['local', 'cards'], + ])('offers an explicit group action in the %s tab %s view without opening tool details', async (tab, viewMode) => { + await mount(); + if (tab === 'local') { + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: /^tool_00 / }); + } + if (viewMode === 'cards') fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + const action = within(nativeRow('tool_00')).getByRole('button', { name: zhGroups.editGroup }); + expect(action).toBeEnabled(); + fireEvent.click(action); + expect(screen.getByRole('combobox')).toHaveValue('Alpha'); + expect(mocks.get).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Beta' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: 'Beta' })); + expect(mocks.get).toHaveBeenCalledTimes(1); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('moves one native row with the keyboard equivalent and only reloads metadata', async () => { + await mount(); + fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Beta' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: 'Beta' })); + await screen.findByRole('button', { name: 'Beta 31' }); + expect(mocks.refresh).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('loads every matching native page before confirming and renaming, not just 25 rows', async () => { + inventory = Array.from({ length: 205 }, (_, index) => ({ ...inventory[0], name: `tool_${String(index).padStart(2, '0')}`, group: 'Alpha' })); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '重命名分组 Alpha' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'Renamed' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledTimes(205)); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 0, limit: 200 })); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 200, limit: 200 })); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('205')); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('tool_204')); + expect(mocks.patch).toHaveBeenLastCalledWith('/api/tools/tool_204', { group: 'Renamed' }); + expect(mocks.refresh).not.toHaveBeenCalled(); + }); + + it('locks shipped rows even when their source is not builtin, while custom members remain movable', async () => { + inventory[0].group_readonly = true; + inventory[1].group_readonly = false; + await mount(); + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(nativeRow('tool_00'), { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(nativeRow('tool_00')).getByRole('button', { name: zhGroups.editGroup })); + expect(mocks.toastWarning).toHaveBeenCalledTimes(2); + expect(mocks.toastWarning).toHaveBeenLastCalledWith(zhGroups.readOnly.system); + expect(mocks.get).not.toHaveBeenCalled(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '重命名分组 Alpha' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '删除分组 Alpha' })).toBeDisabled(); + fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + expect(within(screen.getByRole('combobox')).queryByRole('option', { name: 'tool_00' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + fireEvent.keyDown(nativeRow('tool_01'), { key: 'm', altKey: true }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_01', { group: null })); + }); + + it.each(['move', 'create'])('checks fresh tool ownership before a selected-row %s', async (operation) => { + await mount(); + mocks.get.mockResolvedValue({ data: { ...inventory[0], group_readonly: true } }); + if (operation === 'move') fireEvent.keyDown(nativeRow('tool_00'), { key: 'm', altKey: true }); + else { + fireEvent.click(screen.getByRole('button', { name: '新建分组' })); + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'tool_00' } }); + } + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(zhGroups.errors.readOnlyMembers.replace('{{items}}', 'tool_00'))); + expect(mocks.patch).not.toHaveBeenCalled(); + }); + + it.each(['rename', 'delete'])('finds a locked tool beyond page 200 before whole-group %s', async (operation) => { + inventory = Array.from({ length: 205 }, (_, index) => ({ ...inventory[0], name: `tool_${String(index).padStart(2, '0')}`, group: 'Alpha', group_readonly: index === 204 })); + await mount(); + fireEvent.click(screen.getByRole('button', { name: operation === 'rename' ? '重命名分组 Alpha' : '删除分组 Alpha' })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_204')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', offset: 200, limit: 200 })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it.each([ + ['rename', 'search'], ['delete', 'search'], ['rename', 'local'], ['delete', 'local'], + ])('audits hidden locked members before %s under the %s filter', async (operation, filter) => { + inventory[29].group_readonly = true; + inventory[29].source = 'api'; + await mount(); + if (filter === 'search') { + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await screen.findByRole('button', { name: 'Alpha 1' }); + } else { + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: 'Alpha 29' }); + } + fireEvent.click(screen.getByRole('button', { name: operation === 'rename' ? '重命名分组 Alpha' : '删除分组 Alpha' })); + if (operation === 'rename') { + fireEvent.change(screen.getByRole('textbox', { name: '分组名称' }), { target: { value: 'New' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + } + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_29')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ group: 'Alpha', q: undefined, source: undefined, limit: 200 })); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('deletes with one explicit filtered-scope confirmation and preserves a still-existing selection', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 30' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 30' })).toHaveAttribute('aria-pressed', 'true')); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await screen.findByRole('button', { name: 'Alpha 1' }); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledExactlyOnceWith('/api/tools/tool_00', { group: null })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Alpha 0' })).toHaveAttribute('aria-pressed', 'true')); + expect(window.confirm).toHaveBeenCalledTimes(1); + const confirmation = vi.mocked(window.confirm).mock.calls[0][0]!; + expect(confirmation).toContain('tool_00'); + expect(confirmation).not.toContain('tool_01'); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, group: undefined, q: undefined })); + expect(inventory[1].group).toBe('Alpha'); + }); + + it('clears a filtered selection only after the unfiltered summary confirms the last member left', async () => { + inventory.forEach((tool, index) => { if (index > 0 && tool.group === 'Alpha') tool.group = 'Beta'; }); + await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 1' })); + fireEvent.change(screen.getByPlaceholderText(zhTool.search.placeholder), { target: { value: 'tool_00' } }); + await waitFor(() => expect(mocks.listPage).toHaveBeenLastCalledWith(expect.objectContaining({ group: 'Alpha', q: 'tool_00' }))); + await screen.findByRole('button', { name: 'Alpha 1' }); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + await waitFor(() => expect(mocks.patch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByRole('button', { name: '全部 1' })).toHaveAttribute('aria-pressed', 'true')); + expect(mocks.listPage).toHaveBeenCalledWith(expect.objectContaining({ limit: 1, group: undefined, q: undefined })); + }); + + it('cancels the single native delete confirmation without a preview dialog or writes', async () => { + vi.mocked(window.confirm).mockReturnValue(false); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + await waitFor(() => expect(window.confirm).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mocks.patch).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Alpha 30' })).toBeInTheDocument(); + }); + + it('reports actual partial failures and recomputes both group counts', async () => { + const original = mocks.patch.getMockImplementation()!; + mocks.patch.mockImplementation((url, body) => url === '/api/tools/tool_02' ? Promise.reject(new Error('read-only filesystem')) : original(url, body)); + await mount(); + fireEvent.click(screen.getByRole('button', { name: '删除分组 Alpha' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('tool_02: read-only filesystem')); + expect(mocks.patch).toHaveBeenCalledTimes(30); + expect(screen.getByRole('button', { name: 'Alpha 1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '未分组 30' })).toBeInTheDocument(); + expect(inventory[0].group).toBeNull(); + expect(inventory[2].group).toBe('Alpha'); + }); + + it('permits filtering but gates native settings writes for non-admins', async () => { + mocks.auth.role = 'member'; + await mount(); + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(nativeRow('tool_00'), { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(nativeRow('tool_00')).getByRole('button', { name: zhGroups.editGroup })); + expect(mocks.toastWarning).toHaveBeenLastCalledWith(zhGroups.readOnly.admin); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '新建分组' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '重命名分组 Alpha' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Beta 30' })); + await screen.findByRole('button', { name: 'tool_30' }); + expect(mocks.patch).not.toHaveBeenCalled(); + }); + + it('keeps Local category filtering and tab-specific view preferences', async () => { + await mount(); + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + fireEvent.click(screen.getByRole('button', { name: /^本地工具/ })); + await screen.findByRole('button', { name: /^tool_00 / }); + expect(screen.getByRole('button', { name: '列表视图' })).toHaveAttribute('aria-pressed', 'true'); + const requests = mocks.listPage.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: `${zhTool.category.file} (12)` })); + expect(screen.queryByRole('button', { name: /^tool_00 / })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^tool_01 / })).toBeInTheDocument(); + expect(mocks.listPage).toHaveBeenCalledTimes(requests); + const text = nativeRow('tool_01').textContent; + fireEvent.click(screen.getByRole('button', { name: '卡片视图' })); + expect(nativeRow('tool_01').textContent).toBe(text); + fireEvent.click(screen.getByRole('button', { name: /^全量工具/ })); + await screen.findByRole('button', { name: 'tool_00' }); + expect(screen.getByRole('button', { name: '卡片视图' })).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx index aad856925..1433192fa 100644 --- a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx +++ b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx @@ -1,3 +1,4 @@ +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -72,6 +73,8 @@ vi.mock('@/api/tool', () => ({ }, })); +vi.mock('@/api/mcp', () => ({ mcpAPI: { list: vi.fn(() => Promise.resolve({ data: {} })) } })); + vi.mock('@/api/provider', () => ({ providerAPI: { listApiServices: vi.fn(() => Promise.resolve({ data: [] })), diff --git a/webui/src/pages/Tool/components/APITabContent.test.tsx b/webui/src/pages/Tool/components/APITabContent.test.tsx index b6329a742..254e484f8 100644 --- a/webui/src/pages/Tool/components/APITabContent.test.tsx +++ b/webui/src/pages/Tool/components/APITabContent.test.tsx @@ -1,10 +1,13 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import APITabContent from './APITabContent'; -const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI } = vi.hoisted(() => ({ +vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => ({ user: { role: 'admin' } }) })); + +const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI, toastWarning } = vi.hoisted(() => ({ + toastWarning: vi.fn(), apiDetailProps: vi.fn(), listAllToolPages: vi.fn(), mcpAPI: { @@ -19,6 +22,7 @@ const { apiDetailProps, listAllToolPages, mcpAPI, providerAPI } = vi.hoisted(() })); vi.mock('@/api/provider', () => ({ providerAPI })); +vi.mock('@/components/common/Toast', () => ({ useToast: () => ({ warning: toastWarning }) })); vi.mock('@/api/mcp', () => ({ mcpAPI })); vi.mock('@/api/tool', () => ({ listAllToolPages })); @@ -56,6 +60,262 @@ describe('APITabContent', () => { listAllToolPages.mockResolvedValue([]); }); + it.each([' Alpha ', 'QA-ALPHA_V2', 'unique English', '独特描述'])('searches service metadata independently of child tools (%s)', async (searchQuery) => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'qa-alpha_v2', name: 'QA Alpha', description: 'unique English description', description_cn: '独特描述', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'qa-beta_v1', name: 'QA Beta', description: 'Other description', group: 'Ops', enabled: false, tool_count: 0 }, + ] }); + render(); + expect(await screen.findByText('QA Alpha')).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(listAllToolPages).not.toHaveBeenCalled(); + }); + + it('composes search with group selection, shows no matches, and restores rows without closing details', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'qa-alpha', name: 'QA Alpha', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'qa-beta', name: 'QA Beta', group: 'Ops', enabled: false, tool_count: 0 }, + { id: 'other-alpha', name: 'Outside Alpha', group: 'Other', enabled: false, tool_count: 0 }, + ] }); + listAllToolPages.mockResolvedValue([{ name: 'complete-drawer-tool' }]); + const props = { tools: [], matchingToolServices: {}, onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await screen.findByText('QA Alpha'); + fireEvent.click(screen.getByRole('button', { name: 'Ops 2' })); + fireEvent.click(screen.getByText('QA Alpha', { selector: 'span' }).closest('button')!); + expect(await screen.findByText('complete-drawer-tool')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('Outside Alpha')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.getByText('complete-drawer-tool')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Outside Alpha')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + expect(listAllToolPages).toHaveBeenCalledExactlyOnceWith({ source: 'api', sourceName: 'qa-alpha', sortBy: 'name', sortDir: 'asc' }); + }); + + it('uses complete tool facets, not the current page, and waits for pending tool matches', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'alpha_v1', name: 'QA Alpha', enabled: true, tool_count: 25 }, + { id: 'beta_v2', name: 'QA Beta', enabled: true, tool_count: 1 }, + { id: 'beta_v1', name: 'Other Beta version', enabled: true, tool_count: 0 }, + ] }); + const tools = Array.from({ length: 25 }, (_, index) => ({ name: `needle_${index}`, description: 'needle', + source: 'api' as const, source_name: 'alpha_v1', category: 'custom', parameters: [], enabled: true, requires_confirmation: false })); + const props = { tools, searchQuery: 'needle', onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await waitFor(() => expect(providerAPI.listApiServices).toHaveBeenCalledOnce()); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.queryByText('QA Beta')).not.toBeInTheDocument(); + expect(screen.queryByText('api.noTools')).not.toBeInTheDocument(); + expect(screen.getByText('loading')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('QA Alpha', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('QA Beta')).toBeInTheDocument(); + expect(screen.queryByText('Other Beta version')).not.toBeInTheDocument(); + // A failed/missing summary must not fall back to matching the 25-row sample. + rerender(); + expect(screen.queryByText('QA Alpha', { selector: 'span' })).not.toBeInTheDocument(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(listAllToolPages).not.toHaveBeenCalled(); + }); + + it('keeps catalog keyword search independent of service and child-tool matches', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [{ id: 'service', name: 'Native service', enabled: false, tool_count: 0 }] }); + const props = { tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn(), + catalogEntries: [{ id: 'catalog', name: 'Catalog API', description: 'Catalog description', category: 'intel', tool_type: 'api' as const, + github: '', language: 'python', license: 'MIT', stars: 1, transport: 'stdio', install: {}, env_vars: {}, + system_deps: [], tags: ['catalog-keyword'], official: false, requires_auth: false }], + catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + expect(await screen.findByText('Catalog API')).toBeInTheDocument(); + expect(screen.queryByText('Native service')).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByText('Catalog API')).not.toBeInTheDocument(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(mcpAPI.catalogInstall).not.toHaveBeenCalled(); + }); + + it('reports the full API inventory count across filters and native reloads', async () => { + const service = { id: 'api', name: 'API service', enabled: false, tool_count: 0, group: 'Ops' }; + providerAPI.listApiServices.mockResolvedValue({ data: [service, { ...service, id: 'device', integration_type: 'device' }] }); + const onServiceCountChange = vi.fn(); + const props = { tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn(), onServiceCountChange, + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn() }; + const { rerender } = render(); + await waitFor(() => expect(onServiceCountChange).toHaveBeenLastCalledWith(1)); + fireEvent.click(screen.getByRole('button', { name: 'Ops 1' })); + rerender(); + expect(screen.getByText('api.noTools')).toBeInTheDocument(); + expect(onServiceCountChange).toHaveBeenLastCalledWith(1); + providerAPI.listApiServices.mockResolvedValue({ data: [service, { ...service, id: 'added' }] }); + rerender(); + await waitFor(() => expect(onServiceCountChange).toHaveBeenLastCalledWith(2)); + expect(screen.getByRole('button', { name: 'Ops 2' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('keeps versioned service identities, full group counts, card fields/actions and independent drawer tools', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'service-a__v9_2', name: 'Service A', version: '9.2', description: 'Service A description', enabled: true, status: 'connected', tool_count: 40, latency_ms: 12, verify_ssl: false, group: 'Alpha' }, + { id: 'service-b', name: 'Service B', enabled: false, status: 'disabled', tool_count: 0, verify_ssl: true, group: null, builtin: true, group_readonly: false }, + { id: 'device-hidden', name: 'Not an API row', integration_type: 'device', enabled: true, tool_count: 10, verify_ssl: false }, + ] }); + listAllToolPages.mockResolvedValue([{ name: 'ungrouped-child-tool', source: 'api', group: null }]); + const props = { + tools: [], onSelectTool: vi.fn(), onRefreshTools: vi.fn().mockResolvedValue(undefined), + catalogEntries: [], catalogCategories: {}, catalogLoading: false, configuredIds: new Set(), onConfiguredChange: vi.fn(), + }; + const { rerender } = render(); + await screen.findByText('Service A'); + await waitFor(() => expect(screen.getByRole('button', { name: 'all 2' })).toBeInTheDocument()); + expect(screen.queryByText('Not an API row')).not.toBeInTheDocument(); + const nativeRow = screen.getByText('Service A').closest('[draggable]') as HTMLElement; + expect(nativeRow.children).toHaveLength(6); + const originalText = nativeRow.textContent; + expect(within(nativeRow).getByText('v9.2')).toBeInTheDocument(); + expect(within(nativeRow).getByText('40')).toBeInTheDocument(); + expect(within(nativeRow).getByText('12ms')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha 1' })); + expect(screen.queryByText('Service B')).not.toBeInTheDocument(); + rerender(); + const card = screen.getByText('Service A').closest('[draggable]') as HTMLElement; + expect(card.textContent).toBe(originalText); + fireEvent.click(within(card).getByRole('button', { name: 'mcp.manage' })); + expect(await screen.findByText('ungrouped-child-tool')).toBeInTheDocument(); + expect(listAllToolPages).toHaveBeenCalledWith({ source: 'api', sourceName: 'service-a__v9_2', sortBy: 'name', sortDir: 'asc' }); + fireEvent.click(screen.getByRole('button', { name: 'ungrouped 1' })); + expect(screen.getByText('Service B')).toBeInTheDocument(); + expect(screen.getByText('ungrouped-child-tool')).toBeInTheDocument(); + + const ungroupedRow = screen.getByText('Service B').closest('[draggable]') as HTMLElement; + fireEvent.click(within(ungroupedRow).getByRole('button', { name: 'editGroup' })); + expect(screen.getByText('ungrouped-child-tool')).toBeInTheDocument(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Alpha' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(providerAPI.updateApiService).toHaveBeenCalledWith('service-b', { group: 'Alpha' })); + await waitFor(() => expect(providerAPI.listApiServices).toHaveBeenCalledWith({ force: true })); + expect(props.onRefreshTools).not.toHaveBeenCalled(); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + }); + + it('locks definition-only API rows but leaves builtin configured instances editable', async () => { + providerAPI.listApiServices.mockResolvedValue({ data: [ + { id: 'definition', name: 'System API definition', builtin: true, group_readonly: true, group: 'Mixed', enabled: false, tool_count: 0 }, + { id: 'configured', name: 'Configured API', builtin: true, group_readonly: false, group: 'Mixed', enabled: false, tool_count: 0 }, + ] }); + render(); + await screen.findByText('System API definition'); + const definition = screen.getByText('System API definition').closest('[draggable]')!; + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(definition, { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(definition as HTMLElement).getByRole('button', { name: 'editGroup' })); + expect(toastWarning).toHaveBeenCalledTimes(2); + expect(toastWarning).toHaveBeenLastCalledWith('pluginGroups:readOnly.system'); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + expect(providerAPI.listApiServices).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + fireEvent.keyDown(definition, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + expect(within(screen.getByRole('combobox')).queryByRole('option', { name: /System API definition/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + fireEvent.click(within(screen.getByText('Configured API').closest('[draggable]') as HTMLElement).getByRole('button', { name: 'editGroup' })); + expect(apiDetailProps).not.toHaveBeenCalled(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(providerAPI.updateApiService).toHaveBeenCalledExactlyOnceWith('configured', { group: null })); + }); + + it.each(['move', 'create', 'rename', 'delete'])('rechecks fresh definition-only API rows before %s', async (operation) => { + const service = { id: 'service', name: 'Native API', group: 'Ops', group_readonly: false, enabled: false, tool_count: 0 }; + providerAPI.listApiServices.mockResolvedValueOnce({ data: [service] }).mockResolvedValue({ data: [{ ...service, group_readonly: true }] }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + await screen.findByText('Native API'); + if (operation === 'move') fireEvent.keyDown(screen.getByText('Native API').closest('[draggable]')!, { key: 'm', altKey: true }); + else fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : `${operation}Named` })); + if (operation === 'create' || operation === 'rename') fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'service' } }); + if (operation !== 'delete') fireEvent.click(screen.getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:errors.readOnlyMembers')); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('keeps unconfigured catalog attributes read-only, with no artificial service creation', async () => { + render(); + await screen.findByText('Catalog API'); + const row = screen.getByText('Catalog API').closest('[draggable]') as HTMLElement; + const dataTransfer = { setData: vi.fn() }; + expect(fireEvent.dragStart(row, { dataTransfer })).toBe(false); + expect(dataTransfer.setData).not.toHaveBeenCalled(); + fireEvent.click(within(row).getByRole('button', { name: 'editGroup' })); + expect(toastWarning).toHaveBeenLastCalledWith('pluginGroups:readOnly.system'); + fireEvent.keyDown(row, { key: 'm', altKey: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'renameNamed' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'deleteNamed' })).toBeDisabled(); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + expect(mcpAPI.catalogInstall).not.toHaveBeenCalled(); + }); + + it('keeps the native rows and selected group when reloading after an action fails', async () => { + const response = { data: [{ id: 'service', name: 'Native API', group: 'Ops', enabled: true, tool_count: 0 }] }; + providerAPI.listApiServices.mockResolvedValueOnce(response).mockRejectedValueOnce(new Error('API list offline')).mockResolvedValue(response); + providerAPI.updateApiService.mockResolvedValue({ data: {} }); + render(); + await screen.findByText('Native API'); + fireEvent.click(screen.getByRole('button', { name: 'Ops 1' })); + fireEvent.click(screen.getByTitle('detail.disableServer')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('API list offline')); + expect(screen.getByText('Native API')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Ops 1' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'button.retry' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(providerAPI.updateApiService).toHaveBeenCalledExactlyOnceWith('service', { enabled: false }); + }); + + it.each(['create', 'rename'])('rejects fresh native API name collisions before %s writes', async (operation) => { + const service = { id: 'service__v1', name: 'Native API', group: 'Ops', enabled: true, tool_count: 0 }; + providerAPI.listApiServices.mockResolvedValueOnce({ data: [service] }).mockResolvedValue({ data: [service, { ...service, id: 'other__v2', group: 'Existing' }] }); + render(); + await screen.findByText('Native API'); + fireEvent.click(screen.getByRole('button', { name: operation === 'create' ? 'create' : 'renameNamed' })); + fireEvent.change(screen.getByRole('textbox', { name: 'dialog.name' }), { target: { value: 'Existing' } }); + if (operation === 'create') fireEvent.change(screen.getByRole('combobox'), { target: { value: 'service__v1' } }); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'save' })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('pluginGroups:validation.duplicate')); + expect(providerAPI.listApiServices).toHaveBeenLastCalledWith({ force: true }); + expect(providerAPI.updateApiService).not.toHaveBeenCalled(); + }); + it('loads the complete tool list when a service detail drawer opens', async () => { const user = userEvent.setup(); providerAPI.listApiServices.mockResolvedValue({ diff --git a/webui/src/pages/Tool/components/APITabContent.tsx b/webui/src/pages/Tool/components/APITabContent.tsx index 13f54e3c7..cd5b9fe57 100644 --- a/webui/src/pages/Tool/components/APITabContent.tsx +++ b/webui/src/pages/Tool/components/APITabContent.tsx @@ -8,10 +8,16 @@ import { providerAPI } from '@/api/provider'; import { listAllToolPages, type Tool } from '@/api/tool'; import type { APIServiceSummary, MCPCatalogCategory, MCPCatalogEntry } from '@/types'; import EmptyState from '@/components/common/EmptyState'; +import { useToast } from '@/components/common/Toast'; +import PluginGroupButton from '@/components/plugin-groups/PluginGroupButton'; import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getCatalogDescription } from '@/utils/mcpCatalog'; import { APIServiceDetailPanel } from './ServiceDetailPanel'; import { SERVICE_TAB_GRID_COLS } from './gridLayout'; +import GroupNav, { useGroupDrag } from '@/components/plugin-groups/GroupNav'; +import { deriveGroupNav, matchesGroup, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import { useAuth } from '@/contexts/AuthContext'; +import { extractErrorMessage } from '@/utils/error'; const DETAIL_DRAWER_WIDTH = 560; const LANG_COLORS: Record = { @@ -27,8 +33,15 @@ const INSTALL_CONFIRM_BUTTON_CLASS = 'px-4 py-2 text-sm text-white bg-green-600 interface APITabContentProps { tools: Tool[]; + searchQuery?: string; + /** Complete source_name facet for the current query, not the loaded tool page. */ + matchingToolServices?: Readonly>; + toolSearchPending?: boolean; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; + onServiceCountChange?: (count: number) => void; + refreshKey?: number; + viewMode?: 'list' | 'cards'; catalogEntries: MCPCatalogEntry[]; catalogCategories: Record; catalogLoading: boolean; @@ -38,8 +51,14 @@ interface APITabContentProps { export default function APITabContent({ tools, + searchQuery = '', + matchingToolServices, + toolSearchPending = false, onSelectTool, onRefreshTools, + onServiceCountChange, + refreshKey, + viewMode = 'list', catalogEntries, catalogCategories, catalogLoading, @@ -59,6 +78,7 @@ export default function APITabContent({ const [services, setServices] = useState([]); const [servicesLoading, setServicesLoading] = useState(true); + const [servicesError, setServicesError] = useState(null); const [selectedServiceId, setSelectedServiceId] = useState(null); const [testingServices, setTestingServices] = useState>(new Set()); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -67,14 +87,17 @@ export default function APITabContent({ const [credValues, setCredValues] = useState>({}); const [serviceToolCache, setServiceToolCache] = useState>({}); - const fetchServices = useCallback(async () => { + const fetchServices = useCallback(async (force = false) => { try { setServicesLoading(true); - const res = await providerAPI.listApiServices(); + const res = await providerAPI.listApiServices(force ? { force: true } : undefined); // Exclude security device APIs — they live on the Device Integration page setServices((res.data || []).filter((s) => s.integration_type !== 'device')); - } catch { - setServices([]); + setServicesError(null); + } catch (error) { + if (force) throw error; + // Keep the last native rows and group selection when a reload fails. + setServicesError(error); } finally { setServicesLoading(false); } @@ -82,7 +105,7 @@ export default function APITabContent({ useEffect(() => { fetchServices(); - }, [fetchServices]); + }, [fetchServices, refreshKey]); const selectedService = useMemo( () => (selectedServiceId ? services.find((service) => service.id === selectedServiceId) ?? null : null), @@ -307,8 +330,66 @@ export default function APITabContent({ : (englishDescription || chineseDescription); }, [i18n.language]); + const { user } = useAuth(); + const [groupSelection, setGroupSelection] = useState(null); + // The server distinguishes definition-only rows from configured instances; + // builtin, credentials and enabled state do not establish group ownership. + const asServiceGroupItem = (service: APIServiceSummary) => ({ + key: service.id, name: service.name, group: service.group, + readOnlyReason: service.group_readonly ? t('pluginGroups:readOnly.system') + : user?.role === 'admin' ? undefined : t('pluginGroups:readOnly.admin'), + }); + const asCatalogGroupItem = (entry: MCPCatalogEntry) => ({ + key: `catalog:${entry.id}`, name: entry.name, group: entry.group, + readOnlyReason: t(entry.group_readonly ? 'pluginGroups:readOnly.system' : 'pluginGroups:readOnly.catalog'), + }); + const groupInventory = (nativeServices: APIServiceSummary[]) => [ + ...nativeServices.map(asServiceGroupItem), + ...catalogEntries.filter((entry) => !nativeServices.some((service) => service.id === entry.id)).map(asCatalogGroupItem), + ]; + const groupItems = groupInventory(services); + useEffect(() => { + if (!servicesLoading && servicesError === null) onServiceCountChange?.(groupItems.length); + }, [groupItems.length, servicesLoading, servicesError, onServiceCountChange]); + const { warning } = useToast(); + const groupDrag = useGroupDrag(groupItems, warning); + const reloadGroupData = () => fetchServices(true); + const saveGroup = (id: string, group: string | null) => providerAPI.updateApiService(id, { group }); + const loadGroupItems = async () => { + const { data } = await providerAPI.listApiServices({ force: true }); + return groupInventory(data.filter((service) => service.integration_type !== 'device')); + }; + const moveGroup = async (key: string, group: string | null) => { + const inventory = await loadGroupItems(); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroupData, t); + }; + const createGroup = async (key: string, group: string) => { + const inventory = await loadGroupItems(); + if (inventory.some((item) => matchesGroup(item.group, group))) throw new Error(t('pluginGroups:validation.duplicate')); + await saveGroupItems(inventory.filter((item) => item.key === key), group, saveGroup, reloadGroupData, t); + }; + const changeGroup = async (from: string, to: string | null) => { + const inventory = await loadGroupItems(); + if (to !== null && inventory.some((item) => matchesGroup(item.group, to))) throw new Error(t('pluginGroups:validation.duplicate')); + const members = inventory.filter((item) => matchesGroup(item.group, from)); + await saveGroupItems(members, to, saveGroup, reloadGroupData, t, true); + }; + const query = searchQuery.trim().toLowerCase(); + const matchesQuery = (...values: (string | undefined)[]) => !query || values.some((value) => value?.toLowerCase().includes(query)); + const visibleServices = services.filter((service) => matchesGroup(service.group, groupSelection) + && (matchesQuery(service.id, service.name, service.description, service.description_cn) + || (!toolSearchPending && (matchingToolServices?.[service.id] ?? 0) > 0))); + const visibleCatalog = filteredCatalog.filter((entry) => matchesGroup(entry.group, groupSelection) + && matchesQuery(entry.id, entry.name, entry.description, entry.description_cn, ...entry.tags)); + return ( -
+
+ changeGroup(name, null)} {...groupDrag} /> +
+ {servicesError !== null &&
+ {extractErrorMessage(servicesError, t('alert.refreshFailedTitle'))} + +
}
- {servicesLoading && services.length === 0 && filteredCatalog.length === 0 ? ( + {(servicesLoading && services.length === 0 && filteredCatalog.length === 0) + || (query && toolSearchPending && visibleServices.length === 0 && visibleCatalog.length === 0) ? (
- ) : services.length === 0 && filteredCatalog.length === 0 && !catalogLoading ? ( + ) : visibleServices.length === 0 && visibleCatalog.length === 0 && !catalogLoading ? ( } title={t('api.noTools')} description={t('api.noToolsDesc')} /> ) : ( -
- {services.map((service) => { +
+ {visibleServices.map((service) => { const isSelected = selectedServiceId === service.id; const rowDescription = getServiceDescription(service) || `${service.name} API service`; return (
:nth-child(5)]:col-span-2 [&>:nth-child(6)]:col-span-2' : ''}`} + style={{ gridTemplateColumns: viewMode === 'list' ? SERVICE_TAB_GRID_COLS : '32px minmax(0, 1fr)' }} > {/* Icon */}
@@ -392,7 +475,8 @@ export default function APITabContent({
{/* Actions column */} -
+
+ {service.enabled ? ( <>
)} +
+ {credModalEntry && ( <>
setCredModalEntry(null)} /> diff --git a/webui/src/pages/Tool/components/LocalTabContent.tsx b/webui/src/pages/Tool/components/LocalTabContent.tsx index aa3e7b528..6e9690913 100644 --- a/webui/src/pages/Tool/components/LocalTabContent.tsx +++ b/webui/src/pages/Tool/components/LocalTabContent.tsx @@ -8,6 +8,8 @@ import EmptyState from '@/components/common/EmptyState'; import { CATEGORY_LABEL_KEY } from '../constants'; import { getLocalizedToolDescription } from '../toolDisplay'; import { SERVICE_TAB_GRID_COLS } from './gridLayout'; +import type { GroupDrag } from '@/components/plugin-groups/GroupNav'; +import PluginGroupButton from '@/components/plugin-groups/PluginGroupButton'; interface LocalTabContentProps { tools: Tool[]; @@ -15,6 +17,8 @@ interface LocalTabContentProps { selectedToolName?: string | null; onSelectTool: (tool: Tool) => void; onRefreshTools: () => Promise; + groupDrag?: GroupDrag; + viewMode?: 'list' | 'cards'; } export default function LocalTabContent({ @@ -23,6 +27,8 @@ export default function LocalTabContent({ selectedToolName, onSelectTool, onRefreshTools, + groupDrag, + viewMode = 'list', }: LocalTabContentProps) { const { t, i18n } = useTranslation('tool'); const [selectedCategory, setSelectedCategory] = useState('all'); @@ -92,7 +98,7 @@ export default function LocalTabContent({ description={searchQuery ? t('empty.tryOtherKeywords') : t('local.noToolsDesc')} /> ) : ( -
+
{filteredTools.map((tool) => { const isSelected = selectedToolName === tool.name; const description = getLocalizedToolDescription(tool, i18n.language); @@ -100,8 +106,9 @@ export default function LocalTabContent({ return (
:nth-child(5)]:col-span-2 [&>:nth-child(6)]:col-span-2' : ''}`} + style={{ gridTemplateColumns: viewMode === 'list' ? SERVICE_TAB_GRID_COLS : '32px minmax(0, 1fr)' }} > {/* Icon */}
@@ -158,7 +165,8 @@ export default function LocalTabContent({
{/* Actions column */} -
+
+ {groupDrag && } {tool.enabled ? ( <> +
}
+ {setupEntry && ( <>
setSetupEntry(null)} /> diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index 145828e34..76f5be45d 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -45,8 +45,14 @@ import LoadingSpinner from '@/components/common/LoadingSpinner'; import EmptyState from '@/components/common/EmptyState'; import { useToast } from '@/components/common/Toast'; import { useToolPage } from '@/hooks/useTools'; +import GroupNav, { useGroupDrag, type GroupDrag } from '@/components/plugin-groups/GroupNav'; +import PluginGroupButton from '@/components/plugin-groups/PluginGroupButton'; +import { assertGroupItemsEditable, saveGroupItems, type GroupSelection } from '@/components/plugin-groups/groupView'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePluginViewMode } from '@/hooks/usePluginViewMode'; +import PluginViewToggle from '@/components/plugin-groups/PluginViewToggle'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; -import { canDirectlyTestTool, toolAPI, Tool, ToolFixture } from '@/api/tool'; +import { canDirectlyTestTool, listAllToolPages, toolAPI, Tool, ToolFixture } from '@/api/tool'; import { mcpAPI, MCPServer } from '@/api/mcp'; import { providerAPI } from '@/api/provider'; import client from '@/api/client'; @@ -67,7 +73,7 @@ import { shouldLoadMcpCatalog, type TabKey, } from './tabLoading'; -import { getToolTabCounts } from './tabCounts'; +import { getApiServiceCount, getMcpServiceCount, getToolTabCounts } from './tabCounts'; // ============================================================================ // Constants & Config @@ -170,25 +176,28 @@ export default function ToolPage() { ]; const [activeTab, setActiveTab] = useState(DEFAULT_TOOL_TAB); + const [viewMode, setViewMode] = usePluginViewMode(`tools.${activeTab}`, 'list'); const [searchQuery, setSearchQuery] = useState(''); const [selectedTool, setSelectedTool] = useState(null); const [testParams, setTestParams] = useState('{}'); const [testResult, setTestResult] = useState(null); const [testing, setTesting] = useState(false); const [currentPage, setCurrentPage] = useState(1); + const [toolGroup, setToolGroup] = useState(null); const [refreshing, setRefreshing] = useState(false); const [refreshDone, setRefreshDone] = useState(false); // Sheet state const [showMCPSheet, setShowMCPSheet] = useState(false); - const [mcpRefreshKey, setMcpRefreshKey] = useState(0); + const [serviceRefreshKey, setServiceRefreshKey] = useState(0); const [showAPISheet, setShowAPISheet] = useState(false); const [showGenerateSheet, setShowGenerateSheet] = useState(false); // Sort: default by 类别 (source) MCP -> API -> 内置 const [sort, setSort] = useState({ field: 'source', dir: 'asc' }); const [filters, setFilters] = useState(EMPTY_FILTERS); - const [apiEnabledServicesCount, setApiEnabledServicesCount] = useState(0); + const [apiServicesCount, setApiServicesCount] = useState(0); + const [mcpInventory, setMcpInventory] = useState | MCPServer[]>({}); const debouncedSearchQuery = useDebouncedValue(searchQuery, 250); const tabSourceFilter = getTabSourceFilter(activeTab); const sourceFilterParam = useMemo(() => { @@ -199,7 +208,10 @@ export default function ToolPage() { } return joinFilterValues(values); }, [filters.source, tabSourceFilter]); + // Service tabs group their native inventories, never their child tools. + const toolGroupFilter = activeTab === 'all' || activeTab === 'local' ? toolGroup : null; const toolPageParams = useMemo(() => ({ + group: toolGroupFilter ?? undefined, source: sourceFilterParam, category: joinFilterValues(filters.category), sourceName: joinFilterValues(filters.source_name), @@ -211,6 +223,7 @@ export default function ToolPage() { limit: PAGE_SIZE, }), [ sourceFilterParam, + toolGroupFilter, filters.category, filters.source_name, filters.enabled, @@ -230,6 +243,9 @@ export default function ToolPage() { reload: reloadToolPage, } = useToolPage(toolPageParams); const [hasLoadedToolPage, setHasLoadedToolPage] = useState(false); + // Do not apply a previous query's service facets during the input debounce or load. + const apiToolSearchPending = searchQuery.trim().toLowerCase() !== debouncedSearchQuery.trim().toLowerCase() + || loading || !toolPageInitialized; // Catalog data (fetched once at top level, shared with MCP & API tabs) const [catalogEntries, setCatalogEntries] = useState([]); @@ -295,27 +311,104 @@ export default function ToolPage() { setConfiguredIds(prev => { const next = new Set(prev); next.delete(id); return next; }); }, []); - const fetchApiServicesCount = useCallback(async () => { + const fetchApiServicesCount = useCallback(async (force = false) => { try { - const res = await providerAPI.listApiServices(); - const services = Array.isArray(res.data) ? res.data : []; - setApiEnabledServicesCount(services.filter((service) => service.enabled).length); + const res = await providerAPI.listApiServices(force ? { force: true } : undefined); + setApiServicesCount(getApiServiceCount(Array.isArray(res.data) ? res.data : [])); } catch { - // Keep the previous count when the status request fails. + // Keep the previous native inventory count on a failed reload. + } + }, []); + + const fetchMcpInventory = useCallback(async () => { + try { + const res = await mcpAPI.list(); + setMcpInventory(res.data); + } catch { + // A tool facet cannot stand in for missing/zero-tool native services. } }, []); useEffect(() => { - fetchApiServicesCount(); - }, [fetchApiServicesCount]); + void fetchApiServicesCount(); + void fetchMcpInventory(); + }, [fetchApiServicesCount, fetchMcpInventory]); const refreshToolData = useCallback(async () => { const [refreshResult] = await Promise.all([ refetch(), - fetchApiServicesCount(), + fetchApiServicesCount(true), + fetchMcpInventory(), ]); return refreshResult; - }, [refetch, fetchApiServicesCount]); + }, [refetch, fetchApiServicesCount, fetchMcpInventory]); + + const { user } = useAuth(); + const readOnlyReason = user?.role === 'admin' ? undefined : t('pluginGroups:readOnly.admin'); + const asGroupItem = (tool: Tool) => ({ + key: tool.name, name: tool.name, group: tool.group, + readOnlyReason: tool.group_readonly ? t('pluginGroups:readOnly.system') : readOnlyReason, + }); + const toolGroupItems = tools.map(asGroupItem); + const toolGroupDrag = useGroupDrag(toolGroupItems, toast.warning); + const groupFacets = toolFacets.group ?? {}; + const hasToolInventoryFilters = !!(toolPageParams.q || toolPageParams.source || toolPageParams.category || toolPageParams.sourceName || toolPageParams.enabled); + const nativeGroupNav = { + groups: Object.entries(groupFacets).filter(([name, count]) => name && count > 0).map(([name, count]) => ({ + name, count, readOnlyReason: readOnlyReason || toolGroupItems.find((item) => item.group === name && item.readOnlyReason)?.readOnlyReason, + })), + total: toolFacets.group ? Object.values(groupFacets).reduce((sum, count) => sum + count, 0) : totalTools, + ungroupedCount: groupFacets[''] ?? 0, + inventoryComplete: !hasToolInventoryFilters && toolPageInitialized && !loading && !error, + }; + const saveToolGroup = (name: string, group: string | null) => toolAPI.updateGroup(name, group); + const reloadToolGroups = async () => { + await reloadToolPage(); + if (!toolGroup) return; + // Filtered facets cannot establish that the final native member left. + const { data } = await toolAPI.listPage({ offset: 0, limit: 1 }); + if (!data.facets.group) throw new Error(t('pluginGroups:errors.refresh')); + if (!(data.facets.group[toolGroup] > 0)) { + setToolGroup((current) => current === toolGroup ? null : current); + setCurrentPage(1); + } + }; + const moveToolGroup = async (key: string, group: string | null) => { + const { data } = await toolAPI.get(key); + await saveGroupItems([asGroupItem(data)], group, saveToolGroup, reloadToolGroups, t); + }; + const ensureNewToolGroup = async (name: string) => { + // Query-independent native facets include names hidden by search, columns or tabs. + // Only one summary row is needed; do not fetch every tool just to validate a name. + const { data } = await toolAPI.listPage({ offset: 0, limit: 1 }); + if (!data.facets.group) throw new Error(t('pluginGroups:errors.refresh')); + if ((data.facets.group[name] ?? 0) > 0) throw new Error(t('pluginGroups:validation.duplicate')); + }; + const createToolGroup = async (key: string, group: string) => { + await ensureNewToolGroup(group); + await moveToolGroup(key, group); + }; + const changeToolGroup = async (from: string, to: string | null) => { + if (to !== null) await ensureNewToolGroup(to); + // Audit the entire native group, including members hidden by query, columns, + // tabs or pagination. Counts and the loaded 25 rows cannot prove writability. + const wholeGroup = await listAllToolPages({ group: from, sortBy: toolPageParams.sortBy, sortDir: toolPageParams.sortDir }, { requireComplete: true }); + assertGroupItemsEditable(wholeGroup.map(asGroupItem), t); + // Keep the existing write scope explicit in the single native confirmation. + const members = hasToolInventoryFilters + ? await listAllToolPages({ ...toolPageParams, group: from }, { requireComplete: true }) + : wholeGroup; + await saveGroupItems(members.map(asGroupItem), to, saveToolGroup, reloadToolGroups, t, true); + }; + const selectToolGroup = useCallback((name: GroupSelection) => { + setToolGroup(name); + setCurrentPage(1); + }, []); + // A failed first request has no facets, not an authoritative empty inventory. + // Do not let it clear the selected group and silently fall back to All. + const toolGroupNav = toolFacets.group ? changeToolGroup(name, null)} {...toolGroupDrag} /> : null; const showRefreshOutcome = useCallback((status: 'success' | 'partial' | 'error', message: string) => { if (status === 'partial') { @@ -342,8 +435,11 @@ export default function ToolPage() { const apiCatalogEntries = useMemo(() => [] as MCPCatalogEntry[], []); const tabCounts = useMemo( - () => getToolTabCounts(totalTools, toolFacets, apiEnabledServicesCount), - [totalTools, toolFacets, apiEnabledServicesCount], + () => getToolTabCounts(totalTools, toolFacets, { + api: apiServicesCount, + mcp: getMcpServiceCount(mcpInventory, mcpCatalogEntries), + }), + [totalTools, toolFacets, apiServicesCount, mcpInventory, mcpCatalogEntries], ); const enabledSummary = useMemo(() => ({ active: toolFacets.enabled['true'] ?? tools.filter((tool) => tool.enabled).length, @@ -368,8 +464,8 @@ export default function ToolPage() { const paginatedTools = tools; useEffect(() => { - if (currentPage > totalPages) setCurrentPage(totalPages); - }, [currentPage, totalPages]); + if (toolPageInitialized && currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages, toolPageInitialized]); useEffect(() => { if (toolPageInitialized && !error) { @@ -379,6 +475,7 @@ export default function ToolPage() { const handleTabChange = (tab: TabKey) => { setActiveTab(tab); + selectToolGroup(null); setCurrentPage(1); setSearchQuery(''); setFilters(EMPTY_FILTERS); @@ -409,6 +506,7 @@ export default function ToolPage() { const message = extractErrorMessage(err, t('alert.unknownError')); toast.error(t('alert.refreshFailedTitle'), message); } finally { + setServiceRefreshKey((key) => key + 1); setRefreshing(false); } }; @@ -524,6 +622,7 @@ export default function ToolPage() {
+
); @@ -265,19 +311,28 @@ function WorkflowSection({ title, icon, workflows, + groupSelection, + groupDrag, + viewMode, }: { title: string; icon: React.ReactNode; workflows: WorkflowSummary[]; + groupSelection: GroupSelection; + groupDrag: GroupDrag; + viewMode: PluginViewMode; }) { + const { t, i18n } = useTranslation('workflow'); const [page, setPage] = useState(1); - const totalPages = Math.max(1, Math.ceil(workflows.length / PAGE_SIZE)); + const filtered = workflows.filter((workflow) => matchesGroup(workflow.group, groupSelection)); + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + useEffect(() => { setPage(1); }, [groupSelection]); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [totalPages, page]); - const displayed = workflows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + const displayed = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return ( // Use a labelled
so the grouping is exposed as a landmark @@ -299,9 +354,12 @@ function WorkflowSection({ {/* Grid — min-height anchors layout to avoid jump when pagination hides rows */}
1 ? 540 : undefined }}> -
+ {displayed.length === 0 &&

{t('emptyState.title')}

} +
{displayed.map(workflow => ( - +
+ +
))}
@@ -309,7 +367,7 @@ function WorkflowSection({ {totalPages > 1 && (
- {(page - 1) * PAGE_SIZE + 1}–{Math.min(workflows.length, page * PAGE_SIZE)} / {workflows.length} + {(page - 1) * PAGE_SIZE + 1}–{Math.min(filtered.length, page * PAGE_SIZE)} / {filtered.length}
- +
+ + +
{/* Description */} @@ -445,7 +506,7 @@ function WorkflowCard({ workflow }: { workflow: WorkflowSummary }) {
{/* Stats footer — kept from original, cleaned to white bg */} -
+
{workflow.stats.callCount} diff --git a/webui/src/pages/Workspace/index.test.tsx b/webui/src/pages/Workspace/index.test.tsx index 2cf0ab1f2..6a8e963e2 100644 --- a/webui/src/pages/Workspace/index.test.tsx +++ b/webui/src/pages/Workspace/index.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -72,6 +72,7 @@ const translations: Record = { 'files.preview.htmlSandbox': 'HTML sandboxed', 'files.preview.jsonParseFailed': 'JSON parse failed', 'files.preview.jsonlParseFailed': '{{count}} JSONL lines failed', + 'files.preview.csvTruncated': 'Preview limited to {{rows}} rows and {{columns}} columns.', 'files.preview.pdfLoading': 'Loading PDF', 'files.preview.pdfRendering': 'Rendering page', 'files.preview.pdfLoadFailed': 'Failed to load PDF preview', @@ -114,6 +115,9 @@ vi.mock('react-i18next', () => ({ if (key === 'files.preview.jsonlParseFailed') { return `${params?.count ?? ''} JSONL lines failed`; } + if (key === 'files.preview.csvTruncated') { + return `Preview limited to ${params?.rows ?? ''} rows and ${params?.columns ?? ''} columns.`; + } if (key === 'files.preview.pageIndicator') { return `${params?.page ?? ''} / ${params?.total ?? ''}`; } @@ -390,6 +394,27 @@ describe('WorkspacePage', () => { expect(screen.getByText(/name,count/)).toBeInTheDocument(); }); + it('limits large CSV previews to a bounded number of rows', async () => { + mocks.list.mockResolvedValue({ + data: [file('large.csv', 'large.csv')], + }); + const content = ['name,count', ...Array.from({ length: 1_100 }, (_, index) => `row-${index},${index}`)].join('\n'); + mocks.readFile.mockResolvedValue({ + data: { path: 'large.csv', content, truncated: false }, + }); + + const user = userEvent.setup(); + renderWithRouter(); + await user.click(await screen.findByText('large.csv')); + + expect(await screen.findByText('Preview limited to 1000 rows and 100 columns.')).toBeInTheDocument(); + const previewTable = screen.getAllByRole('table').find( + (table) => within(table).queryByText('row-0'), + ); + expect(previewTable).toBeDefined(); + expect(within(previewTable!).getAllByRole('row')).toHaveLength(1_000); + }); + it('PDF 文件使用 inline preview 地址展示', async () => { mocks.list.mockResolvedValue({ data: [file('report.pdf', 'report.pdf', false)], @@ -413,6 +438,27 @@ describe('WorkspacePage', () => { expect(screen.getByTitle('Next page')).toBeEnabled(); }); + it('mounts only the active PDF page window for very large documents', async () => { + pdfMocks.getDocument.mockReturnValueOnce({ + promise: Promise.resolve({ + numPages: 2_000, + getPage: pdfMocks.getPage, + destroy: pdfMocks.destroyDocument, + }), + destroy: pdfMocks.destroyTask, + }); + mocks.list.mockResolvedValue({ + data: [file('large.pdf', 'large.pdf', false)], + }); + + const user = userEvent.setup(); + const { container } = renderWithRouter(); + await user.click(await screen.findByText('large.pdf')); + await screen.findByText('1 / 2000'); + + expect(container.querySelectorAll('canvas').length).toBeLessThanOrEqual(5); + }); + it('Memory Markdown 文件复用预览渲染和全屏预览', async () => { mocks.listMemory.mockResolvedValue({ data: [file('MEMORY.md', 'MEMORY.md')], diff --git a/webui/src/pages/Workspace/index.tsx b/webui/src/pages/Workspace/index.tsx index 4da62703a..aafd3a375 100644 --- a/webui/src/pages/Workspace/index.tsx +++ b/webui/src/pages/Workspace/index.tsx @@ -1,9 +1,8 @@ -import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react'; import { FolderOpen, Upload, Download, Trash2, Edit3, Save, - X, ChevronRight, ChevronLeft, ChevronDown, ChevronUp, RefreshCw, FolderPlus, + X, ChevronRight, ChevronDown, ChevronUp, RefreshCw, FolderPlus, Brain, AlertTriangle, Search, ArrowLeft, Maximize2, - Code2, Eye, ZoomIn, ZoomOut, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; @@ -13,20 +12,13 @@ import { useConfirm } from '@/components/common/ConfirmDialog'; import { workspaceAPI, WorkspaceNode, WorkspaceProject, formatBytes, formatDate, fileIcon, } from '@/api/workspace'; +import { FilePreviewRenderer, PreviewModal, type PreviewFileAccess } from '@/components/common/FilePreview'; // ─── Types ──────────────────────────────────────────────────────────────── type Tab = 'files' | 'memory'; type SortField = 'name' | 'size' | 'modified'; type SortDirection = 'asc' | 'desc'; -type PreviewKind = 'markdown' | 'html' | 'json' | 'jsonl' | 'csv' | 'text' | 'image' | 'pdf' | 'unsupported'; -type PreviewMode = 'preview' | 'source'; - -interface PreviewFileAccess { - previewUrl: (path: string) => string; - downloadUrl: (path: string) => string; -} - const WORKSPACE_PREVIEW_FILE_ACCESS: PreviewFileAccess = { previewUrl: (path) => workspaceAPI.previewUrl(path), downloadUrl: (path) => workspaceAPI.downloadUrl(path), @@ -40,18 +32,6 @@ const MEMORY_PREVIEW_FILE_ACCESS: PreviewFileAccess = { const PREVIEW_PANEL_DEFAULT_RATIO = 0.5; const PREVIEW_PANEL_MIN_WIDTH = 420; const PREVIEW_PANEL_MIN_LIST_WIDTH = 360; -const PDF_MIN_SCALE = 0.6; -const PDF_MAX_SCALE = 2.2; -const PDF_SCALE_STEP = 0.2; -const PDF_MAX_OUTPUT_SCALE = 3; -const PDF_RENDER_WINDOW = 2; -const IMAGE_MIN_SCALE = 0.5; -const IMAGE_MAX_SCALE = 3; -const IMAGE_SCALE_STEP = 0.25; - -const LazyStreamingMarkdown = lazy(() => import('@/components/common/StreamingMarkdown') - .then((module) => ({ default: module.StreamingMarkdown }))); - function getViewportWidth(): number { return typeof window === 'undefined' ? PREVIEW_PANEL_MIN_WIDTH * 2 : window.innerWidth; } @@ -198,844 +178,6 @@ function SortHeaderButton({ ); } -function fileExtension(name: string): string { - const index = name.lastIndexOf('.'); - return index >= 0 ? name.slice(index + 1).toLowerCase() : ''; -} - -function getPreviewKind(node: WorkspaceNode): PreviewKind { - const ext = fileExtension(node.name); - if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image'; - if (ext === 'pdf') return 'pdf'; - if (node.is_text_file) { - if (['md', 'markdown'].includes(ext)) return 'markdown'; - if (['html', 'htm'].includes(ext)) return 'html'; - if (ext === 'json') return 'json'; - if (ext === 'jsonl') return 'jsonl'; - if (ext === 'csv') return 'csv'; - return 'text'; - } - return 'unsupported'; -} - -function prettyJson(content: string): { value: string; error: string | null } { - try { - return { value: JSON.stringify(JSON.parse(content), null, 2), error: null }; - } catch (e: any) { - return { value: content, error: e?.message ?? 'Invalid JSON' }; - } -} - -function prettyJsonLines(content: string): { value: string; errorCount: number } { - let errorCount = 0; - const value = content.split(/\r?\n/).map((line) => { - if (!line.trim()) return line; - try { - return JSON.stringify(JSON.parse(line), null, 2); - } catch { - errorCount += 1; - return line; - } - }).join('\n'); - return { value, errorCount }; -} - -function parseCsv(content: string): string[][] { - const rows: string[][] = []; - let row: string[] = []; - let field = ''; - let inQuotes = false; - - for (let i = 0; i < content.length; i += 1) { - const char = content[i]; - const next = content[i + 1]; - - if (char === '"') { - if (inQuotes && next === '"') { - field += '"'; - i += 1; - } else { - inQuotes = !inQuotes; - } - continue; - } - - if (char === ',' && !inQuotes) { - row.push(field); - field = ''; - continue; - } - - if ((char === '\n' || char === '\r') && !inQuotes) { - if (char === '\r' && next === '\n') { - i += 1; - } - row.push(field); - rows.push(row); - row = []; - field = ''; - continue; - } - - field += char; - } - - row.push(field); - if (row.length > 1 || row[0] !== '' || content.endsWith(',')) { - rows.push(row); - } - return rows; -} - -function SourcePreview({ content }: { content: string }) { - return ( -
-      {content}
-    
- ); -} - -function CsvPreview({ content }: { content: string }) { - const rows = parseCsv(content); - if (rows.length === 0) { - return ; - } - - const [header, ...body] = rows; - const columnCount = Math.max(...rows.map((row) => row.length)); - - return ( -
- - - - {Array.from({ length: columnCount }).map((_, index) => ( - - ))} - - - - {body.map((row, rowIndex) => ( - - {Array.from({ length: columnCount }).map((_, columnIndex) => ( - - ))} - - ))} - -
- {header[index] || `Column ${index + 1}`} -
- {row[columnIndex] ?? ''} -
-
- ); -} - -function PdfPreview({ - node, - fileAccess, - onReveal, -}: { - node: WorkspaceNode; - fileAccess: PreviewFileAccess; - onReveal?: (node: WorkspaceNode) => void; -}) { - const { t } = useTranslation('workspace'); - const previewAreaRef = useRef(null); - const pageCanvasRefs = useRef(new Map()); - const pageShellRefs = useRef(new Map()); - const renderedPageKeysRef = useRef(new Map()); - const [pdfDoc, setPdfDoc] = useState(null); - const [pageNumber, setPageNumber] = useState(1); - const [pageCount, setPageCount] = useState(0); - const [scale, setScale] = useState(1); - const [previewAreaWidth, setPreviewAreaWidth] = useState(0); - const [pagesToRender, setPagesToRender] = useState>(() => new Set()); - const [loading, setLoading] = useState(true); - const [rendering, setRendering] = useState(false); - const [error, setError] = useState(null); - const previewUrl = fileAccess.previewUrl(node.path); - - useEffect(() => { - let cancelled = false; - let loadingTask: any = null; - setPdfDoc(null); - setPageNumber(1); - setPageCount(0); - setScale(1); - setPagesToRender(new Set()); - renderedPageKeysRef.current.clear(); - setLoading(true); - setError(null); - - async function loadPdf() { - try { - const [pdfjsLib, pdfWorkerModule] = await Promise.all([ - import('pdfjs-dist'), - import('pdfjs-dist/build/pdf.worker.min.mjs?url'), - ]); - if (cancelled) { - return; - } - - pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerModule.default; - loadingTask = pdfjsLib.getDocument({ url: previewUrl, withCredentials: true }); - const doc = await loadingTask.promise; - if (cancelled) { - doc?.destroy?.(); - return; - } - - setPdfDoc(doc); - setPageCount(doc.numPages); - setPagesToRender(new Set(Array.from({ length: Math.min(doc.numPages, PDF_RENDER_WINDOW + 1) }, (_, index) => index + 1))); - setLoading(false); - } catch (e: any) { - if (cancelled) return; - setError(e?.message ?? 'PDF preview failed'); - setLoading(false); - } - } - - loadPdf(); - - return () => { - cancelled = true; - loadingTask?.destroy?.(); - }; - }, [previewUrl]); - - const setPageCanvasRef = useCallback((page: number, element: HTMLCanvasElement | null) => { - if (element) { - pageCanvasRefs.current.set(page, element); - } else { - pageCanvasRefs.current.delete(page); - } - }, []); - - const setPageShellRef = useCallback((page: number, element: HTMLDivElement | null) => { - if (element) { - pageShellRefs.current.set(page, element); - } else { - pageShellRefs.current.delete(page); - } - }, []); - - const scrollToPage = useCallback((page: number) => { - const targetPage = Math.min(pageCount, Math.max(1, page)); - setPageNumber(targetPage); - setPagesToRender((previous) => { - const next = new Set(previous); - for (let candidate = Math.max(1, targetPage - PDF_RENDER_WINDOW); candidate <= Math.min(pageCount, targetPage + PDF_RENDER_WINDOW); candidate += 1) { - next.add(candidate); - } - return next; - }); - pageShellRefs.current.get(targetPage)?.scrollIntoView({ block: 'start' }); - }, [pageCount]); - - const handlePreviewScroll = useCallback(() => { - const area = previewAreaRef.current; - if (!area || pageCount === 0) return; - - const scrollTop = area.scrollTop + 16; - let nearestPage = pageNumber; - let nearestDistance = Number.POSITIVE_INFINITY; - pageShellRefs.current.forEach((element, page) => { - const distance = Math.abs(element.offsetTop - scrollTop); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestPage = page; - } - }); - if (nearestPage !== pageNumber) { - setPageNumber(nearestPage); - } - setPagesToRender((previous) => { - let changed = false; - const next = new Set(previous); - for (let candidate = Math.max(1, nearestPage - PDF_RENDER_WINDOW); candidate <= Math.min(pageCount, nearestPage + PDF_RENDER_WINDOW); candidate += 1) { - if (!next.has(candidate)) { - next.add(candidate); - changed = true; - } - } - return changed ? next : previous; - }); - }, [pageCount, pageNumber]); - - useEffect(() => { - const area = previewAreaRef.current; - if (!area) return; - - if (area.clientWidth > 0) { - setPreviewAreaWidth(area.clientWidth); - } - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(([entry]) => { - if (entry.contentRect.width > 0) { - setPreviewAreaWidth(entry.contentRect.width); - } - }); - observer.observe(area); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - if (!pdfDoc || pageCount === 0) return; - renderedPageKeysRef.current.clear(); - }, [pageCount, pdfDoc, previewAreaWidth, scale]); - - useEffect(() => { - if (!pdfDoc || pageCount === 0 || pagesToRender.size === 0) return; - let cancelled = false; - const renderTasks: any[] = []; - - async function renderPages() { - setRendering(true); - try { - const orderedPages = [...pagesToRender].sort((a, b) => Math.abs(a - pageNumber) - Math.abs(b - pageNumber)); - for (const pageIndex of orderedPages) { - if (pageIndex < 1 || pageIndex > pageCount) continue; - const canvas = pageCanvasRefs.current.get(pageIndex); - if (!canvas) continue; - - const page = await pdfDoc.getPage(pageIndex); - if (cancelled) return; - const baseViewport = page.getViewport({ scale: 1 }); - const availableWidth = Math.max(0, previewAreaWidth - 32); - const fitScale = availableWidth > 0 ? availableWidth / baseViewport.width : 1; - const viewport = page.getViewport({ scale: fitScale * scale }); - const context = canvas.getContext('2d'); - if (!context) { - throw new Error('Canvas unavailable'); - } - const outputScale = Math.min(window.devicePixelRatio || 1, PDF_MAX_OUTPUT_SCALE); - const cssWidth = Math.ceil(viewport.width); - const cssHeight = Math.ceil(viewport.height); - const renderKey = `${cssWidth}x${cssHeight}@${outputScale}`; - if (renderedPageKeysRef.current.get(pageIndex) === renderKey) { - continue; - } - canvas.width = Math.ceil(viewport.width * outputScale); - canvas.height = Math.ceil(viewport.height * outputScale); - canvas.style.width = `${cssWidth}px`; - canvas.style.height = `${cssHeight}px`; - const renderTask = page.render({ - canvasContext: context, - viewport, - transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0], - }); - renderTasks.push(renderTask); - await renderTask.promise; - renderedPageKeysRef.current.set(pageIndex, renderKey); - } - } catch (e: any) { - if (!cancelled && e?.name !== 'RenderingCancelledException') { - setError(e?.message ?? 'PDF preview failed'); - } - } finally { - if (!cancelled) setRendering(false); - } - } - - renderPages(); - return () => { - cancelled = true; - renderTasks.forEach((task) => task?.cancel?.()); - }; - }, [pageCount, pageNumber, pagesToRender, pdfDoc, previewAreaWidth, scale]); - - if (error) { - return ( -
-
- -
-
-

{t('files.preview.pdfLoadFailed')}

-

{error}

-
-
- - - {t('files.downloadFile')} - - {onReveal && ( - - )} -
-
-
-
- ); - } - - return ( -
-
-
- - - {loading ? t('files.preview.pdfLoading') : t('files.preview.pageIndicator', { page: pageNumber, total: pageCount })} - - -
-
- - {Math.round(scale * 100)}% - -
-
-
- {(loading || rendering) && ( -
-
- {loading ? t('files.preview.pdfLoading') : t('files.preview.pdfRendering')} -
-
- )} -
- {Array.from({ length: pageCount }).map((_, index) => { - const page = index + 1; - return ( -
setPageShellRef(page, element)} - className="flex w-full flex-col items-center gap-1" - > - setPageCanvasRef(page, element)} - className="h-fit max-w-none bg-white shadow" - /> - {page} -
- ); - })} -
-
-
- ); -} - -function ImagePreview({ node, fileAccess }: { node: WorkspaceNode; fileAccess: PreviewFileAccess }) { - const { t } = useTranslation('workspace'); - const previewAreaRef = useRef(null); - const [scale, setScale] = useState(1); - const [previewAreaWidth, setPreviewAreaWidth] = useState(0); - const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null); - const previewUrl = fileAccess.previewUrl(node.path); - - useEffect(() => { - setScale(1); - setNaturalSize(null); - }, [node.path]); - - useEffect(() => { - const area = previewAreaRef.current; - if (!area) return; - - if (area.clientWidth > 0) { - setPreviewAreaWidth(area.clientWidth); - } - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(([entry]) => { - if (entry.contentRect.width > 0) { - setPreviewAreaWidth(entry.contentRect.width); - } - }); - observer.observe(area); - return () => observer.disconnect(); - }, []); - - const availableWidth = Math.max(0, previewAreaWidth - 32); - const fitScale = naturalSize && availableWidth > 0 ? Math.min(1, availableWidth / naturalSize.width) : 1; - const displayWidth = naturalSize ? Math.max(1, Math.round(naturalSize.width * fitScale * scale)) : undefined; - - return ( -
-
-
- - {Math.round(scale * 100)}% - -
-
-
-
- {node.name} { - setNaturalSize({ - width: event.currentTarget.naturalWidth, - height: event.currentTarget.naturalHeight, - }); - }} - style={displayWidth ? { width: displayWidth } : undefined} - className="h-fit max-w-none self-start bg-white object-contain shadow" - /> -
-
-
- ); -} - -function RenderedPreview({ - node, - content, - kind, - fileAccess, - onReveal, -}: { - node: WorkspaceNode; - content: string | null; - kind: PreviewKind; - fileAccess: PreviewFileAccess; - onReveal?: (node: WorkspaceNode) => void; -}) { - const { t } = useTranslation('workspace'); - - if (kind === 'image') { - return ; - } - - if (kind === 'pdf') { - return ; - } - - if (kind === 'unsupported') { - return ; - } - - if (content === null) { - return
; - } - - if (kind === 'markdown') { - return ( -
-
}> - - -
- ); - } - - if (kind === 'html') { - return ( -
-
- {t('files.preview.htmlSandbox')} -
-