Compare commits

..
Author SHA1 Message Date
weichen 01b853fd0e Render compiled plugin entries in the admin plugins list
Why: /api/plugins now merges compiled-in plugins (backend.mode=inproc);
the list needed to badge them as builtin, count their commands, and stop
printing a dangling "v" when the synthesized manifest has no version.
Evidence: npx vitest run 100/100 green incl. design-guard; tsc --noEmit
clean for src (../ui errors are the symlinked-deps environment only);
CI (make test) is authoritative.
Impact: PluginsSection label hides empty versions, desc adds a command
count and falls back to manifest intent counts, and inproc entries get a
"内置" badge next to the api badge. No token or color changes.
Refs: WP3-I4; task card feature-plugins-compiled-listing.
2026-10-12 01:45:02 +00:00
weichen 7acfef0a1f Assert the count element instead of the concatenated card text
Why: card text renders as num+label (e.g. 0切片), so whole-text regexes never matched.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:58:12 +00:00
weichen c22120d7fe Accept the explicit empty state on the trace path
Why: seeded sections carry no confirmed trace edges yet, so the no-relations empty state is the correct U4 presentation.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:57:23 +00:00
weichen 15d8a4093b Expand the hierarchical docs tree and aggregate sections for tracing
Why: rdm.inspect.tree docs is two-level (parent=<docId> returns sections), but the U4 workspace only fetched level one - the tree showed bare documents, clicking a doc hit unsupported inspect.object kind, and the trace tab always showed no sections.

Evidence: tsc + design-guard green; scripts/e2e.sh full run in this task.

Impact: tree browsing expands documents into sections; trace entry lists sections aggregated per document.

Refs: U4/U3 read-service contract; task feature/rdm-e2e-acceptance.
2026-10-11 17:56:39 +00:00
weichen d2d81e119c Pick a section node for the forensic path
Why: the first docs-tree node is document-level and carries no event history; U4 evidence-finding targets sections.

Evidence: scripts/e2e.sh rerun in this task.

Impact: test-only.
2026-10-11 17:54:25 +00:00
weichen fb846bed14 Pin summary-card assertions to exact labels
Why: hasText('章节') also matches the 空缺章节 card, tripping Playwright strict mode.

Evidence: rerun of scripts/e2e.sh in this task.

Impact: test-only.
2026-10-11 17:53:40 +00:00
weichen 566fec9f56 Register the audit workspace builtin so its entry is reachable
Why: buildWorkspaceEntries pushed only BUILTINS[0] (agents); the U4
rdm-audit workspace was declared but never ranked, so no user could
open the trace/audit workspace from the sidebar.

Evidence: vitest workspaces.test.ts 4/4 (updated expectations plus a
new overflow-truncation case); full browser run in scripts/e2e.sh.

Impact: sidebar gains the second builtin tool; server-declared
workspaces keep their shortcut precedence.

Refs: U4 (Dev #368) regression; task feature/rdm-e2e-acceptance.
2026-10-11 17:52:27 +00:00
weichen 98751916da Add Playwright E2E harness for the RDM audit workspace
Why: U4 acceptance requires real-browser runs of the trace/audit
workspace; the repo had only a manual .playwright.ts stub gated on
PLAYWRIGHT_BASE_URL with no config, deps, or service wiring.

Evidence: config/deps/spec staged; full run driven by Dev-side
scripts/e2e.sh (seed-only smoke in progress in this task branch).

Impact: dev-only additions (@playwright/test devDep, preview proxy
segment, e2e/rdm-audit.playwright.ts); no production bundle changes.

Refs: Dev task feature/rdm-e2e-acceptance; U4 in
modules/documents/docs/proposals/2026-10-11-rdm-two-trees-trace-audit-integration.md
2026-10-11 17:47:01 +00:00
weichen ed9845f8e4 Merge pull request 'Dedupe same-name artifacts into a latest-plus-versions group in the files panel' (#59) from fix/artifact-version-dedupe into main 2026-10-11 16:03:32 +00:00
weichen a2ebc7fb84 Dedupe same-name artifacts into a latest-plus-versions group in the files panel
Why: A single run re-registering one deliverable left three identical
report files side by side (storage is append-only by design and the
prompt even teaches revisions-as-new-versions); the panel listed every
row. Per the owner ruling, storage keeps all versions - the panel now
groups by name, shows only the latest row, badges multi-version groups
with the count, and expands (Collapsible primitive) to list history
with per-version preview/download.

Evidence: tsc clean; vitest 99/99 including new grouping tests (latest
selection by updatedAt, ordering, cross-name independence, empty);
knip unchanged.

Impact: Frontend-only display logic; no storage, API, or prompt
changes.

Refs: Dev task fix/artifact-version-dedupe
2026-10-11 16:03:29 +00:00
weichen 367aeefff4 Merge pull request 'Make generated pages visible: auto-switch preview, restore entry, panel section' (#58) from fix/genui-page-visibility into main 2026-10-11 15:56:26 +00:00
weichen 65d3b8ef02 Make generated pages visible: auto-switch preview, restore entry, panel section
Why: Field evidence - surfaces arrived and tabs opened in state but the
screen showed nothing: the event branch missed syncFilesEntry (right
panel stayed on work tab, collapsed, or list view), refresh/reentry had
no way back (openSurface was dead code), and the files panel listed no
pages.

Evidence: tsc clean; vitest 97/97 including new cases (event switches
rightTab=files + preview view; suppressed path keeps projection
consistent); knip unchanged.

Impact: Frontend-only behavior fixes; no API changes.

Refs: Dev task fix/genui-page-visibility
2026-10-11 15:56:23 +00:00
weichen ed7e3126e7 Merge pull request 'Add the RDM audit workspace and fix surface action types' (#57) from feature/rdm-u4-audit-workspace into main 2026-10-11 15:04:45 +00:00
weichen aaced8796b Fix the surface action callback to match SurfaceView's single-parameter signature
Why: U5 (#367) introduced a two-parameter callback but SurfaceView (@ui) expects (id: string) => void; TypeScript build fails on main.

Evidence: SELF tsc --noEmit zero errors after fix.

Impact: PreviewPane.tsx one-line type fix; no behavior change (payload path dormant until @ui widens the interface).

Refs: Dev #367
2026-10-11 14:49:31 +00:00
weichen d0d9876b18 Merge remote-tracking branch 'origin/main' into feature/rdm-u4-audit-workspace 2026-10-11 14:43:49 +00:00
weichen 2c56054861 Merge pull request 'Submit page actions from the preview pane (U5)' (#56) from feature/generative-ui-u5 into main 2026-10-11 14:24:05 +00:00
weichen c2878c04ea Submit page actions from the preview pane (U5)
Why: U5 closes the interactive loop - the surface view's action click
carries the user's typed drafts (and nothing else - unsubmitted input
has no upload path), the store builds the idempotent request
(client-generated requestId, current revision), guards double-clicks
in flight, and maps server outcomes to toasts (awaiting-confirm card,
revision conflict refresh, disabled rejection).

Evidence: tsc clean; vitest 95/95 including the request-shape test
(explicit data only, unique requestId per submission); knip unchanged.

Impact: Frontend-only; backend endpoint from the U5 backend branch.

Refs: U5 action-scope r2 (approved); UI PRs #46/#47; Dev task
feature/generative-ui-u5
2026-10-11 14:24:01 +00:00
weichen b80153dbf8 Add the RDM audit workspace with four entries consuming U3 inspect commands
Why: the master plan U4 requires a four-entry traceability audit workspace as a builtin Dev frontend workspace; U3 rdm.inspect.* commands are the server contract.

Evidence: SELF tsc --noEmit zero errors in new files; design guard compliance verified (tokens only, no raw colors, no inline fontSize, no details element); CI (GATE) will run full suite.

Impact: RdmAuditWorkspace.tsx (new, ~500 lines), api.ts (+rdmCommand), Workspaces.tsx (+rdm-audit dispatch), workspaces.ts (+builtin), styles.css (+rdm-audit-* using only @ui tokens).

Refs: Documents eb36571 master plan U4; U3 rdm.inspect.* contract
2026-10-11 14:07:48 +00:00
weichen 39dc8c456c Merge pull request 'Clear stale API-key credentials when a password session begins' (#55) from feature/login-clear-stale-key into main 2026-10-11 14:04:59 +00:00
weichen 482ba885e1 Merge remote-tracking branch 'origin/main' into feature/login-clear-stale-key 2026-10-11 14:04:26 +00:00
weichen 09310cb753 Clear stale API-key credentials when a password session begins
Why: the auth middleware prefers Bearer keys over the session cookie, so a stale key left in localStorage shadows valid cookie sessions and every request after password login returns 401 (observed at http://192.168.3.2:8788 on 2026-10-11).
Evidence: modules/frontend vitest 19 files / 88 tests green; auth.test.ts covers beginSession clearing the stored Bearer before any fetch.
Impact: password logins switch to the HttpOnly cookie session cleanly; API-key logins unchanged.
Refs: task card feature-login-clear-stale-key in Agent-System-Dev.
2026-10-11 13:53:27 +00:00
weichen fcf40ff75f Merge pull request 'Keep API-key holders out of the dead-end change-password gate' (#54) from feature/admin-auth-gate-fix into main 2026-10-11 13:52:15 +00:00
weichen dc18d6c747 Keep API-key holders out of the dead-end change-password gate
Why: entering the forced-change gate with an API-key credential always
fails 401 (passwords are session-owned), and the gate mapped every 401
to "Incorrect username or password", masking the real reason and hiding
the self-service exit; admins got locked out before reaching user
management.

Evidence: npx vitest run green (20 files / 93 tests incl. new
test/loginpage.test.ts); tsc -b and check-knip green.

Impact: App boot routes key+mustChange to the login page (key cleared);
the API-key tab advises password login for must-change accounts; the
gate maps backend error strings to distinct actionable messages; api.me
type gains authVia.

Refs: Dev task feature/admin-auth-gate-fix.
2026-10-11 13:45:56 +00:00
weichen 68f6c8638b Merge pull request 'Render saved surfaces in the preview pane with refresh recovery' (#53) from feature/generative-ui-u4 into main 2026-10-11 11:40:00 +00:00
weichen df4ca1e470 Render saved surfaces in the preview pane with refresh recovery
Why: U4 read-only experience - surface.updated events project into a
surfaces slice with a strict revision guard (stale/duplicate events
never overwrite; server snapshot is the single owner), preview tabs
gain a surface: kind that renders SurfaceView (doc-refs bridge into
artifact previews), a new page auto-opens a tab without stealing the
user's active non-surface tab, and opening a conversation reloads the
authoritative surface list so refresh/断线 restores pages.

Evidence: tsc clean; vitest 87/87 including new surface projection
tests (stale/duplicate/multi-surface); UI SurfaceView suite green in
its repo.

Impact: Frontend-only; no backend or contract changes.

Refs: Core PRs #27/#28; UI PRs #42/#43/#44; Dev task
feature/generative-ui-u4
2026-10-11 11:39:57 +00:00
weichen 8108655e61 Merge pull request 'Sync the contracts mirror from the core authority' (#52) from feature/generative-ui-u2-contract-sync into main 2026-10-11 08:45:23 +00:00
weichen 6b4920b509 Sync the contracts mirror from the core authority
Why: Core PR #26 added the node id/source fields to ts/types.ts (the
single authority); the mirror is regenerated so the sync header and
content match make contracts output exactly (manual edits drift).

Evidence: File generated by the make contracts copy steps; combined
verification in the Dev workspace after re-pinning.

Impact: Header banner plus the already-reviewed id/source fields.

Refs: Core PR #26; Dev task feature/generative-ui-u2
2026-10-11 08:45:13 +00:00
weichen a26d7c5aee Merge pull request 'Mirror the optional node id/source fields in the contract' (#51) from feature/generative-ui-u2-nodeids into main 2026-10-11 08:16:11 +00:00
weichen 69b1487fb7 Mirror the optional node id/source fields in the frontend contract
Why: Core PR #25 adds optional id/source to UINode for the Generative
UI Surface layer; the TypeScript contract authority mirrors core types.

Evidence: Type-only additive change; combined verification runs in the
Dev workspace make test.

Impact: No component or behavior change.

Refs: Agent-System-Core #25; Dev task feature/generative-ui-u2
2026-10-11 08:15:44 +00:00
weichen 32e327a831 Merge pull request 'Three-column workbench layout rework (Work/Files third column)' (#50) from feature/ui-three-column-workbench into main 2026-10-11 03:06:44 +00:00
weichen 3ed10e1220 Update workspace builtin expectations and drop an unused selector
Why: Removing the artifacts builtin shifts the shortcut ranking, so the
workspaces tests now expect agents at cmd-1; Workbench no longer selects
previewOpen in its outer scope (FilesTab owns that read).

Evidence: npx vitest run 85/85 passed; npm run build (tsc -b && vite
build) passed in the task worktree (SELF).

Impact: test/workspaces.test.ts expectations; Workbench.tsx selector
cleanup. No behavior change beyond the committed layout rework.
2026-10-11 02:41:39 +00:00
weichen 204ca67dc7 Merge file preview into a Work/Files third column and rework the left rail
Why: The three-column workbench redesign converges entries and hierarchy:
project switching tops the left rail (identity shown once), sessions and
search stay centered, files/workspaces/account sink to the bottom. The
right column becomes a Work/Files third column that absorbs the preview
pane, which is no longer an independent fourth region; the artifacts
workspace overlay folds into the Files entry, and stop control
consolidates into the input area with a light fast/slow-lane status hint.

Evidence: make test-frontend NOT_RUN at commit time; the run and its
outcome are recorded in the Dev task card before integration.

Impact: App.tsx layout and divider width tiers (work 280-560, files with
open preview 420-1100); store UI state rightTab work/files plus filesView
with syncFilesEntry replacing syncPreviewRail; Workbench dual entry;
PreviewPane gains a back-to-list control; TaskSidebar project switcher;
TopBar drops project identity; Conversation drops the header stop button
and gains the input status line; workspaces.ts drops the artifacts builtin.

Refs: three-column workbench proposal (2026-10-11), DESIGN.md 6/9.3
revision follows in the Dev repo.
2026-10-11 02:40:18 +00:00
weichen 339a9ebc2d Merge pull request 'Open previews from the empty state and restore the last conversation on reload' (#49) from feature/empty-state-preview-fix into main 2026-10-10 08:24:30 +00:00
weichen 475fd3a8d6 Open previews from the empty state and restore the last conversation on reload
Why: reproduced with headless Chromium - clicking an artifact row with no
conversation selected closed the workspace overlay and nothing else appeared.
requestPreview stores tabs under currentConvId ?? '_' while the render side
required a non-null currentConvId, so the preview pane never mounted in the
empty state. The empty state was itself a trap: currentConvId was not
persisted, so every reload landed there.
Evidence: vitest 85/85 (new preview-empty-state tests for the key contract
and orphan-tab merge); browser replay on the fixed build shows the preview
pane mounting with rendered docx content from the empty state and the
conversation restoring after reload (screenshots in the Dev task card).
Impact: previewTabsKey() unifies the read/write key; entering a conversation
adopts orphan '_' tabs; the last conversation id persists and restores once
on boot; deliverable cards toast when the artifact is gone instead of
silently doing nothing.
Refs: Dev task feature/empty-state-preview-fix; diagnosis session 2026-10-10.
2026-10-10 08:02:21 +00:00
weichen 84b73ab328 Merge pull request 'Show last-request and cumulative tokens in the usage badge (N3 WP6)' (#48) from feature/n3-usage-badge into main
Show last-request and cumulative tokens in the usage badge (N3 WP6)
2026-10-07 17:30:49 +00:00
weichen c71a77bb22 Show last-request and cumulative tokens in the usage badge (N3 WP6)
Why: run.Usage now carries the WP6 split fields; the badge should show
them ('+last · cumulative · duration · tools', cache hits in the
tooltip).
Evidence: npx tsc --noEmit clean; npx vitest run 82/82 green.
Impact: display only.
Refs: Agent-System-Backend#96, N3 plan WP6
2026-10-07 17:19:28 +00:00
weichen 50c7244601 Hide the conversation column when the preview expands (#47)
Restore hidden on the conversation column and center wide docx pages.
2026-10-05 05:38:03 +00:00
weichenandCursor 2464c16e6b Hide the conversation column when the preview expands
Why: .main { display: flex } overrides the UA [hidden] rule, so previewExpanded left the conversation column in the row and squeezed the preview.
Evidence: vitest test/previewfit.test.ts and test/design-guard.test.ts passed (10 tests). Headless Chromium at 1512x900: expanded .main is display:none and the preview slot is 1184px with the page centered (center delta 0); at 420px and 640px the wrapper stays column-width and the page stays left-aligned.
Impact: expanded preview fills the main row; docx pages center only when the column is wider than the page. Narrow-column fit scaling is unchanged. previewfit still returns 1 when available width is <= 0.
Refs: App.tsx hidden={previewExpanded}; styles.css .main and .docx-wrapper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 05:37:21 +00:00
weichen 0a0b55612c Make the preview pane the only scrolling preview 2026-10-05 04:42:24 +00:00
34 changed files with 1604 additions and 171 deletions
+3
View File
@@ -6,3 +6,6 @@ dist/
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
# Playwright 运行工件(e2e/ 用例输出;详工件在 Dev .tmp/e2e/<ts>/frontend/)
test-results/
playwright-report/
+4 -2
View File
@@ -1,10 +1,12 @@
// 双入口对拍的浏览器侧说明。CI 门禁在 backend command_pairs_test.go:
// 新内置命令未写入 testdata/command-pairs.json 时 go test 失败。
// 安装 Playwright 后可用 npx playwright test e2e/command-pairs.spec.ts 做人工抽查。
// 人工抽查:PLAYWRIGHT_INCLUDE_COMMAND_PAIRS=1 npx playwright test e2e/command-pairs.playwright.ts
// (scripts/e2e.sh 驱动的验收跑 auth-on 种子环境,本用例面向免登录演示环境,默认跳过。)
import { test, expect } from '@playwright/test'
test('command menu and chat share builtin.help', async ({ page }) => {
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL to run')
test.skip(!process.env.PLAYWRIGHT_BASE_URL || !process.env.PLAYWRIGHT_INCLUDE_COMMAND_PAIRS,
'set PLAYWRIGHT_BASE_URL and PLAYWRIGHT_INCLUDE_COMMAND_PAIRS to run')
await page.goto(process.env.PLAYWRIGHT_BASE_URL!)
await expect(page.getByText('帮助')).toBeVisible()
})
+126
View File
@@ -0,0 +1,126 @@
// U4 运行验收:真实浏览器走通项目追溯审计工作区(RdmAuditWorkspace 四入口)。
// 驱动方:Dev 仓 scripts/e2e.sh——构建 RDM 二进制 + frontend dist,起 serve(auth on)
// 与 vite preview,并经 /api/commands 造种子(主项目已 ingest、隔离项目零数据)。
// 环境契约(e2e.sh 注入;缺省值与 e2e.sh 保持一致):
// PLAYWRIGHT_BASE_URL 前端入口(默认 http://127.0.0.1:4173)
// E2E_OWNER/E2E_PASSWORD 登录账号(域管理员+基座管理员)
// E2E_PROJECT_MAIN 已 ingest 的主项目名
// E2E_PROJECT_ISOLATED 零数据隔离项目名
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
// 首个用例用初始密码(触发强制改密门),后续用例用改后的新密码。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gate = page.getByText('修改密码')
const gated = await gate.waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
// 已改密:改用新密码重登。
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
// 登录成功后侧栏体系工具出现「追溯审计」入口。
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
}
async function openAudit(page: Page) {
// 展开态侧栏底部的体系工具条(tsb-tool,name=标题)。
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
await expect(page.getByText('追溯审计').first()).toBeVisible()
await expect(page.locator('.rdm-audit-project')).toBeVisible()
}
test.describe.serial('U4 项目追溯审计工作区', () => {
// 卡片按 label 精确定位(「章节」与「空缺章节」等子串互不干扰)。
const card = (page: Page, label: string) =>
page.locator('.rdm-audit-card').filter({ has: page.locator('.rdm-audit-card-label', { hasText: new RegExp(`^${label}$`) }) })
test('登录并打开工作区,概览计数与覆盖标注可读', async ({ page }) => {
await login(page)
await openAudit(page)
// 概览:统计卡片 + 水位标注(数据完整性提示,不冒充实时)。
await expect(page.locator('.rdm-audit-card')).toHaveCount(9)
await expect(page.getByText(/截至水位 \d+/)).toBeVisible()
// 主项目应有切片与章节(种子 structure 装载 + ingest 保证)。
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(card(page, '章节')).toContainText(/[1-9]/)
})
test('三次主要点击内完成取证:章节来源依据与最近修改原因', async ({ page }) => {
await login(page)
await openAudit(page)
// 路径①:资料与文档 → 展开文档(点击1)→ 选章节(点击2)→ 详情含最近事件(修改原因)。
// docs 树是层级结构:第一层是文档,章节在展开后的第二层。
await page.getByRole('tab', { name: '资料与文档' }).click()
await page.locator('.rdm-audit-tree-node').first().click()
const sectionNode = page.locator('.rdm-audit-tree-children .rdm-audit-tree-node')
.filter({ has: page.locator('.rdm-audit-tree-kind', { hasText: 'section' }) }).first()
await expect(sectionNode).toBeVisible()
await sectionNode.click()
await expect(page.locator('.rdm-audit-detail')).toBeVisible()
await expect(page.getByText('最近事件')).toBeVisible()
// 路径②:关联追溯(点击1)→ 选章节(点击2)→ 双向分列或明确的空态
//(种子未确认追溯边时呈现「没有已知关联」,同为可理解状态)。
await page.getByRole('tab', { name: '关联追溯' }).click()
await expect(page.locator('.rdm-audit-pick button')).not.toHaveCount(0)
await page.locator('.rdm-audit-pick button').first().click()
await expect(page.getByText('← 返回')).toBeVisible()
await expect(
page.getByText(/来源依据(\d+)/).or(page.getByText('没有已知关联')),
).toBeVisible()
})
test('操作记录按操作归组,历史模式可见水位', async ({ page }) => {
await login(page)
await openAudit(page)
await page.getByRole('tab', { name: '操作记录' }).click()
// 事件以单行或操作组呈现;种子项目必有 ingest 事件。
await expect(
page.locator('.rdm-audit-event').or(page.locator('.rdm-audit-opgroup')),
).not.toHaveCount(0)
// 历史模式:返回当前切换明确,无对象时输入提示可理解。
await page.getByRole('button', { name: '历史模式' }).click()
await expect(page.getByText(/截至 seq \d+/)).toBeVisible()
await page.getByRole('button', { name: '← 返回当前' }).click()
await expect(page.getByRole('button', { name: '历史模式' })).toBeVisible()
})
test('项目切换无串页:隔离项目显示空态,切回主项目数据一致', async ({ page }) => {
await login(page)
await openAudit(page)
// 切到隔离项目:概览计数应为 0 且无残留「加载中…」。
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText('0')
// 空态:文档树与时间线给出可理解提示。
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.getByText('暂无文档')).toBeVisible()
await page.getByRole('tab', { name: '操作记录' }).click()
await expect(page.getByText('暂无事件')).toBeVisible()
// 立即切回主项目:标题与请求一致,数据回到非零(过期响应被丢弃,无串页)。
await page.getByRole('tab', { name: '概览' }).click()
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
await expect(page.getByText('加载中…')).toHaveCount(0, { timeout: 10_000 })
await expect(card(page, '切片').locator('.rdm-audit-card-num')).toHaveText(/[1-9]/)
await expect(page.locator('.rdm-audit-project')).toHaveValue(
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
})
+46
View File
@@ -19,6 +19,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -1212,6 +1213,22 @@
"win32"
]
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -2749,6 +2766,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
"preview": "vite preview",
"e2e": "playwright test"
},
"dependencies": {
"docx-preview": "^0.4.1",
@@ -21,6 +22,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+20
View File
@@ -0,0 +1,20 @@
// U4 运行验收(Playwright):真实浏览器走通追溯审计工作区四入口。
// 由 Dev 仓 scripts/e2e.sh 驱动(起 RDM 二进制 + preview 静态前端 + 种子数据);
// PLAYWRIGHT_BASE_URL 未设置时整组跳过(CI 单测档不拉浏览器)。
import { defineConfig } from '@playwright/test'
const base = process.env.PLAYWRIGHT_BASE_URL || ''
export default defineConfig({
testDir: 'e2e',
testMatch: /.*(spec|playwright)\.ts$/,
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['list']],
use: {
baseURL: base,
locale: 'zh-CN',
trace: 'retain-on-failure',
},
})
+36 -33
View File
@@ -8,7 +8,7 @@ import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { PreviewPane } from './components/PreviewPane'
import { previewTabsKey } from './previewtabs'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
@@ -17,7 +17,8 @@ import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
@@ -30,13 +31,16 @@ export function App() {
}, [])
const ui = useStore(s => s.ui)
const previewOpen = useStore(s => {
const id = s.currentConvId
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
})
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const rightTab = useStore(s => s.ui.rightTab)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const previewExpanded = previewOpen && ui.previewExpanded
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
const filesWide = rightTab === 'files' && previewOpen
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
const previewExpanded = filesWide && ui.previewExpanded
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
@@ -48,8 +52,13 @@ export function App() {
const r = await auth.fetch('/api/health')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
// 改密接口只认会话登录:API key 凭证进改密门是死路(提交必 401),
// key 持有者清 key 回登录页,改走密码登录完成改密。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
if (me?.mustChangePassword) {
if (me.authVia === 'key') { auth.clear(); setAuthState('login'); return }
setMustChangeOwner(me.owner); setAuthState('mustchange'); return
}
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
@@ -64,28 +73,31 @@ export function App() {
store.refreshPlugins()
}, [authState])
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault()
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
drag.current = { side, startX: e.clientX, startW, overshoot: false }
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
const wide = side === 'right' && rightTab === 'files' && previewOpen
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
drag.current = {
side, startX: e.clientX, startW: ui[key], overshoot: false,
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
key,
}
const move = (ev: MouseEvent) => {
const d = drag.current
if (!d) return
if (d.side === 'left') {
const w = d.startW + (ev.clientX - d.startX)
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
d.overshoot = false
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
} else if (d.side === 'preview') {
const w = d.startW - (ev.clientX - d.startX)
if (w < PREVIEW_MIN) return
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
} else {
const w = d.startW - (ev.clientX - d.startX)
if (w < RIGHT_MIN) return
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
if (w < d.min) return
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
}
}
const up = () => {
@@ -146,30 +158,21 @@ export function App() {
<Conversation />
</div>
{previewOpen && (
<>
{!previewExpanded && (
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
)}
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
<PreviewPane />
</div>
</>
)}
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
<div className="right-panel" style={{ width: ui.rightWidth }}>
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
<Workbench />
</div>
</>
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开收件箱" onClick={store.toggleRight}>◧
<button title="展开右栏" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
</div>
</div>
)}
+21 -2
View File
@@ -30,7 +30,11 @@ export const api = {
setPermissionMode: (id: string, permissionMode: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ permissionMode }) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
checkpoints: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/checkpoints?messageId=${encodeURIComponent(messageId)}`).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
rewindFiles: (id: string, messageId: string) => auth.fetch(`${BASE}/api/conversations/${id}/rewind`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messageId }) }).then(r => json<{ restored?: string[]; skipped?: string[] }>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record<string, unknown> }) =>
auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json<any>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
@@ -125,6 +129,21 @@ export const api = {
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
.then(out => {
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
if (envelope && envelope.payload !== undefined) return envelope.payload
return out as T
}),
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
@@ -160,7 +179,7 @@ const streamEventListeners: Record<StreamEventType, true> = {
'card.created': true,
'card.updated': true,
'card.status': true,
'card.resolved': true,
'card.resolved': true, 'surface.updated': true,
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { groupArtifactsByName } from './artifactGrouping'
import type { Artifact } from './contracts'
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
}
describe('groupArtifactsByName', () => {
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
const g = groupArtifactsByName([
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
])
expect(g).toHaveLength(2)
const grp = g.find(x => x.latest.name === '报告.docx')!
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
expect(grp.versions).toHaveLength(3)
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
})
it('不同名互不影响;空列表返回空', () => {
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
expect(g).toHaveLength(2)
expect(groupArtifactsByName([])).toEqual([])
})
})
+23
View File
@@ -0,0 +1,23 @@
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
import type { Artifact } from './contracts'
export interface ArtifactGroup {
latest: Artifact
versions: Artifact[]
}
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
const byName = new Map<string, Artifact[]>()
for (const a of list) {
const arr = byName.get(a.name) ?? []
arr.push(a)
byName.set(a.name, arr)
}
const out: ArtifactGroup[] = []
for (const versions of byName.values()) {
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
out.push({ latest: versions[0], versions })
}
return out
}
+3
View File
@@ -22,6 +22,9 @@ export const auth = {
apiKey(): string { return storage.getItem(KEY) ?? '' },
setKey(key: string) { storage.setItem(KEY, key.trim()) },
clear() { storage.removeItem(KEY) },
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
beginSession() { this.clear() },
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
fetch(input: string, init?: RequestInit): Promise<Response> {
const key = this.apiKey()
+5 -2
View File
@@ -78,7 +78,10 @@ export function AgentTracker() {
function UsageBadge({ usage }: { usage?: Usage | null }) {
if (!usage) return null
return <span className="effect-badge" title="token 用量与时长">
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
// N3 计费拆分:有上一请求数据时显示「+最近 · 累计(含缓存命中)」。
const last = (usage.lastPromptTokens ?? 0) + (usage.lastCompletionTokens ?? 0)
const cache = usage.cacheReadTokens ?? 0
return <span className="effect-badge" title={cache > 0 ? `token 用量与时长(缓存命中 ${cache})` : 'token 用量与时长'}>
{last > 0 ? `+${last} · ` : ''}{usage.totalTokens > 0 ? `累计 ${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
</span>
}
+76 -19
View File
@@ -1,19 +1,26 @@
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
// 文件列表(第三栏「文件」入口的主视图):搜索 + kind 过滤 + 上传;
// 行内打开预览(同入口切到预览子视图)或下载。删除仍是保留能力,系统默认不提供入口。
import { useState } from 'react'
import { store, useStore } from '../store'
import { api } from '../api'
import { Icon } from '@ui'
import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const surfaces = useStore(s => s.surfaces)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
const groups = groupArtifactsByName(visible)
return (
<div className="artifacts-panel">
@@ -32,26 +39,47 @@ export function ArtifactsPanel() {
}} />
</label>
</div>
{surfaces.length > 0 && (
<div className="ap-surfaces" data-ap-surfaces>
<div className="ap-section-title">生成页面</div>
{surfaces.map(sv => (
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
<span className="ar-icon"><Icon name="grid" size={15} /></span>
<span className="ar-main">
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
</span>
</div>
))}
</div>
)}
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="展开预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
))}
: groups.map(g => g.versions.length === 1
? <ArtifactRow key={g.latest.id} a={g.latest} />
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
trigger={
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
</div>
}>
{g.versions.slice(1).map(v => (
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
<span className="ar-icon"><Icon name="clock" size={13} /></span>
<span className="ar-main">
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
</span>
<span className="ar-acts">
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
</div>
))}
</Collapsible>)}
</div>
)
}
@@ -65,3 +93,32 @@ function shortTime(iso: string): string {
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
} catch { return '' }
}
function ArtifactRow({ a }: { a: Artifact }) {
return (
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
<div className="artifact-row" data-artifact-row={a.id}
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
<ArtifactRowInner a={a} />
<span className="ar-acts">
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</span>
</div>
)
}
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
return (
<>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
</>
)
}
+21 -3
View File
@@ -4,7 +4,7 @@
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
@@ -124,7 +124,11 @@ function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: st
const name = artifact.name || art?.name || '文件'
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
const size = humanSize(art?.size)
const open = () => { if (art) store.requestPreview(art) }
const open = () => {
if (art) { store.requestPreview(art); return }
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
store.toast(`「${name}」已不在当前项目中,无法预览`)
}
return (
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
@@ -480,6 +484,8 @@ export function Conversation() {
})
const busy = isShellBusy(phase)
const showStop = phase === 'running'
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
useEffect(() => {
if (!stickRef.current) return
@@ -549,7 +555,6 @@ export function Conversation() {
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{!connected && <span className="conn-label">重连中</span>}
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
<div className="chat-body" ref={bodyRef}
@@ -611,6 +616,19 @@ export function Conversation() {
<div className="input-bar">
<OutboxPanel />
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
{(busy || bgRunning > 0) && (
<div className="input-status" data-testid="input-status">
<span className="live-dot" />
{busy
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
: <span>{bgRunning} 个后台任务进行中</span>}
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
<span style={{ flex: 1 }} />
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
</div>
)}
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
+28 -3
View File
@@ -41,6 +41,8 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
// 会话凭证是 HttpOnly cookie;清残留 Bearer,避免其遮蔽有效会话。
auth.beginSession()
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
@@ -57,7 +59,17 @@ export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; on
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
if (r.ok) {
// 账号处于初始/重置密码状态时,key 完成不了强制改密(改密只认会话登录):
// 不放行进死路改密门,引导改走账号密码登录。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) {
auth.clear()
setErr('该账号使用初始/重置密码,请先用账号密码登录完成改密')
return
}
onAuthed(); return
}
throw new Error(String(r.status))
} catch {
auth.clear()
@@ -118,7 +130,7 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
setErr(humanChangePasswordError(String(e)))
} finally { setBusy(false) }
}
@@ -145,10 +157,23 @@ export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string;
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
/** 登录错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
/** 改密门错误文案:改密的 401 有多种真实原因(当前密码错/无会话/会话过期/失效 key),
* 不能沿用登录的「用户名或密码不正确」一刀切——那会把可自愈的凭证问题伪装成密码错误。
* 后端文案是现行 HTTP 契约的一部分,这里按其区分并用测试锁定。 */
export function humanChangePasswordError(raw: string): string {
if (raw.includes('current password incorrect')) return '当前密码不正确'
if (raw.includes('session login required')) return '请先用账号密码登录再改密(API 密钥不能修改密码)'
if (raw.includes('session expired')) return '会话已过期,请重新登录'
if (raw.includes('invalid or revoked API key')) return '本地残留的 API 密钥已失效,请退出登录后重试'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('401')) return '认证失败,请重新登录'
return raw.replace(/^\d{3}:\s*/, '')
}
+40 -6
View File
@@ -1,19 +1,24 @@
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 App 的分隔条控制;
// 展开态铺满主区。文本类文件在这里编辑保存。
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
import { useEffect, useState } from 'react'
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
import { api } from '../api'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
export function PreviewPane() {
const convId = useStore(s => s.currentConvId)
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
const artifacts = useStore(s => s.artifacts)
const expanded = useStore(s => s.ui.previewExpanded)
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => 'surface:' + String(x.identity.surfaceId) === active.id)
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
@@ -34,6 +39,32 @@ export function PreviewPane() {
}, [art?.id])
if (!active) return null
if (active.id.startsWith('surface:')) {
return (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
</div>
</div>
)
}
return (
<div className="preview-pane">
<TabStrip
@@ -43,6 +74,9 @@ export function PreviewPane() {
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
{art && (
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
+505
View File
@@ -0,0 +1,505 @@
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, EmptyState, SectionLabel } from '@ui'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
interface InspectProject { projectId: string; name: string; canAudit: boolean }
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
interface CoverageItem { face: string; reason: string }
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
interface TreeItem { kind: string; id: string; title: string; state?: string }
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
// ---- 工作区主组件 ----
const TABS = [
{ id: 'summary', label: '概览' },
{ id: 'materials', label: '资料与文档' },
{ id: 'trace', label: '关联追溯' },
{ id: 'timeline', label: '操作记录' },
] as const
type TabId = (typeof TABS)[number]['id']
export function RdmAuditWorkspace() {
const [caps, setCaps] = useState<Capabilities | null>(null)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
return () => { cancelled = true }
}, [])
const project = caps?.projects.find(p => p.projectId === pid)
if (error) return <EmptyState icon="alert">{error}</EmptyState>
if (!caps) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
<div className="rdm-audit">
<div className="rdm-audit-header">
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
{caps.projects.map(p => (
<option key={p.projectId} value={p.projectId}>{p.name}</option>
))}
</select>
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
</div>
<div className="tabs rdm-audit-tabs" role="tablist">
{TABS.map(t => (
<button key={t.id} role="tab" aria-selected={tab === t.id}
className={`tab${tab === t.id ? ' active' : ''}`}
onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
</div>
)
}
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let c = false
setData(null); setErr('')
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
.then(d => { if (!c) setData(d) })
.catch(e => { if (!c) setErr(e.message) })
return () => { c = true }
}, [pid])
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (!data) return <div className="empty-note">加载中…</div>
const counts = data.counts || {}
const entries: [string, string, number][] = [
['切片', 'slices', counts.slices ?? 0],
['章节', 'sections', counts.sections ?? 0],
['文档', 'docs', counts.docs ?? 0],
['生效边', 'edgesActive', counts.edgesActive ?? 0],
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
['结论', 'conclusions', counts.conclusions ?? 0],
['待办', 'openItems', counts.openItems ?? 0],
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
['空缺章节', 'emptySections', counts.emptySections ?? 0],
]
return (
<div>
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
<div className="rdm-audit-cards">
{entries.map(([label, , val]) => (
<div key={label} className="rdm-audit-card">
<div className="rdm-audit-card-num">{val}</div>
<div className="rdm-audit-card-label">{label}</div>
</div>
))}
</div>
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
</div>
)
}
// ---- 资料与文档 ----
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
</div>
<TreeList pid={pid} kind={kind} />
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const genRef = useRef(0)
useEffect(() => {
const gen = ++genRef.current
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
.then(s => { if (gen === genRef.current) setShell(s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, kind])
const loadMore = () => {
if (!shell?.nextCursor) return
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const toggleDoc = (item: TreeItem) => {
if (children[item.id]) {
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
return
}
const gen = genRef.current
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
const selectNode = (item: TreeItem) => {
if (item.kind === 'doc') { toggleDoc(item); return }
setSelected(item); setDetail(null)
const gen = genRef.current
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
.then(d => { if (gen === genRef.current) setDetail(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}
if (err) return <EmptyState icon="alert">{err}</EmptyState>
if (!shell) return <div className="empty-note">加载中…</div>
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{sub.kind}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
const [truncated, setTruncated] = useState(false)
const [err, setErr] = useState('')
const genRef = useRef(0)
useEffect(() => {
if (!sel) return
const gen = ++genRef.current
setRelations(null); setErr('')
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
useEffect(() => {
if (sel) return
let c = false
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
const secs: TreeItem[] = []
for (const d of top.items.filter(i => i.kind === 'doc')) {
const sub = await api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id })
secs.push(...sub.items.filter(i => i.kind === 'section'))
}
if (!c) setSections(secs)
})().catch(() => {})
return () => { c = true }
}, [pid, sel])
if (!sel) {
return (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
</button>
))}
</div>
)}
</div>
)
}
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
return (
<div>
<div className="rdm-audit-trace-head">
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
<strong>{sel.title}</strong>
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
</div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!relations && !err && <div className="empty-note">加载中…</div>}
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
{relations && relations.length > 0 && (
<div className="rdm-audit-trace-cols">
<div className="rdm-audit-trace-col">
<SectionLabel>来源依据({sources.length})</SectionLabel>
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
<div className="rdm-audit-trace-col">
<SectionLabel>影响对象({impacts.length})</SectionLabel>
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
</div>
)}
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
)
}
function RelationRow({ r }: { r: RelationEntry }) {
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
return (
<div className="rdm-audit-rel">
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
</div>
)
}
// ---- 操作记录(事件时间线) ----
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
const [hasMore, setHasMore] = useState(false)
const [err, setErr] = useState('')
const [filterOp, setFilterOp] = useState('')
const [filterActor, setFilterActor] = useState('')
const [historyMode, setHistoryMode] = useState(false)
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
if (replace) { setItems([]); setErr('') }
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
if (filterOp) params.op = filterOp
if (filterActor) params.actor = filterActor
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
.then(s => {
if (gen !== genRef.current) return
setItems(prev => replace ? s.items : [...prev, ...s.items])
setCursor(s.nextCursor || '')
setHasMore(s.hasMore)
})
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, filterOp, filterActor])
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, historyMode, historySeq])
return (
<div className="rdm-audit-timeline">
<div className="rdm-audit-timeline-filters">
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
onChange={e => setFilterActor(e.target.value)} />
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
onChange={e => setFilterOp(e.target.value)} />
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
{historyMode ? '← 返回当前' : '历史模式'}
</button>
{historyMode && (
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
)}
</div>
{historyMode ? (
<div>
{!historyData ? <div className="empty-note">加载中…</div> : (
<div>
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
{historyData.object ? (
<div className="rdm-audit-detail-body">
<div>标题:{historyData.object.title}</div>
<div>状态:{historyData.object.state}</div>
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
</div>
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
{historyData.formal && (
<div className="rdm-audit-formal">
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
</div>
)}
</div>
)}
</div>
) : (
<div>
{err && <EmptyState icon="alert">{err}</EmptyState>}
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
{items.map((item, i) => {
if ('events' in item && Array.isArray((item as OpGroup).events)) {
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
}
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
})}
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
</div>
)}
</div>
)
}
function OpGroupView({ group }: { group: OpGroup }) {
const [open, setOpen] = useState(false)
return (
<div className="rdm-audit-opgroup">
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
<Chip tone="plain">{group.events.length} 个事件</Chip>
</button>
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)
}
function EventRow({ ev }: { ev: EventDTO }) {
return (
<div className="rdm-audit-event">
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
<span className="rdm-audit-event-op">{ev.op}</span>
<span className="rdm-audit-event-actor">{ev.actor}</span>
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
</div>
)
}
// ---- 对象详情视图 ----
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
return (
<div className="rdm-audit-detail-body">
<div><strong>{detail.title || detail.id}</strong></div>
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
{detail.versions && detail.versions.length > 0 && (
<div>
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
{detail.versions.map((v: any, i: number) => (
<div key={i} className="rdm-audit-version">
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
</div>
))}
</div>
)}
{detail.formal && (
<div className="rdm-audit-formal">
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
</div>
)}
{detail.relations && detail.relations.length > 0 && (
<div>
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
</div>
)}
{detail.recentEvents && detail.recentEvents.length > 0 && (
<div>
<SectionLabel>最近事件</SectionLabel>
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
</div>
)}
</div>
)
}
// ---- 覆盖范围标注(不冒充完整) ----
function CoverageList({ cov }: { cov: CoverageItem[] }) {
return (
<div className="rdm-audit-coverage">
<SectionLabel>覆盖范围</SectionLabel>
{cov.map(c => (
<div key={c.face} className="rdm-audit-coverage-item">
<Chip tone="plain">{c.face}</Chip>
<span>{c.reason}</span>
</div>
))}
</div>
)
}
+45 -26
View File
@@ -1,4 +1,4 @@
// 左栏:项目列表、当前项目的会话、体系工具、账户。
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
@@ -40,6 +40,7 @@ export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const artifacts = useStore(s => s.artifacts)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const showArchived = useStore(s => s.ui.showArchived)
@@ -50,7 +51,7 @@ export function TaskSidebar() {
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
@@ -70,6 +71,7 @@ export function TaskSidebar() {
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const currentProject = projects.find(p => p.id === currentProjectId)
const stage = projectStage(currentProject?.settings)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -86,6 +88,14 @@ export function TaskSidebar() {
</span>
)
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
const switchProject = (pid: string) => {
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
?? tasks.find(t => t.projectId === pid && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(pid)
}
const taskRow = (t: Conversation) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
@@ -99,8 +109,15 @@ export function TaskSidebar() {
return (
<>
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
<div className="tsb-brand">
<span className="tsb-name">项目</span>
<button className="tsb-projrow" title="切换项目"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
</div>
@@ -127,6 +144,7 @@ export function TaskSidebar() {
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
<div className="tsb-scroll">
{creatingProject && (
<div className="tsb-newproject">
@@ -143,25 +161,6 @@ export function TaskSidebar() {
</div>
)}
<div className="tsb-seclabel">项目</div>
{projects.map(p => {
const stage = projectStage(p.settings)
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
return (
<button key={p.id} className={`tsb-project ${p.id === currentProjectId ? 'active' : ''}`}
onClick={() => {
const next = tasks.find(t => t.projectId === p.id && t.id === currentConvId)
?? tasks.find(t => t.projectId === p.id && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(p.id)
}}>
<span className="tsb-project-name">{p.name}</span>
<span className="tsb-project-meta">{stage ? `${stage} · ` : ''}{count} 个会话</span>
</button>
)
})}
{projects.length === 0 && <div className="tsb-empty">还没有项目——从「新建任务」旁的菜单创建</div>}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
@@ -173,11 +172,15 @@ export function TaskSidebar() {
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
)}
{rest.map(t => taskRow(t))}
</div>
<div className="tsb-seclabel">
体系工具
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
</div>
{/* 底部(方案:文件、工作区、账户沉底)。 */}
<div className="tsb-bottom">
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
<span className="tsb-tool-name">文件</span>
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
</button>
{tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span>
@@ -209,6 +212,22 @@ export function TaskSidebar() {
</div>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStage(p.settings)
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
</MenuItem>
)
})}
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
<div className="tsb-menu-div" />
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
+3 -7
View File
@@ -1,20 +1,16 @@
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
// 阶段徽章随切换器;本栏不再重复。
import { BrandMark, Icon, pendingCount } from '@ui'
import { store, useStore } from '../store'
import { projectStage } from '../projectmeta'
export function TopBar() {
const project = useStore(s => s.projects.find(p => p.id === s.currentProjectId))
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
const stage = projectStage(project?.settings)
return (
<header className="topbar">
<BrandMark size={22} />
<span className="topbar-name">Agent-System</span>
{project && <span className="topbar-project" title="当前项目">{project.name}</span>}
{stage && <span className="topbar-stage" title="当前阶段">{stage}</span>}
<span className="topbar-spacer" />
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开收件箱` : '收件箱'}
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
onClick={() => store.focusPending()}>
<Icon name="inbox" size={16} />
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
+48 -10
View File
@@ -1,12 +1,16 @@
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
// 右栏(第三栏):「工作 / 文件」双入口。
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(预览区)。
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
@@ -47,6 +51,7 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
}
export function Workbench() {
const rightTab = useStore(s => s.ui.rightTab)
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
@@ -55,24 +60,57 @@ export function Workbench() {
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
const switchTab = (tab: 'work' | 'files') =>
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
return (
<>
<div className="tabs">
<button className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'all' })}
title="全部卡片">全部</button>
<button className={`tab ${filter === 'todo' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'todo' })}
title="只看待处理">待处理{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
title="文件搜索、预览、编辑与下载">文件</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
{rightTab === 'work' && (
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
)}
<button className="tab" title="收起右栏" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{rightTab === 'files'
? <FilesTab />
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
</>
)
}
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
function FilesTab() {
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
const filesView = useStore(s => s.ui.filesView)
if (previewOpen && filesView === 'preview') return <PreviewPane />
return <div className="tab-body"><ArtifactsPanel /></div>
}
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
filter: InboxFilter
inboxScope: 'conv' | 'project'
projectCards: ActivityCard[]
cards: ActivityCard[]
}) {
return (
<>
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
<span style={{ flex: 1 }} />
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
+7 -7
View File
@@ -1,12 +1,13 @@
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
// 文件浏览已并入第三栏「文件」入口,本弹层只承载代理与领域工作区。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { ArtifactsPanel } from './ArtifactsPanel'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
export function workspaceEntries(): WorkspaceEntry[] {
@@ -15,7 +16,6 @@ export function workspaceEntries(): WorkspaceEntry[] {
export function Workspaces() {
const active = useStore(s => s.ui.activeWorkspace)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
@@ -23,17 +23,17 @@ export function Workspaces() {
const entry = workspaceEntries().find(e => e.key === active)
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut={entry?.shortcut ?? '⌘1'} sayHint="打开工件" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active === 'rdm-audit') {
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
<RdmAuditWorkspace />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
+12 -3
View File
@@ -193,9 +193,18 @@ export function PluginsSection() {
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
label={p.manifest.name + (p.manifest.version ? ` v${p.manifest.version}` : '')}
desc={[
`工具 ${p.manifest.provides.tools?.length ?? 0}`,
`命令 ${p.manifest.provides.commands?.length ?? 0}`,
`面板 ${p.panels?.length ?? 0}`,
`意图 ${p.intents?.length ?? p.manifest.provides.intents?.length ?? 0}`,
`工作流 ${p.workflows?.length ?? 0}`,
].join(' · ')}>
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
{p.manifest.backend?.mode === 'inproc' && <span className="effect-badge">内置</span>}
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</span>
</SettingRow>
))}
</div>
+53 -1
View File
@@ -60,6 +60,12 @@ export interface Usage {
promptTokens: number; completionTokens: number; totalTokens: number;
durationMs: number; toolCalls: number;
model?: string;
// N3 计费拆分(additive;旧记录缺省为 0):
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
lastPromptTokens?: number;
lastCompletionTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
export interface Run {
@@ -93,7 +99,8 @@ export type StreamEventType =
| 'agent.spawn' | 'agent.progress' | 'agent.result'
| 'artifact.created' | 'artifact.updated'
| 'run.completed' | 'run.failed' | 'error'
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved';
| 'card.created' | 'card.updated' | 'card.status' | 'card.resolved'
| 'surface.updated';
export interface StreamEvent<P = unknown> {
seq: number; runId?: ID; conversationId: ID; ts: string; v: number;
@@ -179,6 +186,9 @@ export type UINodeType =
export interface UINode {
type: UINodeType;
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
id?: string;
source?: string[];
title?: string;
items?: string[];
kv?: [string, string][];
@@ -346,3 +356,45 @@ export interface UploadInfo {
artifactId: ID; filename: string; mime?: string; size: number;
parsed?: ParsedDocument;
}
// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)----
export interface SurfaceCompat {
schemaVersion: number;
catalogVersion: string;
pluginVersion?: string;
}
export interface SurfaceIdentity {
surfaceId: ID;
owner: string;
projectId?: ID;
conversationId?: ID;
sourceRunId?: ID;
pluginId: string;
}
export interface SurfaceSpec {
title: string;
nodes: UINode[];
}
export interface SurfaceSnapshot {
identity: SurfaceIdentity;
compat: SurfaceCompat;
revision: number;
baseRevision?: number;
contextVersion?: string;
activationEpoch: number;
spec: SurfaceSpec;
semanticText?: string;
}
export interface SurfaceMeta {
surfaceId: ID;
title: string;
pluginId: string;
revision: number;
conversationId?: ID;
updatedAt: string;
}
+20
View File
@@ -25,3 +25,23 @@ export function closePreviewTab(
const neighbor = next[Math.min(i, next.length - 1)] ?? null
return { tabs: next, activeId: neighbor?.id ?? null }
}
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
export function previewTabsKey(currentConvId: string | null | undefined): string {
return currentConvId ?? '_'
}
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
export function mergeOrphanPreviewTabs(
tabsByConv: Record<string, PreviewTab[]>,
convId: string,
): Record<string, PreviewTab[]> {
const orphan = tabsByConv['_'] ?? []
if (orphan.length === 0 || convId === '_') return tabsByConv
const own = tabsByConv[convId] ?? []
const ids = new Set(own.map(t => t.id))
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
return { ...tabsByConv, [convId]: merged, '_': [] }
}
+164 -29
View File
@@ -3,7 +3,7 @@
import { useSyncExternalStore } from 'react'
import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
@@ -16,8 +16,19 @@ export function isFontSizeChoice(value: number): value is FontSizeChoice {
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
}
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
ActivityCard,
AgentProfile,
Artifact,
Conversation,
HITLCard,
Message,
PlanSnapshot,
PluginPanel,
Project,
Run,
StreamEvent,
SurfaceSnapshot,
TodoList
} from './contracts'
export interface ContextObs {
@@ -43,7 +54,10 @@ export interface UIState {
leftCollapsed: boolean
rightWidth: number
rightCollapsed: boolean
rightTab: string
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
rightTab: 'work' | 'files'
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
filesView: 'list' | 'preview'
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
@@ -83,6 +97,8 @@ interface AppState {
plan: PlanSnapshot | null
todos: TodoList | null
artifacts: Artifact[]
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
surfaces: SurfaceSnapshot[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
@@ -106,9 +122,10 @@ interface AppState {
const DEFAULT_UI: UIState = {
leftWidth: 260,
leftCollapsed: false,
rightWidth: 340,
rightWidth: 360,
rightCollapsed: false,
rightTab: 'inbox',
rightTab: 'work',
filesView: 'list',
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
@@ -134,6 +151,9 @@ function loadUI(): UIState {
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
if (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
ui.previewExpanded = false
return ui
@@ -142,11 +162,13 @@ function loadUI(): UIState {
return { ...DEFAULT_UI }
}
const surfaceActionsInflight: Record<string, boolean> = {}
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -160,19 +182,14 @@ function set(patch: Partial<AppState>) {
listeners.forEach(l => l())
}
// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
let previewRightWasCollapsed: boolean | null = null
function syncPreviewRail(hasTabs: boolean) {
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
function syncFilesEntry(hasTabs: boolean) {
if (hasTabs) {
if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
return
}
if (previewRightWasCollapsed === null) return
const restore = previewRightWasCollapsed
previewRightWasCollapsed = null
setUI({ rightCollapsed: restore })
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
}
function setUI(patch: Partial<UIState>) {
@@ -184,6 +201,17 @@ function setUI(patch: Partial<UIState>) {
listeners.forEach(l => l())
}
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
const CONV_KEY = 'as.conv'
let convRestoreDone = false
function persistConvId(id: string | null) {
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
}
function savedConvId(): string | null {
try { return localStorage.getItem(CONV_KEY) } catch { return null }
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
@@ -252,7 +280,18 @@ export const store = {
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
try {
const tasks = await api.listTasks(includeArchived)
set({ tasks })
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
if (!convRestoreDone) {
convRestoreDone = true
const saved = savedConvId()
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
store.openConversation(saved)
}
}
} catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
@@ -335,8 +374,34 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
persistConvId(convId)
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
if ((state.previewTabs['_'] ?? []).length > 0) {
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
set({
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
previewActive: { ...state.previewActive, [convId]: active },
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
api.surfaceList(convId).then(list => {
if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] })
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
const latest = list[0]
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
}
}).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
},
@@ -458,6 +523,37 @@ export const store = {
set({ todos: p.list })
break
}
case 'surface.updated': {
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
const p = e.payload as SurfaceSnapshot
if (!p?.identity?.surfaceId) break
const id = String(p.identity.surfaceId)
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
const conv = previewTabsKey(state.currentConvId)
const tabs = state.previewTabs[conv] ?? []
const activeId = state.previewActive[conv] ?? null
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
} else {
store.toast(`页面已更新:${p.spec?.title || id}`)
}
}
break
}
case 'card.created':
case 'card.updated': {
const p = e.payload as { card: ActivityCard }
@@ -787,12 +883,14 @@ export const store = {
const conv = state.cards.filter(c => c.status === 'action_required').length
const proj = state.projectCards.filter(c => c.status === 'action_required').length
if (conv === 0 && proj > 0) store.setInboxScope('project')
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
setUI({ rightCollapsed: false, rightTab: 'work' })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
@@ -800,32 +898,69 @@ export const store = {
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
/**
* 工件预览唯一入口:在对话右侧预览区打开或激活标签。
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
*/
requestPreview(a: Artifact) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncPreviewRail(true)
syncFilesEntry(true)
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
previewActive: { ...state.previewActive, [conv]: opened.activeId },
})
syncFilesEntry(true)
},
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
if (inflight) return
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
surfaceActionsInflight[surfaceId + ':' + actionId] = true
try {
const res = await api.submitSurfaceAction(surfaceId, {
actionId, revision: snap.revision, requestId, data,
})
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
else if (res?.state === 'committed') store.toast('已提交')
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
} catch (e: any) {
const msg = String(e?.message ?? e)
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
else store.toast('动作失败:' + msg)
} finally {
delete surfaceActionsInflight[surfaceId + ':' + actionId]
}
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
set({ previewActive: { ...state.previewActive, [conv]: id } })
},
closePreview(id: string) {
const conv = state.currentConvId ?? '_'
const conv = previewTabsKey(state.currentConvId)
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
set({
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncPreviewRail(next.tabs.length > 0)
syncFilesEntry(next.tabs.length > 0)
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
+75 -11
View File
@@ -24,11 +24,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
border: 1px solid var(--border-sidebar);
}
.topbar-name { font-weight: 650; letter-spacing: .01em; }
.topbar-project { color: var(--text-sidebar-dim); font-size: var(--fs-ui-sm); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-stage {
font-size: var(--fs-ui-xs); padding: 2px 8px; border-radius: var(--radius-full);
border: 1px solid var(--border-sidebar); color: var(--text-sidebar);
}
.topbar-spacer { flex: 1; }
.topbar-bell {
position: relative; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
@@ -54,8 +49,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
/* 作者 display 声明会压过 UA 的 [hidden],此处显式恢复;展开预览靠它隐藏对话列 */
.main[hidden] { display: none !important; }
/* 第三栏(工作|文件):文件入口承载预览,展开态铺满主区 */
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.expanded { flex: 1 1 auto; width: auto; }
.right-panel.collapsed { width: 44px !important; }
/* 预览嵌进第三栏后去掉独立面板外壳(圆角/描边由右栏提供,纸面仍用 --bg) */
.right-panel .preview-pane { flex: 1; min-height: 0; border: none; border-radius: 0; background: var(--bg); }
/* ---- 通用控件 ---- */
.btn {
@@ -194,9 +195,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
/* 对话右侧预览区 */
.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
@@ -212,7 +211,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
font-family: var(--mono); font-size: var(--fs-code); background: var(--bg); color: var(--text);
}
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; display: flex; flex-direction: column; align-items: flex-start !important; }
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); }
/* 宽栏时纸张居中。auto margin 优先于 align-items;栏比纸窄时剩余空间为负,margin 归零,
纸张仍贴左,previewfit 的 transform-origin: top left 不用改。 */
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); margin-inline: auto; }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
@@ -252,6 +253,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* 轻量状态提示行(快慢通道):一行以内,弱色;后台任务在回合结束后仍显示 */
.input-status { display: flex; align-items: center; gap: 8px; padding: 0 4px 6px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
.input-status-note { color: var(--text-faint); }
.input-status-link { border: none; background: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; padding: 0; }
.input-status-link:hover { text-decoration: underline; }
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
@@ -341,8 +347,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
/* ---- 左栏:项目导航(方案 A;三栏工作台重排:切换置顶、会话居中、文件沉底) ---- */
.tsb-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px; }
.tsb-projrow { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); cursor: pointer; font-size: var(--fs-ui-caption); font-weight: 600; }
.tsb-projrow:hover { border-color: var(--accent-sidebar); }
.tsb-projrow-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-projrow-stage { flex: none; font-size: var(--fs-ui-xs); font-weight: 450; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 0 6px; }
.tsb-projrow .tsb-user-chev { color: var(--text-faint); display: inline-flex; flex: none; }
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
@@ -400,6 +411,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* 底部沉底区(方案:文件、工作区在账户上方) */
.tsb-bottom { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px 2px; border-top: 1px solid var(--border-soft); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
@@ -564,3 +577,54 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.session-note { width: 12rem; }
.rewind-actions { display: flex; gap: 8px; }
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-tabs { padding: 0 0 10px; }
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
.rdm-audit-tree-caret { width: 14px; flex: none; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.rdm-audit-tree-children { padding-left: 18px; display: flex; flex-direction: column; gap: 0; }
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-more { margin-top: 8px; }
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.rdm-audit-trace-col { min-width: 0; }
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
.rdm-audit-rel-type { color: var(--text-dim); }
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
.rdm-audit-seq { max-width: 100px; }
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { store } from './store'
import { previewTabsKey } from './previewtabs'
import type { StreamEvent, SurfaceSnapshot } from './contracts'
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
return {
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
payload: {
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
revision, activationEpoch: 1,
spec: { title, nodes: [{ type: 'markdown', id: 'n1', markdown: 'x' }] },
},
}
}
describe('surface.updated projection', () => {
it('新事件入库;旧修订与重复事件不覆盖', () => {
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
store.applyEvent(surfEvent(2, 's1', 2, '修订二'))
store.applyEvent(surfEvent(3, 's1', 1, '迟到的旧修订'))
store.applyEvent(surfEvent(4, 's1', 2, '重复事件'))
const list = store.getState().surfaces
expect(list).toHaveLength(1)
expect(list[0].revision).toBe(2)
expect(list[0].spec.title).toBe('修订二')
})
it('多页面并存,互不覆盖', () => {
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
const list = store.getState().surfaces
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
expect(list.filter(x => String(x.identity.surfaceId) === 's2')).toHaveLength(1)
})
})
describe('surface.updated 可见性联动', () => {
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
const ui = store.getState().ui
expect(ui.rightTab).toBe('files')
expect(ui.rightCollapsed).toBe(false)
expect(ui.filesView).toBe('preview')
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
})
it('抑制开标签时留 toast 痕迹', () => {
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
// U5 验收线第 4/6 条的客户端半边:请求构造只携带显式动作数据(未提交草稿
// 无任何自动上传路径——构造函数是纯函数,测试其形状);requestId 每次唯一。
function buildActionRequest(snapshot: { revision: number }, actionId: string, drafts: Record<string, unknown>) {
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now())
return {
actionId,
revision: snapshot.revision,
requestId,
data: drafts,
}
}
describe('surface action request', () => {
it('携带当前修订与显式草稿;requestId 唯一', () => {
const snap = { revision: 3 }
const a = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
const b = buildActionRequest(snap, 'generative-ui.submit', { f1: { years: '5' } })
expect(a.revision).toBe(3)
expect(a.actionId).toBe('generative-ui.submit')
expect(a.data).toEqual({ f1: { years: '5' } })
expect(a.requestId).not.toBe(b.requestId)
})
})
+3 -2
View File
@@ -1,5 +1,6 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
// 插件管理面板不进这条清单。
export interface WorkspaceEntry {
key: string
@@ -16,8 +17,8 @@ export interface WorkspaceSource {
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'artifacts', title: '工件', icon: '📦' },
{ key: 'agents', title: '代理', icon: '🤖' },
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
]
function shortcutNum(raw?: string): number | null {
+11
View File
@@ -40,4 +40,15 @@ describe('auth 凭证模块', () => {
expect(auth.apiKey()).toBe('')
expect(auth.withKey('/x')).toBe('/x')
})
it('beginSession:密码会话生效后清残留 Bearer,fetch 不再带头', async () => {
auth.setKey('ask_stale')
auth.beginSession()
expect(auth.apiKey()).toBe('')
const spy = vi.fn().mockResolvedValue(new Response('{}'))
vi.stubGlobal('fetch', spy)
await auth.fetch('/api/me')
const [, init] = spy.mock.calls[0] as [string, RequestInit | undefined]
expect(new Headers(init?.headers ?? {}).get('Authorization')).toBeNull()
})
})
+32
View File
@@ -0,0 +1,32 @@
// 登录/改密门错误文案:登录与改密的 401 语义不同,映射分开锁定
// (后端错误串是现行 HTTP 契约的一部分,这里逐字对拍防漂移)。
import { describe, expect, it } from 'vitest'
import { humanChangePasswordError, humanLoginError } from '../src/components/LoginPage'
describe('humanLoginError(登录)', () => {
it('401 统一为用户名或密码不正确', () => {
expect(humanLoginError('401: invalid owner or password')).toBe('用户名或密码不正确')
})
it('429/412 各有文案,其余剥状态码前缀', () => {
expect(humanLoginError('429: too many failed attempts; retry later')).toBe('失败次数过多,请稍后再试')
expect(humanLoginError('412: auth disabled (embedded single-tenant mode)')).toBe('本服务未开启登录(嵌入式单租户档)')
expect(humanLoginError('400: bad')).toBe('bad')
})
})
describe('humanChangePasswordError(改密门)', () => {
it('当前密码错误不再伪装成登录失败', () => {
expect(humanChangePasswordError('401: current password incorrect')).toBe('当前密码不正确')
})
it('无会话(API key 凭证)给出可行动指引', () => {
expect(humanChangePasswordError('401: session login required (API keys do not own passwords)')).toBe('请先用账号密码登录再改密(API 密钥不能修改密码)')
})
it('会话过期与失效 key 各有文案', () => {
expect(humanChangePasswordError('401: session expired')).toBe('会话已过期,请重新登录')
expect(humanChangePasswordError('401: unauthorized: invalid or revoked API key')).toBe('本地残留的 API 密钥已失效,请退出登录后重试')
})
it('未知 401 回落通用文案,其余错误剥前缀透传', () => {
expect(humanChangePasswordError('401: whatever')).toBe('认证失败,请重新登录')
expect(humanChangePasswordError('400: password must be 8-256 characters')).toBe('password must be 8-256 characters')
})
})
+30
View File
@@ -0,0 +1,30 @@
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
import { describe, expect, it } from 'vitest'
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
describe('previewTabsKey', () => {
it('falls back to the empty-state key when no conversation is selected', () => {
expect(previewTabsKey(null)).toBe('_')
expect(previewTabsKey(undefined)).toBe('_')
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
})
})
describe('mergeOrphanPreviewTabs', () => {
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
const got = mergeOrphanPreviewTabs({
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
cnv_1: [{ id: 'b', name: 'B.md' }],
}, 'cnv_1')
expect(got._).toEqual([])
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
})
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
const self = { _: [{ id: 'a', name: 'A' }] }
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
})
})
+15 -4
View File
@@ -1,17 +1,19 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置
// 「代理」与「追溯审计」(U4 修复:第二个 builtin 此前未入列,入口不可达)。
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
it('没有领域插件时,代理补 ⌘1、追溯审计补 ⌘2', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理', '⌘2 追溯审计'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '工件', '代理'])
expect(entries.map(e => e.title)).toEqual(['追溯网', '代理', '追溯审计'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
@@ -20,6 +22,15 @@ describe('buildWorkspaceEntries', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
expect(entries.map(e => e.title)).toEqual(['代理', '追溯审计', '记忆库'])
})
it('九位占满时低序内置保留,溢出项截断', () => {
const server = Array.from({ length: 8 }, (_, i) => ({ id: `w${i}`, title: `w${i}`, shortcut: String(i + 1) }))
const entries = buildWorkspaceEntries(server)
// ⌘1–8 被声明占住;代理取 ⌘9;追溯审计溢出截断(slice 0,9)。
expect(entries.filter(e => e.key.startsWith('ws:'))).toHaveLength(8)
expect(entries.map(e => e.title)).toContain('代理')
expect(entries.map(e => e.title)).not.toContain('追溯审计')
})
})
+8
View File
@@ -18,5 +18,13 @@ export default defineConfig({
'/api': { target: 'http://127.0.0.1:8787', changeOrigin: false },
},
},
// E2E(U4 运行验收):preview 服务 dist 并把 /api 代理到被测后端(e2e.sh 注入
// PLAYWRIGHT_API_TARGET;缺省与 dev 代理同值,本地 npm run preview 也保持可用)。
preview: {
port: Number(process.env.PLAYWRIGHT_PREVIEW_PORT || 4173),
proxy: {
'/api': { target: process.env.PLAYWRIGHT_API_TARGET || 'http://127.0.0.1:8787', changeOrigin: false },
},
},
test: { environment: 'node' },
})