From 4374aa656bdfda312fd3d39c52cb27f198b1c583 Mon Sep 17 00:00:00 2001 From: dommaker Date: Fri, 11 Sep 2026 14:36:03 +0800 Subject: [PATCH 01/19] =?UTF-8?q?fix(web):=20=E9=A2=91=E9=81=93=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=A6=96=E6=8B=89=E5=A4=B1=E8=B4=A5=E5=91=88=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=80=81=20+=20=E9=87=8D=E8=AF=95=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=91=88=E5=81=87=E7=A9=BA=E6=80=81?= =?UTF-8?q?=20(#482)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useChannelMessages 暴露 error 态:首拉/兜底轮询共用 fetchMessages, 失败置 error(已加载消息保留),成功/频道切换清零 - ChannelDetailPage 三态分流:加载中骨架 / 加载失败(错误态 + 重试钮 走 refresh)/ 真空频道(空态文案 + 示例 chip);已有消息时轮询失败 不整屏替换 - 测试:hook 层首拉失败→error、refresh 重试恢复、兜底轮询失败同口径、 频道切换重置;页面层错误态渲染、重试按钮、有消息时不整屏替换 --- apps/web/src/CONTEXT.md | 2 +- .../hooks/__tests__/useChannelEvents.test.ts | 67 +++++++++++++++++++ apps/web/src/hooks/useChannelEvents.ts | 8 ++- apps/web/src/pages/ChannelDetailPage.tsx | 12 +++- .../__tests__/ChannelDetailPage.test.tsx | 52 ++++++++++++++ 5 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index 2b9e158c..d0802c25 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -88,7 +88,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **未归属过滤(#405,消费 #428 API)**:WorkUnitListPage 状态 pill 行新增「未归属」pill——服务端过滤(store `unattributedOnly` → `attributed=false` 参数,与 status/type 过滤交集组合),计数徽标 = 服务端 pagination.total(`unattributedTotal`,挂载/重连经 `loadUnattributedCount` limit=1 轻量拉取;loadWorkUnits 仅在无 status/type 组合过滤时顺带同步,组合过滤下 total 是交集计数不得覆盖徽标;best-effort 失败留旧值,null 不显数字)。`applyWorkunitEvent` 过滤匹配新增归属维度:过滤态下 SSE 增量行须过 `isUnattributedWu`(无 reqId 且戳 pmoId‖legacy ownershipProjectId 为 null,与服务端 parseWuPmoId 同口径的本地镜像)。 - **WorkUnit 列表页行形态**(2026-09 UI 丝滑化 Step 2,docs/plans/2026-09-ui-smoothness.md):筛选合一——第二套 pill 筛选行已删,统计条改可点击 `StatChip`(总数/待确认/待领取/进行中/待验收/待人工,点击过滤/再点回全部);行从 card 边框块改 `.wu-row` 无边框行(行间细分隔线 + 左侧 3px 状态色条 + hover 微底色)。样式独立文件 `styles/workunits.css`(页面内 import),状态色与 `WU_STATUS_COLORS` 语义一致。**E2 页面重设计**(docs/plans/2026-09-page-redesign.md):整行展开区(MetaStrip 9 字段/metadata toggle/ExecutionSteps/DiscussionPanel/ReviewHint)已删——行点击直跳 `/workunits/:id` 详情页(**2026-09-10 起抽屉形态已撤**:列表行原挂的 `WorkUnitDrawer` 与行尾「↗」深链均已删,行本体即唯一入口,本页零 Drawer 引用),行内闸门按钮(WuGateActions)保留作快速处置;分页 E2-5 = 底部「加载更多」追加式(store `loadMoreWorkUnits` 拉 page+1 按 id 去重拼接)+「已加载 X / 共 N」(共 N = pagination.total,与「总数」chip 同口径)。**批次 D-1.6 视觉试点**(docs/plans/2026-09-ui-interaction-polish.md,纯视觉层不动数据流):① StatChip 数字档归 `--fs-title`(16px)+font-mono(`--fs-stat` 按 style-guide §2.3 仅统计卡大数字用,chip 非统计卡;label 保持 `text-sm`=--fs-sm),激活 chip 追加 accent 底线(border-bottom 2px var(--accent-primary) + padding-bottom 4→3 补偿防抖动),#472 颜色语义不动;② 「待人工」行级提权——`derived.needsHuman`(in_review ‖ done 缺 l3,与统计 chip 同口径)行加 `.wu-row-human` 类,整行 `--warning-dim` 底色,hover 仍走 `--bg-hover` 不透明覆盖提亮(显式 `.wu-row.wu-row-human:hover` 规则锁叠加顺序);行 hover 即 `--bg-hover`(无私有 hover 值)。**批次 D-2 项4 标题搜索**(docs/plans/2026-09-ui-interaction-polish.md):页头 chip 行下加 `.input` 搜索框,300ms 防抖(LibraryPage 先例:debounceRef + 跳首次 effect)进 store `searchQuery`(`setSearchQuery` 归一 trim/空白→null,词未变 no-op 不重拉),loadWorkUnits/loadMoreWorkUnits 带 `q` 走服务端过滤(与 status/type/attributed 交集,total 即过滤后计数,「已加载 X / 共 N」随过滤自然变化);`applyWorkunitEvent` 的 matches 增 q 维度(scope 大小写不敏感子串,与服务端同口径——搜索态 SSE 增量不匹配不插入/不匹配即移除);搜索激活时 chip 行「计数口径」标注同 #472 规则出现。 - **「已沉睡」徽标 StaleSleepBadge**(#464,components/workunit/StaleSleepBadge.tsx,挂列表行 + 详情页头):72h 认领陈旧守卫命中的 WU 此前仍显示「待领取」。判定与后端 monitor-probes 同源——`metadata.staleGuardBlockedAt === updatedAt` 且 unassigned(守卫落盘的沉睡锚点);任何写刷新 updatedAt 即复活、徽标自消,无额外同步机制。频道侧出声由后端 stale_claim_guard 探针发 milestone 提醒。 -- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 +- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。**useChannelMessages 暴露 `error` 态**(#482):首拉/兜底轮询共用 fetchMessages,失败置 error(消息不清空),成功/频道切换清——ChannelDetailPage 据此三态分流:加载中骨架 / error 且 messages 空 → 错误态+重试钮(走 refresh)/ 真空态示例 chip;已有消息时轮询失败不整屏替换。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 - **一次性拉取 `useAsyncData`**(#350,架构评审候选 F5):useGatedPoll 之外的姊妹 hook,吃掉 `useState(data/loading/error)+useCallback(load)+useEffect(load)` 样板——6 处消费方归一(MonitoringPage / NeedsAttentionSection / ProjectDetailPage / PMOPage / KnowledgePage / AgentDetailPage 历史窗口)。语义:挂载首拉(微任务推迟,纪律同 useGatedPoll)+ deps 渲染期重置(切参当帧清数据置回加载,prevX 微模式内建)+ `reload()`(重拉保留旧数据、即清 error,刷新按钮/事件路径复用)+ `setData`(SSE 就地更新绕过 fetch 生命周期)。约定:**错误上屏与静默的分工留在 fetcher**——主拉取直接 reject(hook 落 `error.message`,要友好文案就在 fetcher 内 `throw new Error(...)`),best-effort 子拉取 fetcher 内自 catch 落 null(hook error 不上屏)。依赖驱动级联:ProjectDetailPage 子拉取 deps=[project.data](身份变化即级联重拉,替代原 loadData 全量串行)。新的一次性拉取一律复用,禁止再写裸 useState+useEffect 组合。已知行为差:拉取失败后成功刷新会清 error(原 MonitoringPage 永不清理的潜在 bug 顺带修正);ProjectDetail 子拉取由串行改并行(首屏更快,pop-in 顺序不变)。 - **SSE 负载消费约定·message_updated**(#315,ADR 2026-08-24 D1/D2):`useChannelMessages` 处理 `channel.message_updated` 优先读负载 `message` 全量本体(meta 为后端合并后真值)整体原位替换;旧形状(无 `message` 字段)回退顶层增量 `content`/`meta` patch。`createdAt` 为不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable),原位替换与刷新归位因此恒一致。 - **SSE 负载消费约定·instance status_changed**(#312,ADR 2026-08-24 体检;#313 修订):`agent.instance.status_changed` 负载带 `currentWorkUnit` 快照/`channelId`/`lastError` 系字段——#346 起就地更新唯一一份在 `rosterStore.applyInstanceStatusEvent`:以负载快照为准(含悬空 null),仅旧事件(无快照字段)回退补查;instanceId ‖ roleId 匹配(error 事件带新 instanceId 时RoleId 兜底并切 id),**未匹配实例 = 新角色实例,用负载合成条目插头部**(轮询不承担发现职责);原 useAgentRoster/ChannelRail 两副本已删。消费方 selector 订阅(roster 卡片/ChannelRail 状态点/AgentDetailPage),online 计数等派生面各自 useMemo。30s 轮询经 useRosterStoreSync→useGatedPoll 保留为 SSE 断开兜底。 diff --git a/apps/web/src/hooks/__tests__/useChannelEvents.test.ts b/apps/web/src/hooks/__tests__/useChannelEvents.test.ts index 4b8b9ae7..7e68d1b6 100644 --- a/apps/web/src/hooks/__tests__/useChannelEvents.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelEvents.test.ts @@ -390,3 +390,70 @@ describe('useChannelMessages(#313 门禁轮询接线)', () => { expect(mockListMessages).toHaveBeenLastCalledWith('ch-2'); }); }); + +// #482:首拉/兜底轮询失败暴露 error 态(原仅 console.error → 页面把加载故障渲染成假空态) +describe('useChannelMessages 错误态(#482)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCtx.status = 'connected'; + mockOnEvent.mockReturnValue(() => {}); + }); + + it('首拉失败:error 置位、loading 落位、messages 保持空', async () => { + mockListMessages.mockRejectedValue(new Error('network down')); + const { result } = renderHook(() => useChannelMessages('ch-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeTruthy(); + expect(result.current.messages).toHaveLength(0); + }); + + it('重试(refresh)成功:error 清空,消息正常进入列表', async () => { + mockListMessages.mockRejectedValueOnce(new Error('network down')); + const { result } = renderHook(() => useChannelMessages('ch-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeTruthy(); + + const m1 = msg('m1'); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + await act(async () => { + await result.current.refresh(); + }); + expect(result.current.error).toBeNull(); + expect(result.current.messages.map(m => m.id)).toEqual(['m1']); + }); + + it('兜底轮询失败同口径:error 置位,已加载消息保留', async () => { + // 走 #313 兜底路径:SSE 断开 → 10s 周期拉取与首拉共用 fetchMessages,失败同一 error 口径 + vi.useFakeTimers(); + try { + mockCtx.status = 'disconnected'; + const m1 = msg('m1'); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + const { result } = renderHook(() => useChannelMessages('ch-1')); + await act(async () => { await Promise.resolve(); }); + expect(result.current.messages).toHaveLength(1); + expect(result.current.error).toBeNull(); + + mockListMessages.mockRejectedValueOnce(new Error('blip')); + await act(async () => { vi.advanceTimersByTime(10000); await Promise.resolve(); }); + expect(result.current.error).toBeTruthy(); + expect(result.current.messages.map(m => m.id)).toEqual(['m1']); + } finally { + vi.useRealTimers(); + } + }); + + it('频道切换重置 error(旧频道故障不污染新频道)', async () => { + mockListMessages.mockRejectedValueOnce(new Error('network down')); + const { result, rerender } = renderHook(({ id }) => useChannelMessages(id), { initialProps: { id: 'ch-1' } }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeTruthy(); + + const b1 = msg('b1', { channelId: 'ch-2' }); + mockListMessages.mockResolvedValue({ data: { data: [b1], hasMore: false } }); + rerender({ id: 'ch-2' }); + expect(result.current.error).toBeNull(); + await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['b1'])); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/useChannelEvents.ts b/apps/web/src/hooks/useChannelEvents.ts index e6708a2f..80843706 100644 --- a/apps/web/src/hooks/useChannelEvents.ts +++ b/apps/web/src/hooks/useChannelEvents.ts @@ -39,6 +39,8 @@ export function useChannelMessages(channelId: string | undefined, options?: UseC const [messages, setMessages] = useState([]); // 初值覆盖挂载首拉;channelId undefined→defined 的上升沿由下方渲染期分支补齐 const [loading, setLoading] = useState(!!channelId); + // #482:首拉/兜底轮询失败暴露 error 态(原仅 console.error → 页面把加载故障渲染成假空态) + const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(false); const { onEvent } = useWebSocketContext(); @@ -48,6 +50,7 @@ export function useChannelMessages(channelId: string | undefined, options?: UseC if (prevChannelId !== channelId) { setPrevChannelId(channelId); if (channelId) setLoading(true); + setError(null); } // #328:记录当前已完成首拉的频道——同频道 refetch 走合并,首拉/频道切换仍替换 @@ -77,8 +80,11 @@ export function useChannelMessages(channelId: string | undefined, options?: UseC setHasMore(res.data.hasMore); loadedChannelRef.current = channelId; } + setError(null); } catch (err) { console.error('[Channel] Failed to fetch messages', err); + // #482:error 态供页面区分「加载失败(可重试)/ 真空频道」;已加载消息不清空 + setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } @@ -211,5 +217,5 @@ export function useChannelMessages(channelId: string | undefined, options?: UseC if (plan.hydrateBefore) scheduleHydration(plan.hydrateBefore); }, [scheduleHydration]); - return { messages, loading, hasMore, sendMessage, loadMore, refresh: fetchMessages, syncPruning }; + return { messages, loading, error, hasMore, sendMessage, loadMore, refresh: fetchMessages, syncPruning }; } diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 02a66a0f..9922f75f 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -100,7 +100,7 @@ export function ChannelDetailPage() { // #393:记录最近访问频道(/ 与 /channels 重定向落点,spec §2) useEffect(() => { if (id) saveLastChannelId(id); }, [id]); const [channel, setChannel] = useState(null); - const { messages, loading, sendMessage, loadMore, hasMore, refresh, syncPruning } = useChannelMessages(id); + const { messages, loading, error, sendMessage, loadMore, hasMore, refresh, syncPruning } = useChannelMessages(id); const [sending, setSending] = useState(false); // 折叠 UI 状态(showCompleted / collapsedThreads / expandedProcGroups)按频道持久化(Step 3), // setter 语义同 useState;线程默认全部展开,collapsedThreads 只存手动收起的锚点 id @@ -841,7 +841,15 @@ export function ChannelDetailPage() { // 批次 F-3:消息流首拉骨架(批次 E-2 ui/Skeleton 正本)——消息行形态 )} - {!loading && messages.length === 0 && ( + {!loading && error && messages.length === 0 && ( + // #482:首拉/兜底轮询失败——错误态 + 重试入口,与真空频道区分(原呈假空态, + // 用户会把加载故障误判为空频道);已有消息时轮询失败不整屏替换,消息流保留 +
+

消息加载失败

+ +
+ )} + {!loading && !error && messages.length === 0 && (

发送消息开始对话

@Agent 提及 Agent 创建任务

diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx index 03ae6e87..6eaff8d5 100644 --- a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx +++ b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx @@ -42,6 +42,7 @@ vi.mock('../../hooks/useChannelEvents', async () => { return { messages: msgs, loading: false, + error: currentError, hasMore: more, sendMessage: mockSendMessage, loadMore: () => mockLoadMore(setMsgs, setMore), @@ -180,6 +181,9 @@ let currentMessages: ChannelMessage[] = MESSAGES; let currentHasMore = false; const mockLoadMore = vi.fn(); +// #482:mock 的首拉 error 态(null = 正常;非 null = 加载失败,页面应渲染错误态 + 重试入口) +let currentError: string | null = null; + // #242:onEvent 注册的 SSE 处理器(用例手工驱动事件); // 批 2(决策 5/6)后页面有多个订阅方(live 状态条 / waitingWus chip / REQ chips)→ 收集全部处理器统一派发 type SseHandler = (msg: { event_type: string; data?: unknown }) => void; @@ -213,6 +217,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { window.localStorage.clear(); currentMessages = MESSAGES; currentHasMore = false; + currentError = null; // toast.dismiss() 是 200ms 动画后异步移除——有残留时等其落定,防跨用例 toast 文本污染断言 toast.dismiss(); if (document.getElementById('toast-container')?.childElementCount) { @@ -1402,6 +1407,7 @@ describe('ChannelDetailPage — 空频道态示例提示 chip(视觉批次 2 window.localStorage.clear(); currentMessages = []; // 空频道 currentHasMore = false; + currentError = null; sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( @@ -1441,3 +1447,49 @@ describe('ChannelDetailPage — 空频道态示例提示 chip(视觉批次 2 expect(Number(screen.getByTestId('channel-input').getAttribute('data-prefill-nonce'))).toBe(before + 1); }); }); + +// #482:消息首拉失败——渲染错误态 + 重试入口,与真空频道区分(原呈「发送消息开始对话」假空态) +describe('ChannelDetailPage — 消息加载失败错误态(#482)', () => { + const CHANNEL = { data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }; + + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + currentMessages = []; + currentHasMore = false; + currentError = 'network down'; + sseHandlers = []; + useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); + mockApiGet.mockImplementation((url: string) => Promise.resolve( + String(url).endsWith('/suggestions') ? { data: { data: { currentWuId: null, suggestions: [] } } } : CHANNEL, + )); + mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( + params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, + )); + mockOnEvent.mockImplementation((cb: SseHandler) => { sseHandlers.push(cb); return () => {}; }); + mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); + reconnectHandlers = []; + mockListReqs.mockResolvedValue({ data: { data: [] } }); + mockSendMessage.mockResolvedValue({}); + }); + + it('加载失败 → 错误态 + 重试按钮,不渲染空态文案/示例 chip', async () => { + renderPage(); + await screen.findByText('消息加载失败'); + expect(screen.queryByText('发送消息开始对话')).toBeNull(); + expect(screen.getByRole('button', { name: '重试' })).toBeTruthy(); + }); + + it('点击重试 → 走 hook refresh 重新拉取', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: '重试' })); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('已有消息时轮询失败:消息流保留,不整屏替换为错误态', async () => { + currentMessages = MESSAGES; + renderPage(); + await waitFor(() => expect(screen.queryByText('消息加载失败')).toBeNull()); + expect(screen.getByText((MESSAGES[0] as { content: string }).content)).toBeTruthy(); + }); +}); From 067885c5dc27c653180d8b986b97774ca5c72267 Mon Sep 17 00:00:00 2001 From: dommaker Date: Fri, 11 Sep 2026 14:54:50 +0800 Subject: [PATCH 02/19] =?UTF-8?q?fix(web):=20=E5=BE=85=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=20chip=20=E5=AE=9A=E4=BD=8D=E5=A4=8D=E7=94=A8=20#439=20?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=E5=AE=9A=E4=BD=8D=E5=BE=AA=E7=8E=AF=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E9=97=AE=E6=8E=89=E5=87=BA=E5=88=86=E9=A1=B5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=9D=99=E9=BB=98=20(#483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽 pageBackToFind 共用翻页定位循环(found/cancelled 回调 + 三态返回),highlight 路径与 chip 路径共用 - chip 定位:提问在已加载页 → 直接高亮(行为不变);掉出分页 → 翻页定位(上限 10 页);翻不到 → toast 兜底 - 测试覆盖三态:已加载(既有用例)/ 未加载可翻到 / 翻页到底翻不到 --- apps/web/src/CONTEXT.md | 2 +- apps/web/src/pages/ChannelDetailPage.tsx | 96 ++++++++++++++----- .../__tests__/ChannelDetailPage.test.tsx | 56 ++++++++++- 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index d0802c25..bd083cb5 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -131,7 +131,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **设置域 E8 页面重设计**(2026-09,docs/plans/2026-09-page-redesign.md):Settings 页标题/三节标题去 emoji,ThemeSettings 主题卡圆角归 4px 卡片档(`rounded-xl`→`rounded`);「默认执行机器」保存成功加轻反馈(Select 旁「✓ 已保存」2s 定时淡出,失败仍 toast)+ 机器名直链 `/workspaces/:id`(WorkspacePage 唯一站内入口,仅 Admin 拿到 workspaces 清单时呈现,非 Admin 降级视图不出);WorkspacePage 骨架归 §4.7(`u-page-bg`/`u-page-head`/`page-title`/`max-w-5xl`)、runtime 行卡自造 `rounded-lg` 归 `.card`、「设为角色」自造按钮归 `btn btn-primary btn-sm`、删硬编码「0 个角色」假数据(无按 runtime 的角色计数现成接口,按方案兜底直接删)。**E8-3 RolesSetup 僵尸路由清理**:`/setup/roles` 路由与 `pages/RolesSetup.tsx`(+测试)已删——主路径 = CreateRoleModal(#397 §6.4),onboarding 的 FirstRoleSetupModal 是 App 根弹框不依赖该路由,ReviewHint `onSetupClick`(E2 起在 WorkUnitDrawer 闸门区上方)链 `/agents`;截图采集配置 `scripts/visual/config.ts` 同步移除 setup-roles 条目。**E8-4 创建角色表单合一(已做)**:WorkspacePage 内嵌创建角色 dialog 已删,复用 CreateRoleModal 正本——新增可选 `presetProvider` prop(additive,AgentDashboardPage 消费路径不变),传 preset 时跳过 runtime 清单拉取、单项固定预选、provider 只读;WorkspacePage 原「创建成功留框内联提示」差异行为归一为 §6.4 正本行为(关弹框 + onCreated),字段口径不变(name/description?/provider → channelApi.createAgent)。 - **频道翻页游标(#319,2026-08-24)**:`useChannelMessages.loadMore` 的 `before` = 最老消息 **id**(原 createdAt 时间戳,同毫秒多条会漏/重);后端锚点 id 不存在时返回空页 + hasMore=false,loadMore 据此自然停止 - **DeliveryPanel branch-only 标记已交付(#469,2026-09-10)**:branch-only 且未交付时,台账区除「证据已齐请合并分支」提示(deliverable 时)外,常驻 commit 哈希输入 + 「标记已交付」按钮(`projectApi.markDelivered`,空 commit 禁用;409 走 toast error.message;成功清输入 + onRefresh)——系统外合并后人工落档,台账不再永停「✓ 可交付」。 -- **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影,本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区)、TriageBanner(未读 critical incident 通知投影——设计稿 §3 仅 critical 突破成横幅,warning 级留铃铛面板;severity 从 content 首行 `severity:` 解析,「关闭」= 逐条 markRead 持久化)、MoreDropdown 徽标(= unreadCount,原 monitoringApi 24h 计数已删)全部为同一数据源投影。**SSE 只作失效触发**:NotificationBell 上 channel.message_sent atHuman / workunit.status_changed → `load()` 重拉,SSE 不再直接入列。**打开频道即读**:ChannelDetailPage 进页 `markChannelRead(id)`,只清 channelId 匹配的未读(逐条 POST + unreadCount 乐观递减),其他频道不动。**点击直达消息**:频道分支跳转带 `?highlight=`,页面消费一次后交既有 highlightId 机制滚动定位+高亮 2s(目标在折叠线程先展开);后端通知 link 带消息粒度(#439),messageId 经 link 的 `?highlight=` 解析;目标掉出首页分页时沿 #319 翻页游标向前翻页定位(上限 10 页),翻到底/超限 → toast 可见反馈不静默;定位跳转先经 `unpinFromBottom` 解钉(#439 真机走查实测),highlight 目标是降级骨架行时骨架同样带 mc-msg-highlight。通知跳转优先级 #468 起为 WU > 频道(?highlight=) > PMO。**标题闪烁定时器**与行动中心挂钩:startFlash 开新必清旧(曾闭包单变量被覆盖导致 interval 永久泄漏闪烁),停止条件 = unreadCount + stateItems.length 归零/卸载(曾纯 10s 定时、全部已读后照闪)。**重连对齐**(#415,ADR D3):NotificationBell 注册 `onReconnect → load()`。**批次 D-2 处理待办流程断链修复**(docs/plans/2026-09-ui-interaction-polish.md 第一轮 1/2/3 项):① reply 深链——后端 stateItems 补 additive `messageId`(= 该 WU 频道线程最新非人类消息,与 chip「当前提问消息」#279 同口径),reply 点击跳频道带 `?highlight=`(messageId 缺失 fail-closed 不拼参数、无 channelId 仍落 WU 详情);② review/confirm 就地化——点击不再整页跳 `/workunits/:id`,改开 NotificationBell 自挂的 WorkUnitDrawer 实例(lazy 加载 + `.ac-drawer-host` 全断点 fixed 覆盖宿主,与频道页/列表页 drawer 状态机完全隔离;↗ 详情页深链入口随本次补进抽屉 subject 行);③ 下一个待办——闸门动作成功(当前 WU 掉出 stateItems 池 = 状态变即消语义反用)后抽屉内出「下一个 →」同池(review+confirm)导航/「待办都处理完了」收口;reply 线在频道页处理,由 NotificationBell 追踪点击过的 wuId、掉出池时 toast(toast 新增可选 `action` 行动按钮)给「下一个 →」跳下一条频道 | +- **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影,本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮,#483 起提问掉出已加载分页时复用 #439 翻页定位循环——上限 10 页,翻到底/超限 → toast 兜底不静默)、TriageBanner(未读 critical incident 通知投影——设计稿 §3 仅 critical 突破成横幅,warning 级留铃铛面板;severity 从 content 首行 `severity:` 解析,「关闭」= 逐条 markRead 持久化)、MoreDropdown 徽标(= unreadCount,原 monitoringApi 24h 计数已删)全部为同一数据源投影。**SSE 只作失效触发**:NotificationBell 上 channel.message_sent atHuman / workunit.status_changed → `load()` 重拉,SSE 不再直接入列。**打开频道即读**:ChannelDetailPage 进页 `markChannelRead(id)`,只清 channelId 匹配的未读(逐条 POST + unreadCount 乐观递减),其他频道不动。**点击直达消息**:频道分支跳转带 `?highlight=`,页面消费一次后交既有 highlightId 机制滚动定位+高亮 2s(目标在折叠线程先展开);后端通知 link 带消息粒度(#439),messageId 经 link 的 `?highlight=` 解析;目标掉出首页分页时沿 #319 翻页游标向前翻页定位(上限 10 页),翻到底/超限 → toast 可见反馈不静默;定位跳转先经 `unpinFromBottom` 解钉(#439 真机走查实测),highlight 目标是降级骨架行时骨架同样带 mc-msg-highlight。通知跳转优先级 #468 起为 WU > 频道(?highlight=) > PMO。**标题闪烁定时器**与行动中心挂钩:startFlash 开新必清旧(曾闭包单变量被覆盖导致 interval 永久泄漏闪烁),停止条件 = unreadCount + stateItems.length 归零/卸载(曾纯 10s 定时、全部已读后照闪)。**重连对齐**(#415,ADR D3):NotificationBell 注册 `onReconnect → load()`。**批次 D-2 处理待办流程断链修复**(docs/plans/2026-09-ui-interaction-polish.md 第一轮 1/2/3 项):① reply 深链——后端 stateItems 补 additive `messageId`(= 该 WU 频道线程最新非人类消息,与 chip「当前提问消息」#279 同口径),reply 点击跳频道带 `?highlight=`(messageId 缺失 fail-closed 不拼参数、无 channelId 仍落 WU 详情);② review/confirm 就地化——点击不再整页跳 `/workunits/:id`,改开 NotificationBell 自挂的 WorkUnitDrawer 实例(lazy 加载 + `.ac-drawer-host` 全断点 fixed 覆盖宿主,与频道页/列表页 drawer 状态机完全隔离;↗ 详情页深链入口随本次补进抽屉 subject 行);③ 下一个待办——闸门动作成功(当前 WU 掉出 stateItems 池 = 状态变即消语义反用)后抽屉内出「下一个 →」同池(review+confirm)导航/「待办都处理完了」收口;reply 线在频道页处理,由 NotificationBell 追踪点击过的 wuId、掉出池时 toast(toast 新增可选 `action` 行动按钮)给「下一个 →」跳下一条频道 | - **`.mc-ws button` 全局重置已 `:where()` 降权**(#431,2026-09-01;原 #375 待清理项了断):mission-control.css 头部重置从 `.mc-ws button`(0-1-1)改 `:where(.mc-ws) button`(0-0-1)——原先被吞的 20 条单类按钮规则(`.mc-btn-primary` 背景、`.mc-icon-btn`/`.mc-drawer-close` 等的 color/padding,清单 = `.studio/research/2026-08-31-style-hygiene.md` §2)自此**首次生效**;`cursor: pointer` 仍由重置承担(TW v4 preflight 不含 button cursor)。mc-* 壳内按钮的单类样式不再需要双类复合绕行(既有绕行保留未清)。 - **共享结构类**(#431):`u-page-bg`(页面骨架底)/ `u-page-head`(页头条 padding+hairline)/ `u-btn-reset`(裸按钮重置,不含 font/color——preflight 已继承,避免压类上排类)住 mission-control.css 尾部块;`u-surface-0` = `--bg-secondary`(surface 档位:-0 secondary < 默认 elevated < -2 tertiary),登记于 style-guide §4.5/§4.7。**E5 增 `u-tab`/`u-tab-active`**:tab 形态正本(批次 D-4 定 Knowledge border-b 形态)激活态 accent 底线收进类,禁止再写内联 borderBottom。 - **测试 setup 禁用 `@testing-library/jest-dom/vitest` 入口**(2026-09-02,commit 0f1175c1):jest-dom 未声明 vitest peer,该入口被外部化后经 Node 解析向上命中根 node_modules 的另一 peer 变体 vitest 副本(本仓 pnpm monorepo 4 个变体共存),`@vitest/snapshot` 单例分裂 → 一切 `toMatchSnapshot` 报 "snapshot state not found"(vitest#7430/#7668;`deps.inline`/`server.deps.inline` 补救无效)。`src/test/setup.ts` 用官方等价写法 `expect.extend(jestDomMatchers)`(matchers 来自 `@testing-library/jest-dom/matchers`),matcher 集合同等。新增 setup 导入时同理警惕:任何经 node_modules 外部化传递 `import 'vitest'` 的入口都可能触发变体分裂。 diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 9922f75f..0adf8099 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -43,6 +43,17 @@ import { toast } from '../utils/toast'; /** #439:?highlight 定位的翻页页数上限(50 条/页 → 最多回看 500 条),超限/翻到底降级为可见反馈 */ const HIGHLIGHT_LOCATE_MAX_PAGES = 10; +/** #483:某 WU 的当前提问消息 = 该 WU 最新一条非人类消息(与 latestQuestionIdByWu 同口径)。 + * chip 翻页定位循环需读最新快照(memo 值在异步循环里是旧闭包),故抽纯函数共用 */ +function latestQuestionMessageOf(msgs: ChannelMessage[], wuId: string): ChannelMessage | null { + let best: ChannelMessage | null = null; + for (const m of msgs) { + if (m.workUnitId !== wuId || m.authorType === 'human') continue; + if (!best || new Date(m.createdAt).getTime() >= new Date(best.createdAt).getTime()) best = m; + } + return best; +} + /** 视觉批次 2 ⑥:空频道态示例提示——点击走既有 prefill 通道填入输入框(不自动发送)。 * 文案按产品 agent 命名风格(pm-agent / dev-agent / reviewer-agent),仅作起点提示,用户可改 */ const EMPTY_EXAMPLE_PROMPTS = [ @@ -491,15 +502,59 @@ export function ChannelDetailPage() { }, [setCollapsedThreads]); // #279(决策 #250 D4):chip 点条目 → 滚动定位到该 WU 当前提问消息并高亮(2s 后消退)。 - // 提问消息若埋在被用户收起的线程里,先把所属线程展开 const [highlightId, setHighlightId] = useState(null); + // 翻页定位循环是异步长任务,经 ref 读最新快照,避免闭包锁旧值(#439 引入,#483 起 chip 定位复用) + const locateSnapshotRef = useRef({ messages, hasMore, loadMore }); + locateSnapshotRef.current = { messages, hasMore, loadMore }; + + /** #439 翻页定位循环(#483 起 chip 定位复用):沿 #319 翻页游标向前翻,直到 found() 命中 / + * 翻到底 / 超 HIGHLIGHT_LOCATE_MAX_PAGES / 翻页无新内容;cancelled() 为真则放弃(无终局反馈) */ + const pageBackToFind = useCallback(async ( + found: () => boolean, + cancelled: () => boolean, + ): Promise<'found' | 'cancelled' | 'exhausted'> => { + for (let page = 0; page < HIGHLIGHT_LOCATE_MAX_PAGES; page++) { + if (cancelled()) return 'cancelled'; + if (found()) return 'found'; + if (!locateSnapshotRef.current.hasMore) break; // 翻到底 + const prepended = await locateSnapshotRef.current.loadMore(); + if (!prepended) break; // 翻页失败/无新内容,终止防空转 + // loadMore resolve 时 React 尚未提交新快照——让出一个 macrotask 等 ref 刷新, + // 否则下一轮判空读旧快照会多翻一页(目标恰在末页时甚至可能误报不可达) + await new Promise(resolve => setTimeout(resolve, 0)); + } + if (cancelled()) return 'cancelled'; + return found() ? 'found' : 'exhausted'; + }, []); + + // 提问消息若埋在被用户收起的线程里,先把所属线程展开; + // #483:提问掉出已加载分页 → 复用 #439 翻页定位循环;翻到底/超限/无新内容 → toast 兜底,不静默 + const chipLocatingRef = useRef(null); const locateWaitingQuestion = useCallback((wuId: string) => { - const msgId = latestQuestionIdByWu.get(wuId); - if (!msgId) return; - const target = messages.find(m => m.id === msgId); - if (target?.replyToId) ensureThreadExpanded(target.replyToId); - setHighlightId(msgId); - }, [latestQuestionIdByWu, messages, ensureThreadExpanded]); + const loaded = latestQuestionMessageOf(locateSnapshotRef.current.messages, wuId); + if (loaded) { + if (loaded.replyToId) ensureThreadExpanded(loaded.replyToId); + setHighlightId(loaded.id); + return; + } + if (chipLocatingRef.current === wuId) return; // 同 WU 防重入 + chipLocatingRef.current = wuId; + void (async () => { + const result = await pageBackToFind( + () => latestQuestionMessageOf(locateSnapshotRef.current.messages, wuId) !== null, + () => chipLocatingRef.current !== wuId, + ); + if (result === 'cancelled') return; + chipLocatingRef.current = null; + const target = latestQuestionMessageOf(locateSnapshotRef.current.messages, wuId); + if (target) { + if (target.replyToId) ensureThreadExpanded(target.replyToId); + setHighlightId(target.id); + } else { + toast.warning('该消息太旧或已删除,无法定位'); + } + })(); + }, [ensureThreadExpanded, pageBackToFind]); // 通知中心点击直达(?highlight=):复用上方高亮定位机制,滚动到该消息并高亮 2s。 // 每个 mid 只消费一次(防消息流更新反复重置高亮);首拉未完成(loading)时等下一轮 messages。 @@ -509,9 +564,6 @@ export function ChannelDetailPage() { const highlightConsumedRef = useRef(null); /** #439:正在为哪个 mid 跑翻页定位循环(同 mid 防重入;定位成功/终局反馈后清空) */ const highlightLocatingRef = useRef(null); - // 翻页循环是异步长任务,经 ref 读最新快照,避免闭包锁旧值 - const locateSnapshotRef = useRef({ messages, hasMore, loadMore }); - locateSnapshotRef.current = { messages, hasMore, loadMore }; useEffect(() => { const mid = searchParams.get('highlight'); if (!mid || highlightConsumedRef.current === mid) return; @@ -529,24 +581,16 @@ export function ChannelDetailPage() { if (highlightLocatingRef.current === mid) return; highlightLocatingRef.current = mid; void (async () => { - for (let page = 0; page < HIGHLIGHT_LOCATE_MAX_PAGES; page++) { - if (highlightLocatingRef.current !== mid) return; // 已被上方分支定位/消费 - if (locateSnapshotRef.current.messages.some(m => m.id === mid)) return; // 已载入,交给 effect 定位 - if (!locateSnapshotRef.current.hasMore) break; // 翻到底 - const prepended = await locateSnapshotRef.current.loadMore(); - if (!prepended) break; // 翻页失败/无新内容,终止防空转 - // loadMore resolve 时 React 尚未提交新快照——让出一个 macrotask 等 ref 刷新, - // 否则下一轮判空读旧快照会多翻一页(目标恰在末页时甚至可能误报不可达) - await new Promise(resolve => setTimeout(resolve, 0)); - } - if (highlightLocatingRef.current !== mid) return; + const result = await pageBackToFind( + () => locateSnapshotRef.current.messages.some(m => m.id === mid), + () => highlightLocatingRef.current !== mid, + ); + if (result !== 'exhausted') return; // found → 交给上方分支定位;cancelled → 已被消费 highlightLocatingRef.current = null; - if (!locateSnapshotRef.current.messages.some(m => m.id === mid)) { - highlightConsumedRef.current = mid; - toast.warning('该消息太旧或已删除,无法定位'); - } + highlightConsumedRef.current = mid; + toast.warning('该消息太旧或已删除,无法定位'); })(); - }, [searchParams, messages, loading, ensureThreadExpanded]); + }, [searchParams, messages, loading, ensureThreadExpanded, pageBackToFind]); // 里程碑判定(不折叠):人类消息 / 卡片消息 / 等待回复 / 最后一条回复——已迁入 deriveStreamView(#322) diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx index 6eaff8d5..ade0eecc 100644 --- a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx +++ b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx @@ -678,9 +678,15 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', ...(question ? { waitingQuestion: question } : {}), since: iso(0), }); - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); currentMessages = FOLLOWUP_MESSAGES; + currentHasMore = false; + // toast.dismiss() 是 200ms 动画后异步移除——有残留时等其落定,防跨用例 toast 文本污染断言 + toast.dismiss(); + if (document.getElementById('toast-container')?.childElementCount) { + await new Promise(r => setTimeout(r, 250)); + } sseHandlers = []; useNotificationStore.setState({ stateItems: [replyItem('WU-3000', '使用 OAuth 还是账号密码?')], @@ -799,6 +805,54 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', }); expect(Element.prototype.scrollIntoView).toHaveBeenCalled(); }); + + it('#483:chip 定位——提问掉出已加载分页 → 自动翻页定位并高亮(与 highlight 路径同循环)', async () => { + // 提问 q-2 不在已加载页(空消息集),翻一页后载入并到底 + currentMessages = []; + currentHasMore = true; + mockLoadMore.mockImplementation(async ( + setMsgs: (fn: (prev: ChannelMessage[]) => ChannelMessage[]) => void, + setMore: (v: boolean) => void, + ) => { + setMsgs(prev => [...FOLLOWUP_MESSAGES, ...prev]); + setMore(false); + return true; + }); + + renderPage(); + await waitFor(() => expect(screen.getByText('待回复 · 1')).toBeTruthy()); + fireEvent.click(screen.getByText('待回复 · 1')); + fireEvent.click(screen.getByText('WU-3000')); + + await waitFor(() => { + const el = document.querySelector('[data-message-id="q-2"]'); + expect(el?.className).toContain('mc-msg-highlight'); + }); + expect(mockLoadMore).toHaveBeenCalledTimes(1); + // 目标已定位,无降级反馈 + expect(document.getElementById('toast-container')?.textContent ?? '').not.toContain('无法定位'); + }); + + it('#483:chip 定位——翻页到底仍无该 WU 提问 → toast 可见反馈,不静默', async () => { + currentMessages = []; + currentHasMore = true; + // 翻一页后到底(hasMore → false),WU-3000 的提问始终不存在 + mockLoadMore.mockImplementation(async (_setMsgs: unknown, setMore: (v: boolean) => void) => { + setMore(false); + return true; + }); + + renderPage(); + await waitFor(() => expect(screen.getByText('待回复 · 1')).toBeTruthy()); + fireEvent.click(screen.getByText('待回复 · 1')); + fireEvent.click(screen.getByText('WU-3000')); + + await waitFor(() => { + expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); + }); + expect(mockLoadMore).toHaveBeenCalledTimes(1); + toast.dismiss(); + }); }); // SSE 事件负载深化 批 2(决策 5/6):REQ chips 事件化,摘 messages.length 依赖; From 1356f1957bb0e0b384491cfce1335afafd686cc1 Mon Sep 17 00:00:00 2001 From: dommaker Date: Fri, 11 Sep 2026 15:05:28 +0800 Subject: [PATCH 03/19] =?UTF-8?q?fix(web):=20=E5=BC=95=E5=AF=BC=E7=89=87?= =?UTF-8?q?=20dismiss=20=E6=94=B6=E7=89=87=E7=B2=92=E5=BA=A6=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=AF=8F=E7=89=87=E7=8B=AC=E7=AB=8B=20=E2=9C=95=20?= =?UTF-8?q?=E5=8F=AA=E5=85=B3=E6=9C=AC=E7=89=87=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E6=B8=85=E5=85=A8=E9=83=A8=20(#484)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SuggestionChips:onDismiss 改上送被点片(item),每片(status/action/prompt 三形态) 各自带 ✕;✕ 不嵌片按钮,片 + ✕ 由 .mc-suggest-item 容器并排承载 - ChannelDetailPage:按片 dismissKey 单片记账(原循环清全部可见片);台账会话级 (刷新复活)语义不变——持久化需先改 #441 spec,另行提案 - 测试:组件侧锁每片独立 ✕ + 只上送本片;页面侧锁多片并存 dismiss 其一、 其余保留、被关片重拉不复活 --- apps/web/src/CONTEXT.md | 2 +- .../components/channel/SuggestionChips.tsx | 71 +++++++++++-------- .../__tests__/SuggestionChips.test.tsx | 18 ++++- apps/web/src/pages/ChannelDetailPage.tsx | 13 ++-- .../__tests__/ChannelDetailPage.test.tsx | 32 ++++++++- apps/web/src/styles/mission-control.css | 3 +- 6 files changed, 98 insertions(+), 41 deletions(-) diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index bd083cb5..be8f35f1 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -66,7 +66,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `mergeStepEvents` / `lastProgressEntry` / `formatStepTokens` | `utils/executionSteps.ts` | 执行步共享纯函数(#396 自 ExecutionSteps 抽取,ExecutionFlow 共用):步事件并集合并 / progressLog 末条 / token k 缩写 | | `renderSuggestionCopy` | `utils/suggestionCopy.ts` | #443(spec #441)引导文案模板注册表:后端建议端点只回 `{id,kind,params}` 结构化数据,文案全部经本注册表渲染(说人话、带工单上下文、无内部术语;快照测试锁定);未知模板 id → null(fail-closed 不渲染)。#446 起注册 prompt 形态模板(`diagnose-blocked` / `transcribe-review-checklist`,后者 label 冠「可选」):prompt 片的预填指令本体不在本注册表——由后端 `text` 字段承载(单一正本,契约测试在后端断言 @mention 可解析),本注册表只渲染展示文案 label/hint。#447:#440 静态映射 `utils/wuSuggestions.ts`(suggestionsForWu/pickCurrentWu)已删——引导片与阶段条「当前工单」唯一来源 = 建议端点(currentWuId) | | `getSuggestionAction` | `utils/suggestionActions.ts` | #444 动作片 → 确定性接口注册表:`redispatch-review` → `workunitApi.dispatchReview`(直调 dispatch-review 端点,与 ReviewDispatcher 自动派发同原语;URL 契约测试锁定);#445 `claim-wu` → `workunitApi.claim(wuId)`(直调 claim 端点,认领即发声原语与 loop 自动认领同路径;前端不带身份,认领人由服务端按会话用户解析)。附确认弹窗文案(title/confirmLabel/confirmMessage);未注册 id → null(fail-closed 不执行) | -| `SuggestionChips` / `AgentAvatar` | `components/channel/` | #440:建议片(点击经 ChannelInput `prefill={text,nonce}` 通道填入,dismiss 会话级)/ 频道顶部阶段条(复用 buildLifecycle+StationStepper,deriveDisplayState 同口径;2026-09 已并入 ChannelWorkBar)/ per-agent identicon 确定性头像(图样在 utils/avatar)。#443:SuggestionChips 扩为三形态渲染骨架——status 只读(非按钮无发送语义,`.mc-suggest-status`)/ action(onAction 直调骨架,#444 起有产出方:补派评审经页面 ConfirmDialog 一次确认后 `getSuggestionAction` 直调,生效后重拉建议片随前置条件转假消失,失败原因内联进弹窗)/ prompt(缺省)。#446:端点 prompt 片透传后端 `text` 进 SuggestionChips(点击 → onPick(text) 预填进输入框,人可编辑后发送走既有 @mention 消息路由,不自动发送;prompt 片缺 text → 不渲染,fail-closed 不点空指令)。#447:引导片唯一来源 = 端点派生片(静态映射已删),dismiss 台账 key = `ep:{wuId}:{suggestionId}`;阶段条 currentWu = 端点 currentWuId × channelWus(命中不了不渲染,fail-closed) | +| `SuggestionChips` / `AgentAvatar` | `components/channel/` | #440:建议片(点击经 ChannelInput `prefill={text,nonce}` 通道填入,dismiss 会话级)/ 频道顶部阶段条(复用 buildLifecycle+StationStepper,deriveDisplayState 同口径;2026-09 已并入 ChannelWorkBar)/ per-agent identicon 确定性头像(图样在 utils/avatar)。#443:SuggestionChips 扩为三形态渲染骨架——status 只读(非按钮无发送语义,`.mc-suggest-status`)/ action(onAction 直调骨架,#444 起有产出方:补派评审经页面 ConfirmDialog 一次确认后 `getSuggestionAction` 直调,生效后重拉建议片随前置条件转假消失,失败原因内联进弹窗)/ prompt(缺省)。#446:端点 prompt 片透传后端 `text` 进 SuggestionChips(点击 → onPick(text) 预填进输入框,人可编辑后发送走既有 @mention 消息路由,不自动发送;prompt 片缺 text → 不渲染,fail-closed 不点空指令)。#447:引导片唯一来源 = 端点派生片(静态映射已删),dismiss 台账 key = `ep:{wuId}:{suggestionId}`;阶段条 currentWu = 端点 currentWuId × channelWus(命中不了不渲染,fail-closed)。#484:dismiss 收片粒度——每片独立 ✕(✕ 不嵌片按钮,片 + ✕ 由 `.mc-suggest-item` 容器并排承载),onDismiss 上送被点片、页面按 dismissKey 单片记账;台账会话级(刷新复活)语义不变,持久化需先改 #441 spec 另行提案 | | `icons` | `components/ui/icons.tsx` | #474 图标策略定稿(全去 emoji):16 枚 stroke SVG 图标组件(IconChat/IconChart/IconClipboard/IconUsers/IconBook/IconLibrary/IconActivity/IconSearch/IconSettings/IconGrid/IconCheck/IconRefresh/IconClock + 批次 D-0 补 IconSun/IconMoon/IconMonitor 主题三态)——24×24 viewBox、stroke=currentColor 随主题/active 态、aria-hidden 不进读屏、size 默认 16;Sidebar 主导航 / MoreDropdown / DeliveryPanel 徽章与台账 / 主题切换控件(ThemeToggleButton/ThemeSettings)同源消费,新图标需求一律加在此 | | `MetaStrip` | `components/ui/MetaStrip.tsx` | #440 标题下密排元信息条:label:value 项,空值省略、全空不占位(WU 详情页 / PMO 项目页共用) | | `Skeleton` / `SkeletonText` / `SkeletonCard` | `components/ui/Skeleton.tsx` | 批次 E-2 静态骨架占位(样式 `.skeleton`/`.skeleton-text` 在 theme.css「加载状态」区,紧邻 `.loading-spinner`):加载态替代「加载中...」纯文字,WU 列表/详情、ProjectDetail、PMO、DiscussionPanel、ProjectPipeline 6 处接入;整块 aria-hidden。**静态零动画——禁止给 skeleton 加 shimmer/pulse,style-guide §2.5 动效白名单不覆盖** | diff --git a/apps/web/src/components/channel/SuggestionChips.tsx b/apps/web/src/components/channel/SuggestionChips.tsx index 5873577d..e10aabb4 100644 --- a/apps/web/src/components/channel/SuggestionChips.tsx +++ b/apps/web/src/components/channel/SuggestionChips.tsx @@ -3,7 +3,7 @@ // status(只读状况说明,非按钮、点击无发送语义)/ action(确定性动作,点击走 onAction // 直调后端,不经输入框——#444 起由后端产出「补派评审」)/ prompt(缺省;点击 → onPick(text) // 预填,由页面经 ChannelInput prefill 填入,不自动发送——人过目后按 Enter)。 -// × dismiss 由调用方记账(会话级),本组件纯展示。 +// × dismiss 由调用方记账(会话级),本组件纯展示;#484 起每片独立 ✕,onDismiss 上送被点片(不再一键清全部)。 // 数据来源:唯一来源 = 端点派生建议(GET /channels/:id/suggestions,经 suggestionCopy 模板渲染); // #440 静态映射(wuSuggestions)已于 #447 删除,组件本身不感知来源。 @@ -25,7 +25,8 @@ interface Props { onPick: (text: string) => void; /** action 形态点击(直调确定性接口骨架);无产出方时可不传(#444 已接线补派评审) */ onAction?: (item: SuggestionChipItem) => void; - onDismiss: () => void; + /** #484:片粒度 dismiss——每片独立 ✕,上送被点的片;调用方按片记账 */ + onDismiss: (item: SuggestionChipItem) => void; } export function SuggestionChips({ suggestions, onPick, onAction, onDismiss }: Props) { @@ -34,51 +35,61 @@ export function SuggestionChips({ suggestions, onPick, onAction, onDismiss }: Pr
{suggestions.map(s => { const label = s.label ?? s.text ?? ''; + // #484:✕ 不能嵌进片按钮(非法嵌套),片 + ✕ 由外层容器并排承载 + const dismissBtn = ( + + ); if (s.kind === 'status') { // 只读状况说明:非交互元素,无点击语义(自动化在途,安心等待) return ( - - {label} - {s.hint ? {s.hint} : null} + + + {label} + {s.hint ? {s.hint} : null} + + {dismissBtn} ); } if (s.kind === 'action') { return ( + + + {dismissBtn} + + ); + } + // prompt 形态(缺省):无预填文本不出(fail-closed,不点空指令) + if (s.text === undefined) return null; + const text = s.text; + return ( + - ); - } - // prompt 形态(缺省):无预填文本不出(fail-closed,不点空指令) - if (s.text === undefined) return null; - const text = s.text; - return ( - + {dismissBtn} + ); })} -
); } diff --git a/apps/web/src/components/channel/__tests__/SuggestionChips.test.tsx b/apps/web/src/components/channel/__tests__/SuggestionChips.test.tsx index 0afa465e..85269bcd 100644 --- a/apps/web/src/components/channel/__tests__/SuggestionChips.test.tsx +++ b/apps/web/src/components/channel/__tests__/SuggestionChips.test.tsx @@ -23,11 +23,25 @@ describe('SuggestionChips', () => { expect(onPick).toHaveBeenCalledTimes(1); }); - it('dismiss 按钮 → onDismiss', () => { + // #484:片粒度 dismiss——每片独立 ✕,点击只上送本片(不再一键清全部) + it('每片独立 ✕:点击只上送该片给 onDismiss,其余片保留', () => { const onDismiss = vi.fn(); render( {}} onDismiss={onDismiss} />); - fireEvent.click(screen.getByLabelText('关闭建议')); + const dismissBtns = screen.getAllByRole('button', { name: /关闭建议/ }); + expect(dismissBtns).toHaveLength(2); // 每片各一 ✕,不再有整组清全部按钮 + fireEvent.click(dismissBtns[0]); expect(onDismiss).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledWith(SUGGESTIONS[0]); // 只上送被点片 + // 组件纯展示:台账由调用方记账,未被 dismiss 的片仍在 + expect(screen.getByText('@developer 锁定实现契约')).toBeTruthy(); + }); + + it('status 形态片同样带独立 ✕(dismiss 覆盖三形态)', () => { + const onDismiss = vi.fn(); + const statusItem = { id: 'auto-review-in-flight', kind: 'status' as const, label: '等待自动评审:《登录功能》' }; + render( {}} onDismiss={onDismiss} />); + fireEvent.click(screen.getByRole('button', { name: /关闭建议/ })); + expect(onDismiss).toHaveBeenCalledWith(statusItem); }); // #442:in_review 片收窄——label 标注「可选」、hint 说明点击后果;预填内容(text)不污染 diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 0adf8099..cba8813f 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -968,17 +968,18 @@ export function ChannelDetailPage() {
{/* #443–#447:引导片(唯一来源 = 建议端点派生片;prompt 点击填入输入框,status 只读, - action 点击走下方确认弹窗直调确定性接口;会话级 dismiss) */} + action 点击走下方确认弹窗直调确定性接口;会话级 dismiss) + #484:片粒度 dismiss——每片独立 ✕,按片 dismissKey 记账,不再一键清全部 */} {visibleChips.length > 0 && ( setInputPrefill(p => ({ text, nonce: (p?.nonce ?? 0) + 1 }))} onAction={handleSuggestionAction} - onDismiss={() => setDismissedSuggestionKeys(prev => { - const next = new Set(prev); - for (const c of visibleChips) next.add(c.dismissKey); - return next; - })} + onDismiss={(item) => { + const key = visibleChips.find(c => c.id === item.id)?.dismissKey; + if (!key) return; // fail-closed:找不到台账 key 不记(不静默吞掉别片) + setDismissedSuggestionKeys(prev => new Set(prev).add(key)); + }} /> )} diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx index ade0eecc..422ab236 100644 --- a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx +++ b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx @@ -1035,7 +1035,7 @@ describe('ChannelDetailPage — #447 引导片唯一来源 = 建议端点', () = renderPage(); const chip = await screen.findByText(/转写审查清单/); expect(chip).toBeTruthy(); - fireEvent.click(screen.getByLabelText('关闭建议')); + fireEvent.click(screen.getByLabelText(/关闭建议:/)); expect(document.querySelector('.mc-suggest')).toBeNull(); // 同片重拉(同 dismissKey ep:WU-4001:transcribe-review-checklist)→ 不复活 act(() => emitSse({ @@ -1052,6 +1052,36 @@ describe('ChannelDetailPage — #447 引导片唯一来源 = 建议端点', () = })); await waitFor(() => expect(screen.getByText(/转写审查清单/)).toBeTruthy()); }); + + // #484:片粒度 dismiss——多片并存时 ✕ 只关本片,其余片保留;会话级语义不变(同片重拉不复活) + it('多片并存时 dismiss 其一:只关本片,其余片保留;被关片重拉不复活', async () => { + suggestionPayload = { + data: { + data: { + currentWuId: 'WU-4001', + suggestions: [ + { id: 'transcribe-review-checklist', kind: 'prompt', params: { wuId: 'WU-4001', wuTitle: '登录功能' }, text: '@reviewer 把《登录功能》的验收标准转写成审查清单' }, + { id: 'diagnose-blocked', kind: 'prompt', params: { wuId: 'WU-4001', wuTitle: '登录功能', blockReason: '依赖未完成' }, text: '@developer 诊断《登录功能》的阻塞' }, + ], + }, + }, + }; + renderPage(); + await screen.findByText(/转写审查清单/); + expect(screen.getByText(/诊断阻塞/)).toBeTruthy(); + // 只 dismiss「转写审查清单」片 → 「诊断阻塞」片保留 + fireEvent.click(screen.getByLabelText(/关闭建议:.*转写审查清单/)); + expect(screen.queryByText(/转写审查清单/)).toBeNull(); + expect(screen.getByText(/诊断阻塞/)).toBeTruthy(); + // 同负载重拉:被 dismiss 片(同 dismissKey)不复活,未 dismiss 片仍在 + act(() => emitSse({ + event_type: 'workunit.status_changed', + data: { workunit: { id: 'WU-4001', status: 'in_review', channelId: 'ch-1', type: 'task', metadata: '{}' } }, + })); + await waitFor(() => expect(suggestionsCalls()).toBe(2)); + expect(screen.queryByText(/转写审查清单/)).toBeNull(); + expect(screen.getByText(/诊断阻塞/)).toBeTruthy(); + }); }); // #440 Phase 2:频道阶段条——复用 StationStepper/buildLifecycle,deriveDisplayState 同口径; diff --git a/apps/web/src/styles/mission-control.css b/apps/web/src/styles/mission-control.css index 9cadf069..0dc69932 100644 --- a/apps/web/src/styles/mission-control.css +++ b/apps/web/src/styles/mission-control.css @@ -842,7 +842,8 @@ color: var(--text-muted); margin-top: 2px; } -.mc-suggest-dismiss { margin-left: auto; } +/* #484:片粒度 dismiss——片 + 各自 ✕ 由容器并排承载(原整组单个 ✕ 靠 margin-left:auto 右推,已删) */ +.mc-suggest-item { display: inline-flex; align-items: flex-start; gap: 2px; } .mc-input-reply { display: flex; From c3ceea1c57c8d85db7c6745b47fd1e9987df610d Mon Sep 17 00:00:00 2001 From: dommaker Date: Fri, 11 Sep 2026 15:14:52 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat(web):=20composer=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=BC=95=E7=94=A8=E5=8F=B0=E8=B4=A6=20chip=20?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=EF=BC=8C=E5=A4=B1=E6=95=88=E6=A0=87?= =?UTF-8?q?=E7=81=B0=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=20(#485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 已挂 fileRefs 渲染为输入框上方 chip(路径+工程 basename),可单独移除 - 正文编辑导致引用失效时 chip 标灰 + 「已失效」标记(非静默) - 发送仍只上送正文含路径的有效引用(行为不变,仅透明化) - 新增 ChannelInput-file-ref-chips 测试 5 例(渲染/移除/失效/混合过滤) --- apps/web/src/CONTEXT.md | 1 + .../src/components/channel/ChannelInput.tsx | 34 +++++ .../ChannelInput-file-ref-chips.test.tsx | 119 ++++++++++++++++++ apps/web/src/styles/mission-control.css | 17 +++ 4 files changed, 171 insertions(+) create mode 100644 apps/web/src/components/channel/__tests__/ChannelInput-file-ref-chips.test.tsx diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index be8f35f1..34922897 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -108,6 +108,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **批次 G-6**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11)统计档归一:MonitoringPage StatCard 大数字 `--fs-title` → `--fs-stat`(对齐 AgentDashboardPage/AgentDetailPage/AuditLogsPage/MonitorSection/ProjectProgressCard 五处既有口径,§2.3 统计大数字专用档)。 - **批次 H-1**(2026-09-11,治理人闸当场过)样式规范根部修正四项——把规矩改对而非给现状开口子:① §2.5 动效白名单 4 类扩 5 类,新增场景⑤「加载与进度反馈」(spinner 旋转 theme.css `spin 0.8s`、`.mc-dot-busy` pulse、进度条 `transition-all` 宽度/填充过渡收编——历史遗留白名单外动效名分落地,代码零改动);② §4.8 补豁免——统计卡大数字(`--fs-stat` 档)数据强调允许 accent(AgentDetailPage/MonitoringPage/AuditLogsPage 等统计卡现状收编);③ §2.3 补豁免——长文阅读正文(markdown 渲染、文档阅读页)允许 `lineHeight: 1.8`(MarkdownBody/LibraryDocPage 正本);④ §4.2 label 写法根治——原条文给的四属性内联示例与规则 2「禁内联布局样式」自相矛盾,theme.css「输入组件」区新增 `.form-label` 组件类(§4.4 登记),代码迁移 4 处(FirstRoleSetupModal ×3、StudioRoleSetupModal ×1,原 `u-text` + 四属性内联 → 单类;全仓 grep 复核无其他命中)。**留痕说明**:style-guide.md 被 .gitignore `docs/*` 忽略不入 git,条文修正只落本地磁盘;决策留痕 = 本条 + commit trailer `Governance-Approved: session`。 - **批次 H-2**(2026-09-11,docs/plans/2026-09-channel-attachments.md)频道截图粘贴/上传:ChannelInput 加 onPaste(clipboardData.files 有图 → 上传 → 光标处插入 markdown 图片语法 `![name](url)`,无图不拦默认文本粘贴)+ 图片选择按钮(隐藏 input[type=file],ui/icons 新增 IconImage);上传 = `channelApi.uploadAttachment`(JSON base64,POST /channels/:id/attachments),客户端先拦类型白名单/5MB 上限即时 toast,失败 toast 不动草稿(草稿从未被触碰,比发送失败回灌更简单),上传中 hint 行出「上传图片中…」占位。渲染双侧出图——agent 侧 MarkdownBody img 渲染器接 `resolveAttachmentSrc`(`utils/attachmentUrl.ts`:命中频道附件相对 URL 现拼 `?token=`(buildSseUrl 同款现取 authStore token,token 不落消息文本),其余 src 原样透传);人类/系统侧纯文本渲染自 `renderWithMentions` 升级为 `renderWithMentionsAndImages`(`utils/messageImages.tsx`:只识别本系统附件 URL 的图片语法 → ,任意外链图不渲染按文本原样输出,mention chip 共存)。消息体存相对 URL 即完整事实源,刷新后重渲现拼 token,天然持久。 +- **#485**(2026-09-11)composer 文件引用台账可视化:ChannelInput 已挂 fileRefs 渲染为输入框上方 chip(`.mc-fileref-chips`/`.mc-fileref-chip`,路径 + 工程 basename),✕ 单独移除(只动台账不动正文,发送过滤自然不携带);正文被编辑得不再含路径时 chip 标灰 + 「已失效」标记(`.mc-fileref-chip-invalid`),不静默丢弃;发送仍只上送正文含路径的有效引用(行为不变)。 - **导航骨架**(#393,spec §2/§4.1/§4.4):左侧菜单仅 4 主项(频道/PMO/WorkUnit/Agent),知识库/阅览室/监控/设置/审计日志收进顶部 header「更多」下拉(`MoreDropdown`,原 sidebar「更多」展开组已移除);全局 sidebar 桌面宽 176px(`responsive.css` ≥640px 档);详情页返回一律 `ui/BackButton`(PMO 项目→/pmo、Agent→/agents、WU→/workunits、阅览室文档→/library、Workspace→/settings),禁止再写右侧「返回」或散落的 navigate(-1) 拷贝。**E4/B-8 监控入口可见性**:MoreDropdown 按钮挂待处理计数徽标(近 24h 告警 overview.alerts.last24h + 提案待审 flywheel.proposalsPendingReview,两端点 60s 服务端缓存,best-effort 失败/403 落 0 隐藏;挂载首拉 + 展开时重拉,无 SSE 告警事件可接故不接重连;徽标 = NotificationBell 同款绝对定位独立槽位防 ellipsis 吞掉)。**#474 布局/信息架构批**:① 双路由合并——`/project/:id` 收编为 App 内 replace 重定向到唯一入口 `/pmo/project/:id`(存量书签/外链不失效,PmoNumberLink 同改);② 监控入口有待处理时升主导航临时项——SidebarNew 订阅 notificationStore.unreadCount(与 MoreDropdown 徽标同源口径),>0 追加「监控」项(带计数徽标 99+ 截断),归零即撤;③ 图标策略定稿**全去 emoji**——导航/徽章统一 `components/ui/icons.tsx` stroke SVG(详见该模块表行);④ 消息 footer 三枚同形 chip 差异化配色(`.mc-wu-link--wu` accent / `--pmo` success / `--req` warning)。 - **词表与频道名渲染唯一出口**(#429):WU 状态文案/配色一律消费 `@dommaker/studio-shared/web` 的 `WU_STATUS_LABELS`/`WU_STATUS_COLORS`(#399 §8.3 正词:待领取/进行中/待验收/完成),禁止组件内私有拷贝(#429 收 RequirementChainPanel,#472 收 ProjectPipeline——泳道词/色与 WU 卡 chip 同源自此);频道名渲染一律 `formatChannelName`(数据本身含前导 `#`,盲拼会出 `##`)。有意方言仅存 mapUtils `DEP_STATUS_LABEL`(依赖图大白话,对齐另议)。**#472 颜色语义**:pending「待确认」= 中性色(与 in_review「待验收」warning 分开,语义相反不同色);「待人工」= warning(error 红只留 blocked/failed 真错误,WorkUnitListPage StatChip 与 MonitoringPage 证据台账同改)。**#472 项目级词表**:`components/pmo/projectDisplay.ts` 是项目状态词(讨论中/开发中/验收中/已交付/已取消,delivered 归并已交付)+ 项目状态配色 + 交付策略文案(自动合并/分支交付)唯一出口——ProjectCard 状态 chip/策略标注、ProjectDetailPage MetaStrip、DeliveryPanel 台账、PmoNumberBadge tooltip/配色四处同源,禁止再写三元或裸输出英文值;DeliveryPanel 项目级徽标「待验收」已改「待交付」(与 WU 四站「待验收」分词)。**#472 统计 chip 口径**:WorkUnitListPage 「总数」chip 走全量徽标 `allTotal`(store 维护:无过滤 loadWorkUnits 顺带同步 / 过滤态 `loadAllCount` limit=1 补齐 / SSE created 全局 +1;null 回退过滤态 total——旧口径直接显示过滤态 total,切 tab 后「总数」变脸为过滤计数,2026-09-10 修复),其余 chip 计当前已加载子集,分页未全量/筛选/待人工生效时 chip 行内联标注「计数口径:当前 n/N 条」(措辞避开「已加载」——底栏分页文案有唯一断言)。**2026-09-10 截字修复**:行标题长 scope 2 行折行(`.wu-scope` clamp)+ title 悬停全文;抽屉主体标题 `.mc-drawer-subject-title` 允许折行、抽屉顶栏 uuid 加 title 悬停。**批次 D-3 项2 术语外露审计**(docs/plans/2026-09-ui-interaction-polish.md,延续 #473「机制词不上 UI」):频道右栏四站 stepper 站标签白话化(`activityRail.ts`:REQ→需求、WU n/m→任务 n/m);WorkUnitDrawer 行标「所属需求」、WU 详情页 FactRow「需求」+ 全链路按钮 tooltip、RequirementChainPanel 弹窗标题「需求全链路 · id」、右栏空态「暂无需求与动态」同步改;只登记不改:ProjectDetailPage「REQ 别名」(#399 定稿范围不动)、交付 409 `missing` 后端原文直渲(后端契约)、WU/REQ-xxxx 编号本身与技术性 tooltip(如列表页 chip「REQ 需求编号」)属豁免。 - **频道工作区**(#394 三栏定稿,spec §4.1–4.3)= 左 ChannelRail(200px,频道名 500 字重/未读加粗)/ 中对话流(Linear 式弹性:max-width 无上限、左对齐、padding 32px,760/1000 双档居中已删)/ 右 ChannelActivityRail(300px「频道动态」REQ 链路卡)+ 覆盖式右抽屉 WorkUnitDrawer。原中栏 REQ chips 条已移除(REQ 呈现挪右栏链路卡)。消息分侧:人类右气泡、agent 左文档流、系统播报左对齐随文档流(#437,居中装置随 #394 取消限宽而废,区隔 = 淡色小字 + 无头)。**窄屏降级**(#395,spec §4.6):断点统一 Tailwind 档 lg/md/sm(mission-control.css 900/1100 自定断点已删);收起顺序 = 右栏先收(<1024 内联右栏卸载 → 顶栏「频道动态」入口开覆盖滑出抽屉,点 REQ/WU 收覆盖层再开详情抽屉;E1 起该入口收进顶栏 ⋯ 菜单 ChannelTopbarMenu,外层 `.mc-act-open` 显隐规则不变)→ 左栏再收(<768 内联 ChannelRail 卸载,并入 SidebarNew 渲染,640–767 随静态 sidebar、<640 随汉堡 overlay);REQ/WU 抽屉 768–1023 转 fixed 覆盖式、<768 全屏化(左上「← 返回」)。挂/不挂走 JS 断点 `hooks/useMediaQuery`(matchMedia 缺失回落宽屏默认,jsdom 既有测试零改动),CSS 只管入口显隐与覆盖形态;字号全端同值不豁免,44px 触控目标与 iOS 输入 16px 沿用 responsive.css 既有规则。**#440 引导面**:topbar 下挂频道工作条(ChannelWorkBar,2026-09 合并阶段条 + live 实况条,阶段口径与 WU 详情页同 deriveDisplayState)、输入框上方挂引导片(SuggestionChips);两者共用 channelWus 数据面(REST 打底 + status_changed upsert + 重连对齐,与 waitingWus/channelReqs 同模式)。**#443 起引导片双源**:端点派生片(`channelApi.getSuggestions`,挂载/重连/workunit.status_changed SSE 后重拉,不新增事件类型)+ #440 静态 prompt 片(#447 删);端点片经 `renderSuggestionCopy` 模板渲染,status 形态只读不可点。 diff --git a/apps/web/src/components/channel/ChannelInput.tsx b/apps/web/src/components/channel/ChannelInput.tsx index 26fd6697..764bf84c 100644 --- a/apps/web/src/components/channel/ChannelInput.tsx +++ b/apps/web/src/components/channel/ChannelInput.tsx @@ -3,6 +3,7 @@ // #281(决策 #249 §5 / #248 D9):@弹框统一分组——上 Agents 下 Files;文件候选走 // 频道词表(git ls-files)路径后缀精确匹配补全,选中插入纯路径文本(mention 正则不动), // 发送时携带结构化 files=[{repo, path}](仅保留正文仍含其路径的引用,防陈旧)。 +// #485:fileRefs 台账可视化——chip 可单独移除;正文被编辑得不含路径时 chip 标灰「已失效」,不静默丢弃。 import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import type { AgentProfile, ChannelMessage, FileRef } from '../../api/channel'; import { channelApi } from '../../api/channel'; @@ -249,6 +250,11 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI void uploadImage(img); }; + // #485:chip 单独移除引用(不动正文路径文本——移除台账后发送过滤自然不携带) + const removeFileRef = useCallback((ref: FileRef) => { + setFileRefs(prev => prev.filter(f => !(f.repo === ref.repo && f.path === ref.path))); + }, []); + // 批次A 项1:await 真实发送结果——失败回灌文本/文件引用 + toast 提示 // (参照 ChannelMessageItem 内嵌回复「失败保留 draft」模式,发送中输入框经 sending 禁用) const handleSend = async () => { @@ -335,6 +341,34 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI )} + {/* #485:已挂文件引用 chip——台账可视化;正文被编辑得不再含路径时标灰「已失效」(不再静默丢弃) */} + {fileRefs.length > 0 && ( +
+ {fileRefs.map(ref => { + const invalid = !content.includes(ref.path); + return ( + + {ref.path} + {repoBasename(ref.repo)} + {invalid && 已失效} + + + ); + })} +
+ )} +