From 0f860c074319d05d52072810c47159f505a7004e Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 02:16:57 +0000 Subject: [PATCH] Add the workspace task-page container with pin and project aggregation (U6) Why: U6 brings generated pages into the work view - a compact task-page section renders the latest conversation surface under the current-task card (SurfaceView), a pin freezes the shown page against new arrivals (unpin follows latest), a jump button opens the full preview, and the project-level aggregate loads on conversation open for cross-session visibility; when the plugin is disabled or no pages exist the section disappears and the work view is untouched. Evidence: tsc clean; vitest 101/101 including the pin/follow test; knip unchanged. Impact: Frontend-only; consumes the U6 backend aggregate endpoint. Refs: proposal r2 U6; Backend #163; Dev task feature/generative-ui-u6 --- src/api.ts | 1 + src/components/Workbench.tsx | 33 ++++++++++++++++++++++++++++++++- src/store.ts | 12 +++++++++--- src/surface.test.ts | 18 ++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/api.ts b/src/api.ts index 703447d..c51c9fb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -33,6 +33,7 @@ export const api = { surfaceList: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/surfaces`).then(r => json(r)), submitSurfaceAction: (id: string, body: { actionId: string; revision: number; requestId: string; data: Record }) => auth.fetch(`${BASE}/api/surfaces/${id}/actions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), + projectSurfaces: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/surfaces`).then(r => json(r)), surfaceGet: (id: string) => auth.fetch(`${BASE}/api/surfaces/${id}`).then(r => json(r)), me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; authVia?: string; isAdmin?: boolean; mustChangePassword?: boolean }>(r)), // ---- 认证与会话(管理员发放制,R2)---- diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index b0bbaed..0d72972 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -4,7 +4,7 @@ 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 { Icon, SurfaceView } from '@ui' import { previewTabsKey } from '../previewtabs' import { ArtifactsPanel } from './ArtifactsPanel' import { PreviewPane } from './PreviewPane' @@ -115,6 +115,7 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { {inboxScope === 'project' ? : } + ) @@ -172,3 +173,33 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) { ) } + +// U6 工作区任务页容器:会话内页面在"工作"入口的常驻紧凑视图——默认显示最新 +// 一页,固定(pin)后不随新页面抢占;关闭插件后此处自然消失(surfaces 为空), +// 收件箱/任务卡等原有内容不受影响(禁用回退完整性)。点击标题去文件预览全屏。 +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] + if (!shown) return null + return ( +
+
+ 任务页面 + {shown.spec?.title || '(未命名)'} · 修订 {shown.revision} + + + +
+
+ +
+
+ ) +} diff --git a/src/store.ts b/src/store.ts index 89cd65f..0a02b5d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -27,6 +27,7 @@ import type { Project, Run, StreamEvent, + SurfaceMeta, SurfaceSnapshot, TodoList } from './contracts' @@ -58,6 +59,8 @@ export interface UIState { rightTab: 'work' | 'files' /** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */ filesView: 'list' | 'preview' + /** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */ + workspaceSurfacePin: string | null /** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */ inboxFilter: 'all' | 'todo' theme: ThemeChoice @@ -99,6 +102,8 @@ interface AppState { artifacts: Artifact[] /** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */ surfaces: SurfaceSnapshot[] + /** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */ + projectSurfaces: SurfaceMeta[] cards: ActivityCard[] projectCards: ActivityCard[] inboxScope: 'conv' | 'project' @@ -125,7 +130,7 @@ const DEFAULT_UI: UIState = { rightWidth: 360, rightCollapsed: false, rightTab: 'work', - filesView: 'list', +filesView: 'list', workspaceSurfacePin: null, inboxFilter: 'all', activeWorkspace: null, theme: 'dark', @@ -168,7 +173,7 @@ let state: AppState = { contextObs: {}, projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, - plan: null, todos: null, artifacts: [], surfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], + plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], ui: loadUI(), connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, @@ -374,7 +379,7 @@ 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: [], surfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false }) + set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false }) persistConvId(convId) // 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。 if ((state.previewTabs['_'] ?? []).length > 0) { @@ -385,6 +390,7 @@ export const store = { }) } // U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。 + api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {}) api.surfaceList(convId).then(list => { if (state.currentConvId !== convId) return set({ surfaces: list ?? [] }) diff --git a/src/surface.test.ts b/src/surface.test.ts index 2e14516..5347206 100644 --- a/src/surface.test.ts +++ b/src/surface.test.ts @@ -57,3 +57,21 @@ describe('surface.updated 可见性联动', () => { expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true) }) }) + +describe('U6 工作区任务页', () => { + it('pin 固定后新页面事件不改变固定对象;取消后跟随最新', () => { + store.setUI({ workspaceSurfacePin: null }) + store.applyEvent(surfEvent(201, 'w1', 1, '第一页')) + const first = store.getState().ui.workspaceSurfacePin + expect(first).toBeNull() // 默认跟随(不自动 pin) + // 用户固定 w1 + store.setUI({ workspaceSurfacePin: 'w1' }) + store.applyEvent(surfEvent(202, 'w2', 1, '第二页')) + // pin 不变,且 surfaces 数组两页并存(禁用回退时容器随空数组自然消失) + expect(store.getState().ui.workspaceSurfacePin).toBe('w1') + const w2 = store.getState().surfaces.find(x => String(x.identity.surfaceId) === 'w2') + expect(w2?.spec.title).toBe('第二页') + store.setUI({ workspaceSurfacePin: null }) + expect(store.getState().ui.workspaceSurfacePin).toBeNull() + }) +}) -- 2.54.0