From f718095d783d7e13c445a490c52551b29a3f93a8 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 15:30:50 +0000 Subject: [PATCH] Show background notices without blocking the composer Why: A background child should not look like the conversation is busy, and its completion note needs a compact row instead of a full assistant bubble. Evidence: vitest run passed, including design-guard. tsc -b passed. Impact: The stop button cancels the conversation run. System notices fold with the shared Collapsible. Refs: feature/agent-harness-p3b Co-authored-by: Cursor --- src/components/Conversation.tsx | 14 ++++++++++++++ src/contracts.ts | 2 ++ src/store.ts | 4 ++-- src/styles.css | 3 +++ test/notice.test.ts | 34 +++++++++++++++++++++++++++++++++ tsconfig.tsbuildinfo | 2 +- 6 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 test/notice.test.ts diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 18cc89b..92a99cd 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -341,6 +341,19 @@ function OutboxPanel() { ) } +export function NoticeMessage({ msg }: { msg: Message }) { + const text = msg.parts.filter(p => p.type === 'text').map(p => p.text).join('\n') + const preview = text.replace(/<\/?system-reminder>/g, '').trim().split('\n').find(line => line.trim()) ?? '系统通知' + return ( +
+ 通知 · {preview}}> +
{text}
+
+
+ ) +} + export function Conversation() { const currentConvId = useStore(s => s.currentConvId) const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId)) @@ -447,6 +460,7 @@ export function Conversation() { ) } + if (m.role === 'system') return return })} {live && !messages.some(m => m.runId === live.runId) && diff --git a/src/contracts.ts b/src/contracts.ts index 754287d..f914030 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -210,6 +210,8 @@ export interface PromptModule { body: string; outputContract?: string; source?: 'builtin' | 'plugin' | 'user'; owner?: string; basedOnVersion?: number; drifted?: boolean; disabled?: boolean; + /** 子代理 profile。只对 agent.sub.* 有意义;空字段表示沿用内置。 */ + tools?: string[]; model?: string; maxTurns?: number; } export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; } diff --git a/src/store.ts b/src/store.ts index 9ef07e8..a6466c5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -487,7 +487,7 @@ export const store = { }, async cancelActive() { - const active = state.runs.find(r => !isTerminal(r.status)) + const active = activeRun(state.runs) if (!active) return await api.cancelRun(active.id).catch(() => undefined) setTimeout(() => store.reconcile(), 200) @@ -721,5 +721,5 @@ function isConversationBusy(err: unknown): boolean { } export function activeRun(runs: Run[]): Run | undefined { - return runs.find(r => !isTerminal(r.status)) + return runs.find(r => r.kind !== 'subagent' && !isTerminal(r.status)) } diff --git a/src/styles.css b/src/styles.css index 2e0b1fa..679f4f8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -67,6 +67,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); } .msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; } .msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); } +.msg.notice { margin-bottom: 12px; } +.notice-summary { font-size: var(--fs-ui-sm); color: var(--text-dim); background: none; border: none; padding: 0; cursor: pointer; } +.notice-body { font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: pre-wrap; margin-top: 4px; } .msg.user .bubble { background: var(--accent-soft); border: 1px solid transparent; border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap; diff --git a/test/notice.test.ts b/test/notice.test.ts new file mode 100644 index 0000000..03dcccd --- /dev/null +++ b/test/notice.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' +import { createElement } from 'react' +import { activeRun } from '../src/store' +import { NoticeMessage } from '../src/components/Conversation' +import type { Message, Run } from '../src/contracts' + +function run(kind: Run['kind'], status: Run['status']): Run { + return { + id: `${kind}-${status}`, conversationId: 'c', projectId: 'p', + kind, status, createdAt: '2026-10-01T00:00:00Z', + } +} + +describe('activeRun', () => { + it('ignores background subagents so they do not block sending', () => { + const runs = [run('subagent', 'running'), run('conversation', 'completed')] + expect(activeRun(runs)).toBeUndefined() + expect(activeRun([run('conversation', 'running'), run('subagent', 'running')])?.kind).toBe('conversation') + }) +}) + +describe('NoticeMessage', () => { + it('renders a compact notice row', () => { + const msg: Message = { + id: 'msg_notice', conversationId: 'c', role: 'system', createdAt: '2026-10-01T00:00:00Z', + parts: [{ type: 'text', text: '\n后台子代理「检索」已结束。\n' }], + } + const html = renderToStaticMarkup(createElement(NoticeMessage, { msg })) + expect(html).toContain('通知 · 后台子代理「检索」已结束。') + expect(html).toContain('msg notice') + expect(html).not.toContain('