Compare commits

...
Author SHA1 Message Date
weichenandCursor 0f52634380 Hydrate surface list into snapshots before render
Why: GET .../surfaces returns SurfaceMeta without identity; writing it into
surfaces let PreviewPane crash on x.identity.surfaceId after refresh.
Evidence: npx vitest run (118 passed), including surfaceKey.test.ts
Impact: openConversation fetches SurfaceSnapshot via surfaceGet; find paths
use surfaceKey; Meta no longer enters the surfaces projection
Refs: fix/surface-list-hydrate

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 08:33:23 +00:00
weichen a45a4d8652 Merge pull request 'Fix password form semantics and typed setting inputs' (#66) from feature/rdm-audit-capability-gate-changepw-form into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/66
2026-10-12 06:20:05 +00:00
weichen 378ec24bbd Merge pull request 'Gate the audit workspace entry on rdm.inspect command availability' (#65) from feature/rdm-audit-capability-gate into main
Reviewed-on: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/65
2026-10-12 06:19:56 +00:00
7 changed files with 107 additions and 26 deletions
+6 -4
View File
@@ -4,7 +4,7 @@ import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, StreamEventType, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
ActivityCard, SurfaceMeta, SurfaceSnapshot,
} from './contracts'
const BASE = ''
@@ -35,11 +35,13 @@ 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)),
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<any[]>(r)),
/** 会话内页面目录:扁平 SurfaceMeta(无 identity/spec),不可直接写入 surfaces 投影。 */
surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json<SurfaceMeta[]>(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)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<any[]>(r)),
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<any>(r)),
projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json<SurfaceMeta[]>(r)),
/** 单页权威快照(含 identity + spec),供列表水合与预览渲染。 */
surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json<SurfaceSnapshot>(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 => {
+9 -5
View File
@@ -7,6 +7,7 @@ import { Collapsible, Icon } from '@ui'
import { artifactIcon, humanSize } from '../messageparts'
import type { Artifact } from '../contracts'
import { groupArtifactsByName } from '../artifactGrouping'
import { surfaceKey } from '../surfaceKey'
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
@@ -42,16 +43,19 @@ export function ArtifactsPanel() {
{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 ?? ''}`}>
{surfaces.filter(sv => surfaceKey(sv) != null).map(sv => {
const id = surfaceKey(sv)!
return (
<div key={id} className="artifact-row" data-surface-row={id}
onClick={() => store.openSurface(id)} 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 className="meta">页面 · 修订 {sv.revision} · {sv.identity?.pluginId}</span>
</span>
</div>
))}
)
})}
</div>
)}
{artifacts.length === 0
+6 -2
View File
@@ -7,6 +7,7 @@ import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
import { surfaceKey } from '../surfaceKey'
export function PreviewPane() {
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
@@ -17,7 +18,10 @@ export function PreviewPane() {
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)
? surfaces.find(x => {
const id = surfaceKey(x)
return id != null && 'surface:' + id === active.id
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
@@ -56,7 +60,7 @@ export function PreviewPane() {
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
+7 -4
View File
@@ -6,6 +6,7 @@ import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon, SurfaceView } from '@ui'
import { previewTabsKey } from '../previewtabs'
import { surfaceKey } from '../surfaceKey'
import { ArtifactsPanel } from './ArtifactsPanel'
import { PreviewPane } from './PreviewPane'
@@ -180,9 +181,11 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
function WorkspaceSurfaces() {
const surfaces = useStore(s => s.surfaces)
const pin = useStore(s => s.ui.workspaceSurfacePin)
if (surfaces.length === 0) return null
const shown = pin ? surfaces.find(x => String(x.identity.surfaceId) === pin) : surfaces[surfaces.length - 1]
const usable = surfaces.filter(x => surfaceKey(x) != null)
if (usable.length === 0) return null
const shown = pin ? usable.find(x => surfaceKey(x) === pin) : usable[usable.length - 1]
if (!shown) return null
const shownId = surfaceKey(shown)!
return (
<div className="workspace-surfaces" data-workspace-surfaces>
<div className="ws-head">
@@ -190,10 +193,10 @@ function WorkspaceSurfaces() {
<span className="ws-meta">{shown.spec?.title || '(未命名)'} · 修订 {shown.revision}</span>
<span style={{ flex: 1 }} />
<button className="icon-btn" title={pin ? '取消固定(跟随最新)' : '固定此页(新页面不抢占)'}
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : String(shown.identity.surfaceId) })}>
onClick={() => store.setUI({ workspaceSurfacePin: pin ? null : shownId })}>
{pin ? '📍' : '📌'}
</button>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(String(shown.identity.surfaceId))}>
<button className="icon-btn" title="在文件预览中打开" onClick={() => store.openSurface(shownId)}>
↗
</button>
</div>
+20 -11
View File
@@ -8,6 +8,7 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag
import { canFlushOutbox, deriveShellPhase } from './runstate'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady } from './workspaces'
type FontSizeChoice = 13 | 14 | 16 | 18
@@ -397,23 +398,31 @@ export const store = {
})
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(list => {
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId) return
set({ surfaces: list ?? [] })
const metas = list ?? []
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
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]
if (!hasSurfaceTab && metas.length > 0) {
const latest = metas[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 },
})
}
const fetched = await Promise.all(metas.map(m =>
api.surfaceGet(String(m.surfaceId)).catch(() => null),
))
if (state.currentConvId !== convId) return
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
}).catch(() => {})
const resumeSeq = await store.reconcile()
store.connectLive(resumeSeq ?? state.lastSeq)
@@ -540,10 +549,10 @@ export const store = {
// 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)
const id = surfaceKey(p)
if (!id) break
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
const existing = state.surfaces.find(x => surfaceKey(x) === id)
if (!existing || p.revision > existing.revision) {
surfaces.push(p)
set({ surfaces })
@@ -934,7 +943,7 @@ export const store = {
},
openSurface(surfaceId: string) {
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
const snap = state.surfaces.find(x => surfaceKey(x) === surfaceId)
const conv = previewTabsKey(state.currentConvId)
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
set({
@@ -948,7 +957,7 @@ export const store = {
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)
const snap = state.surfaces.find(x => surfaceKey(x) === 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
+19
View File
@@ -0,0 +1,19 @@
// surface 投影可能短暂混入无 identity 的条目;读 id 一律经此助手,避免渲染崩溃。
import type { SurfaceSnapshot } from './contracts'
export function surfaceKey(x: { identity?: { surfaceId?: unknown } } | null | undefined): string | null {
const id = x?.identity?.surfaceId
return id == null || id === '' ? null : String(id)
}
/** 按 surfaceId 合并;同 id 取 revision 较大者。跳过无 identity 的脏项。 */
export function mergeSurfacesByRevision(a: SurfaceSnapshot[], b: SurfaceSnapshot[]): SurfaceSnapshot[] {
const byId = new Map<string, SurfaceSnapshot>()
for (const s of [...a, ...b]) {
const id = surfaceKey(s)
if (!id) continue
const existing = byId.get(id)
if (!existing || (s.revision ?? 0) >= (existing.revision ?? 0)) byId.set(id, s)
}
return [...byId.values()]
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { SurfaceSnapshot } from '../src/contracts'
import { mergeSurfacesByRevision, surfaceKey } from '../src/surfaceKey'
function snap(id: string, revision: number): SurfaceSnapshot {
return {
identity: {
surfaceId: id,
owner: 'o',
pluginId: 'p',
},
compat: { schemaVersion: 1, catalogVersion: '1' },
revision,
activationEpoch: 0,
spec: { title: id, nodes: [] },
}
}
describe('surfaceKey', () => {
it('reads nested identity and skips missing', () => {
expect(surfaceKey(snap('s1', 1))).toBe('s1')
expect(surfaceKey(undefined)).toBeNull()
expect(surfaceKey({} as SurfaceSnapshot)).toBeNull()
expect(surfaceKey({ identity: {} } as SurfaceSnapshot)).toBeNull()
})
})
describe('mergeSurfacesByRevision', () => {
it('keeps higher revision and drops dirty entries', () => {
const dirty = { revision: 99 } as SurfaceSnapshot
const merged = mergeSurfacesByRevision(
[snap('a', 1), dirty],
[snap('a', 3), snap('b', 1)],
)
expect(merged.map(s => [surfaceKey(s), s.revision])).toEqual([
['a', 3],
['b', 1],
])
})
})