feat: support native subagents and long-running prompts
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s

OpenCode Task calls now appear as first-class subagents in local and managed SSH conversations, with stable status, persistence, terminal handling, and true event ordering instead of duplicate generic tool entries.

GoodBuddy no longer applies a fixed outer wall-clock total to OpenCode, Continue, DeepSeek Harness, or managed SSH Prompts. Runtime-owned limits, user cancellation, bounded control operations, output limits, and released Agent protocol compatibility remain intact. Desktop and Agent release metadata are synchronized at 0.11.13 with approved bilingual notes.

Release note: OpenCode 原生 Task 现在以独立子 Agent 展示;长任务不再被 GoodBuddy 固定总时限中止,并修复 OpenCode 启动识别、可配置 HTTP 服务地址与发布说明读取问题。
This commit is contained in:
mesalogo
2026-08-30 17:18:33 +08:00
parent 7c6f15d3c0
commit efd304fa1d
42 changed files with 2776 additions and 528 deletions
+162 -6
View File
@@ -8515,7 +8515,7 @@ describe('App', () => {
)
})
it('shows bounded Subagent states and records child expert activity', async () => {
it('shows Subagent states and records child expert activity', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析复杂问题' }
@@ -8568,19 +8568,26 @@ describe('App', () => {
}
})
const statusRegion = await screen.findByLabelText('子专家状态')
const statusRegion = await screen.findByLabelText('子代理状态')
expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('等待中')).toBeInTheDocument()
expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('进行中')).toBeInTheDocument()
expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('失败')).toBeInTheDocument()
const subagentCards = within(statusRegion).getAllByRole('group')
fireEvent.click(
within(subagentCards[2]!).getByText('安全专家', {
selector: 'strong'
})
)
fireEvent(subagentCards[2]!, new Event('toggle'))
expect(
within(statusRegion).getByText('无法读取必要上下文')
).toBeInTheDocument()
expect(
within(statusRegion).queryByText('第四位专家')
).not.toBeInTheDocument()
within(statusRegion).getByText('第四位专家')
).toBeInTheDocument()
act(() => {
agentListener?.({
@@ -8599,9 +8606,20 @@ describe('App', () => {
output: '代码专家的部分结论'
})
})
expect(within(statusRegion).getByText('已完成')).toBeInTheDocument()
expect(within(statusRegion).getAllByText('已完成')).toHaveLength(2)
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument()
for (const [index, expertName] of [
'研究专家',
'代码专家'
].entries()) {
fireEvent.click(
within(subagentCards[index]!).getByText(expertName, {
selector: 'strong'
})
)
fireEvent(subagentCards[index]!, new Event('toggle'))
}
expect(
within(statusRegion).getByText('研究专家的独立结论')
).toBeInTheDocument()
@@ -8627,7 +8645,7 @@ describe('App', () => {
fireEvent.click(screen.getByText('运行记录'))
expect(await screen.findAllByText('子专家')).toHaveLength(4)
expect(screen.getAllByText(/智能路由/u).length).toBeGreaterThan(0)
expect(screen.getAllByText(/手动指定/u).length).toBeGreaterThan(0)
expect(screen.getAllByText(/指定专家/u).length).toBeGreaterThan(0)
expect(
screen.getByText(/研究专家的独立结论/u)
).toBeInTheDocument()
@@ -8636,6 +8654,144 @@ describe('App', () => {
).toBeInTheDocument()
})
it('replaces an incremental OpenCode Task tool with a native subagent card', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查架构' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-task-1',
name: 'Review application architecture',
state: 'running',
summary: '远端 Runtime 工具:Review application architecture'
})
agentListener?.({
requestId: request.requestId,
type: 'reasoning',
delta: '等待子代理结果'
})
})
expect(await screen.findByText('等待子代理结果')).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
childTaskId: '00000000-0000-4000-8000-000000000611',
expertId: '00000000-0000-4000-8000-000000000711',
expertName: 'explorer',
routingMode: 'native',
runtimeCallId: 'call-task-1',
state: 'running',
reason: 'Review application architecture'
})
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: '父 Agent 最终回复'
})
})
const region = await screen.findByLabelText('子代理状态')
expect(within(region).getByText('explorer')).toBeInTheDocument()
expect(within(region).getByText('OpenCode 原生'))
.toBeInTheDocument()
expect(
screen.queryByRole('region', {
name: '工具执行,共 1 项'
})
).not.toBeInTheDocument()
const assistantArticle = screen
.getByText('父 Agent 最终回复')
.closest('article')
const orderedBlocks = [
...assistantArticle!.querySelectorAll('.message-blocks > *')
].map((element) => element.textContent)
expect(orderedBlocks).toEqual([
expect.stringContaining('Review application architecture'),
expect.stringContaining('等待子代理结果'),
expect.stringContaining('父 Agent 最终回复')
])
fireEvent.click(screen.getByText('运行记录'))
expect(await screen.findAllByText('子专家')).toHaveLength(1)
expect(
screen.queryByText(
'远端 Runtime 工具:Review application architecture'
)
).not.toBeInTheDocument()
})
it('fails an unterminated subagent when the parent reports done', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查未完成子任务' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
childTaskId: '00000000-0000-4000-8000-000000000612',
expertId: '00000000-0000-4000-8000-000000000712',
expertName: 'explorer',
routingMode: 'native',
runtimeCallId: 'call-task-2',
state: 'running',
reason: '检查未完成路径'
})
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
const region = await screen.findByLabelText('子代理状态')
expect(within(region).getByText('失败')).toBeInTheDocument()
const subagentCard = within(region).getByRole('group')
fireEvent.click(
within(subagentCard).getByText('explorer', {
selector: 'strong'
})
)
fireEvent(subagentCard, new Event('toggle'))
expect(
within(region).getByText(
'父请求已结束,但子 Agent 未报告完成状态。'
)
).toBeInTheDocument()
await waitFor(() => {
const persistedMessages = vi
.mocked(api.conversations.saveLocal)
.mock.calls.flatMap(([batch]) =>
batch.flatMap((conversation) => conversation.messages)
)
expect(
persistedMessages.some(
(message) =>
message.subagents?.[0]?.state === 'failed' &&
message.subagents[0].error ===
'父请求已结束,但子 Agent 未报告完成状态。'
)
).toBe(true)
})
})
it('offers once, session, permanent, and deny for a tool call', async () => {
render(<App />)
+161 -26
View File
@@ -128,6 +128,7 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationSubagentActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -619,9 +620,57 @@ function upsertMessageToolBlock(
]
}
function upsertMessageSubagentBlock(
blocks: ConversationMessageBlock[] | undefined,
childTaskId: string,
runtimeCallId?: string
): ConversationMessageBlock[] | undefined {
if (!blocks) {
return blocks
}
if (
blocks.some(
(block) =>
block.type === 'subagent' &&
block.childTaskId === childTaskId
)
) {
return blocks
}
const provisionalIndex = runtimeCallId
? blocks.findIndex(
(block) =>
block.type === 'tool' &&
block.tool.callId === runtimeCallId
)
: -1
if (provisionalIndex >= 0) {
return blocks.map((block, index) =>
index === provisionalIndex
? {
id: block.id,
type: 'subagent' as const,
childTaskId
}
: block
)
}
if (blocks.length >= maxMessageBlocks) {
return blocks
}
return [
...blocks,
{
id: crypto.randomUUID(),
type: 'subagent',
childTaskId
}
]
}
function terminalizeMessageToolBlocks(
blocks: ConversationMessageBlock[] | undefined,
state: 'failed' | 'cancelled'
state: 'failed' | 'cancelled' | 'interrupted'
): ConversationMessageBlock[] | undefined {
return blocks?.map((block) =>
block.type === 'tool' &&
@@ -1212,7 +1261,8 @@ function isConversation(value: unknown): value is Conversation {
))) &&
(entry.subagents === undefined ||
(Array.isArray(entry.subagents) &&
entry.subagents.length <= 3 &&
entry.subagents.length <=
maximumConversationSubagentActivities &&
entry.subagents.every(
(subagent) =>
conversationSubagentActivitySchema.safeParse(subagent)
@@ -4183,7 +4233,10 @@ function App(): React.JSX.Element {
conversationId: run.conversationId,
parentTaskId: event.requestId,
expertId: event.expertId,
routingMode: event.routingMode,
routingMode:
event.routingMode === 'native'
? undefined
: event.routingMode,
title: event.expertName,
instructions:
event.reason ??
@@ -4205,8 +4258,23 @@ function App(): React.JSX.Element {
? current.map((task) =>
task.id === event.childTaskId ? childTask : task
)
: [...current, childTask].slice(0, 100)
: [...current, childTask].slice(
0,
maximumConversationSubagentActivities
)
})
if (event.runtimeCallId) {
setActivityRecords((current) =>
current.filter(
(record) =>
!(
record.requestId === event.requestId &&
record.kind === 'tool' &&
record.callId === event.runtimeCallId
)
)
)
}
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
@@ -4216,7 +4284,9 @@ function App(): React.JSX.Element {
detail: [
event.routingMode === 'smart'
? tRef.current('chat.subagents.smart')
: tRef.current('chat.subagents.manual'),
: event.routingMode === 'native'
? tRef.current('chat.subagents.native')
: tRef.current('chat.subagents.manual'),
event.reason,
event.error
]
@@ -4239,6 +4309,7 @@ function App(): React.JSX.Element {
expertId: event.expertId,
expertName: event.expertName,
routingMode: event.routingMode,
runtimeCallId: event.runtimeCallId,
state: event.state,
reason: event.reason,
output: event.output,
@@ -4246,10 +4317,27 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
subagents[index] = subagent
} else if (subagents.length < 3) {
} else if (
subagents.length <
maximumConversationSubagentActivities
) {
subagents.push(subagent)
}
return { ...message, subagents }
const runtimeCallId = event.runtimeCallId
return {
...message,
subagents,
tools: runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks: upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId
)
}
})
} else if (event.type === 'approval') {
recordActivity({
@@ -4342,6 +4430,12 @@ function App(): React.JSX.Element {
? 'cancelled'
: 'failed'
: 'completed'
const incompleteSubagentError = tRef.current(
'chat.subagents.incomplete'
)
const incompleteActivityDetail = tRef.current(
'chat.status.activityIncomplete'
)
updateRequestActivity(
event.requestId,
terminalStatus,
@@ -4349,25 +4443,27 @@ function App(): React.JSX.Element {
? event.message
: tRef.current('chat.status.taskCompleted')
)
if (event.type === 'error') {
setActivityRecords((current) =>
current.map((record) =>
record.requestId === event.requestId &&
record.kind !== 'request' &&
(record.status === 'pending' ||
record.status === 'running')
? {
...record,
status: terminalStatus,
detail: `${record.detail}\n${event.message}`.slice(
0,
4_000
)
}
: record
)
setActivityRecords((current) =>
current.map((record) =>
record.requestId === event.requestId &&
record.kind !== 'request' &&
(record.status === 'pending' ||
record.status === 'running')
? {
...record,
status:
event.type === 'done'
? 'interrupted'
: terminalStatus,
detail: `${record.detail}\n${
event.type === 'done'
? incompleteActivityDetail
: event.message
}`.slice(0, 4_000)
}
: record
)
}
)
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
@@ -4382,6 +4478,25 @@ function App(): React.JSX.Element {
: tRef.current('chat.status.runtimeCompleted'),
status: terminalStatus
})
setAssistantTasks((current) =>
current.map((task) =>
task.parentTaskId === event.requestId &&
(task.status === 'queued' || task.status === 'running')
? {
...task,
status:
event.type === 'done'
? 'failed'
: terminalStatus,
completedAt: new Date().toISOString(),
error:
event.type === 'error'
? event.message
: incompleteSubagentError
}
: task
)
)
updateMessage(run.conversationId, run.messageId, (message) => {
const representedToolError =
event.type === 'error' &&
@@ -4394,7 +4509,7 @@ function App(): React.JSX.Element {
? event.status === 'cancelled'
? ('cancelled' as const)
: ('failed' as const)
: undefined
: ('interrupted' as const)
const fallbackError =
event.type === 'error' &&
!representedToolError &&
@@ -4436,6 +4551,26 @@ function App(): React.JSX.Element {
: tool
)
: message.tools,
subagents: message.subagents?.map((subagent) =>
subagent.state === 'queued' ||
subagent.state === 'running'
? {
...subagent,
state:
event.type === 'error'
? event.status === 'cancelled'
? ('cancelled' as const)
: ('failed' as const)
: ('failed' as const),
...(event.type === 'error' &&
event.status !== 'cancelled'
? { error: event.message.slice(0, 1_000) }
: event.type === 'done'
? { error: incompleteSubagentError }
: {})
}
: subagent
),
blocks: toolTerminalState
? terminalizeMessageToolBlocks(
appendMessageContentBlock(
+88 -4
View File
@@ -246,26 +246,110 @@ describe('ChatTimeline', () => {
/>
)
const region = screen.getByLabelText('子专家状态')
const region = screen.getByLabelText('子代理状态')
const summary = screen.getByText('综合结果').parentElement
const messageBody = region.parentElement
expect(summary?.parentElement).toBe(messageBody)
expect(
Array.from(messageBody?.children ?? []).indexOf(region)
).toBeLessThan(
).toBeGreaterThan(
Array.from(messageBody?.children ?? []).indexOf(summary!)
)
const cards = within(region).getAllByRole('group')
expect(cards).toHaveLength(3)
const experts = ['研究专家', '代码专家', '安全专家']
for (const [index, output] of [
'研究专家的独立结论',
'代码专家的独立结论',
'安全专家的独立结论'
].entries()) {
expect(within(cards[index]!).getByText(output)).toBeInTheDocument()
expect(
within(cards[index]!).queryByText(output)
).not.toBeInTheDocument()
expect(cards[index]).not.toHaveAttribute('open')
fireEvent.click(within(cards[index]!).getByText(/专家$/u))
fireEvent.click(
within(cards[index]!).getByText(experts[index]!, {
selector: 'strong'
})
)
fireEvent(cards[index]!, new Event('toggle'))
expect(cards[index]).toHaveAttribute('open')
expect(within(cards[index]!).getByText(output)).toBeInTheDocument()
}
})
it('labels native OpenCode subagents and shows their task description', () => {
const messages: Message[] = [
{
id: 'assistant-message',
role: 'assistant',
content: '',
createdAt: 1_775_000_000_000,
state: 'streaming',
blocks: [
{
id: '00000000-0000-4000-8000-000000000121',
type: 'reasoning',
content: '先分析桌面目录'
},
{
id: '00000000-0000-4000-8000-000000000122',
type: 'subagent',
childTaskId:
'00000000-0000-4000-8000-000000000111'
},
{
id: '00000000-0000-4000-8000-000000000123',
type: 'text',
content: '父 Agent 最终回复'
}
],
subagents: [
{
childTaskId: '00000000-0000-4000-8000-000000000111',
expertId: '00000000-0000-4000-8000-000000000211',
expertName: 'explorer',
routingMode: 'native',
runtimeCallId: 'call-task-1',
state: 'running',
reason: 'Review application architecture'
}
]
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(screen.getByText('子代理')).toBeInTheDocument()
expect(screen.getByText('OpenCode 原生')).toBeInTheDocument()
expect(screen.getByText('任务')).toBeInTheDocument()
expect(screen.getByText('进行中')).toBeInTheDocument()
expect(
screen.getByText('Review application architecture')
).toBeInTheDocument()
const assistantArticle = screen
.getByText('父 Agent 最终回复')
.closest('article')
const orderedBlocks = [
...assistantArticle!.querySelectorAll('.message-blocks > *')
].map((element) => element.textContent)
expect(orderedBlocks).toEqual([
expect.stringContaining('先分析桌面目录'),
expect.stringContaining('Review application architecture'),
expect.stringContaining('父 Agent 最终回复')
])
})
})
+142 -43
View File
@@ -1,14 +1,19 @@
import {
Bot,
CheckCircle2,
ChevronRight,
Clock3,
Download,
FileText,
Library,
ClockFading,
LoaderCircle,
ShieldCheck,
TerminalSquare,
UserRound
UserRound,
XCircle
} from 'lucide-react'
import { memo, useEffect, useRef } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
ApprovalDecision,
@@ -78,19 +83,40 @@ export type ImageViewerItem = {
type MessageBlockRenderItem =
| {
kind: 'block'
block: Exclude<ConversationMessageBlock, { type: 'tool' }>
block: Extract<
ConversationMessageBlock,
{ type: 'text' | 'reasoning' }
>
}
| {
kind: 'tools'
id: string
tools: ToolActivity[]
}
| {
kind: 'subagents'
id: string
childTaskIds: string[]
}
function groupMessageBlocks(
blocks: ConversationMessageBlock[]
): MessageBlockRenderItem[] {
const items: MessageBlockRenderItem[] = []
for (const block of blocks) {
if (block.type === 'subagent') {
const previous = items.at(-1)
if (previous?.kind === 'subagents') {
previous.childTaskIds.push(block.childTaskId)
} else {
items.push({
kind: 'subagents',
id: block.id,
childTaskIds: [block.childTaskId]
})
}
continue
}
if (block.type !== 'tool') {
items.push({ kind: 'block', block })
continue
@@ -219,6 +245,90 @@ function ToolExecutionList({
)
}
const SubagentStatusCard = memo(function SubagentStatusCard({
subagent
}: {
subagent: SubagentActivity
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const StateIcon =
subagent.state === 'completed'
? CheckCircle2
: subagent.state === 'running'
? LoaderCircle
: subagent.state === 'queued'
? Clock3
: XCircle
const source =
subagent.routingMode === 'smart'
? t('chat.subagents.smart')
: subagent.routingMode === 'native'
? t('chat.subagents.native')
: t('chat.subagents.manual')
return (
<details
className={`subagent-status-card subagent-status-card--${subagent.state}`}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>
<span className="subagent-status-card__identity">
<span className="subagent-status-card__heading">
<span className="subagent-status-card__badge">
<Bot aria-hidden="true" size={13} />
{t('chat.subagents.badge')}
</span>
<strong>{subagent.expertName}</strong>
</span>
{subagent.reason && (
<span className="subagent-status-card__task">
<small>{t('chat.subagents.task')}</small>
<span>{subagent.reason}</span>
</span>
)}
<small className="subagent-status-card__source">
{source}
</small>
</span>
<span
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
>
<StateIcon aria-hidden="true" size={14} />
{t(`chat.subagents.states.${subagent.state}`)}
</span>
<ChevronRight
aria-hidden="true"
className="subagent-status-card__chevron"
size={15}
/>
</summary>
{expanded && (
<div className="subagent-status-card__details">
{subagent.output ? (
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
</div>
</section>
) : (
<p>{t('chat.subagents.noOutput')}</p>
)}
{subagent.error &&
(subagent.state === 'failed' ||
subagent.state === 'cancelled') && (
<section className="subagent-status-card__error">
<strong>{t('chat.subagents.error')}</strong>
<p>{subagent.error}</p>
</section>
)}
</div>
)}
</details>
)
})
function SubagentStatusList({
subagents
}: {
@@ -231,44 +341,11 @@ function SubagentStatusList({
aria-label={t('chat.subagents.region')}
className="subagent-status-list"
>
{subagents.slice(0, 3).map((subagent) => (
<details
className={`subagent-status-card subagent-status-card--${subagent.state}`}
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
>
<summary>
<Bot aria-hidden="true" size={15} />
<span className="subagent-status-card__identity">
<strong>{subagent.expertName}</strong>
<small>
{subagent.routingMode === 'smart'
? t('chat.subagents.smart')
: t('chat.subagents.manual')}
</small>
</span>
<span>{t(`chat.subagents.states.${subagent.state}`)}</span>
</summary>
<div className="subagent-status-card__details">
{subagent.output ? (
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
</div>
</section>
) : (
<p>{t('chat.subagents.noOutput')}</p>
)}
{(subagent.error || subagent.reason) &&
(subagent.state === 'failed' ||
subagent.state === 'cancelled') && (
<section className="subagent-status-card__error">
<strong>{t('chat.subagents.error')}</strong>
<p>{subagent.error ?? subagent.reason}</p>
</section>
)}
</div>
</details>
subagent={subagent}
/>
))}
</section>
)
@@ -327,6 +404,20 @@ function ChatMessageRowView({
const compressionMarkers =
message.contextCompressions ??
(message.contextCompression ? [message.contextCompression] : [])
const subagentsById = new Map(
(message.subagents ?? []).map((subagent) => [
subagent.childTaskId,
subagent
])
)
const orderedSubagentIds = new Set(
message.blocks
?.filter((block) => block.type === 'subagent')
.map((block) => block.childTaskId)
)
const unorderedSubagents = message.subagents?.filter(
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
)
const compressionLabel = (
compression: ConversationContextCompressionMarker
): string =>
@@ -468,14 +559,19 @@ function ChatMessageRowView({
})}
</div>
)}
{message.subagents && message.subagents.length > 0 && (
<SubagentStatusList subagents={message.subagents} />
)}
{message.blocks && message.blocks.length > 0 ? (
<div className="message-blocks">
{groupMessageBlocks(message.blocks).map((item) =>
item.kind === 'tools' ? (
<ToolExecutionList key={item.id} tools={item.tools} />
) : item.kind === 'subagents' ? (
<SubagentStatusList
key={item.id}
subagents={item.childTaskIds.flatMap((childTaskId) => {
const subagent = subagentsById.get(childTaskId)
return subagent ? [subagent] : []
})}
/>
) : item.block.type === 'reasoning' ? (
<MessageReasoning
content={item.block.content}
@@ -510,6 +606,9 @@ function ChatMessageRowView({
)}
</>
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList subagents={unorderedSubagents} />
)}
{message.artifactIds?.map((artifactId) => {
const candidate = artifactById.get(artifactId)
const artifact =
+11 -4
View File
@@ -322,6 +322,8 @@ export const app = {
taskCompleted: 'Task completed',
taskFailed: 'Task failed',
runtimeCompleted: 'Agent Runtime completed its response',
activityIncomplete:
'The parent request ended before this activity reported completion',
answerSubmitted: 'Answer submitted; OpenCode is continuing',
questionSkipped: 'Question skipped'
},
@@ -345,12 +347,17 @@ export const app = {
},
subagents: {
region: 'Subagent status',
smart: 'Smart routing',
manual: 'Selected manually',
badge: 'Subagent',
smart: 'GoodBuddy smart routing',
manual: 'GoodBuddy selected expert',
native: 'Native OpenCode',
fallbackTask: '{{name}} subagent task',
output: 'Expert output',
task: 'Task',
output: 'Subagent output',
error: 'Execution details',
noOutput: 'This expert has no output to display yet.',
noOutput: 'This subagent has no output to display yet.',
incomplete:
'The parent request ended before the subagent reported completion.',
states: {
queued: 'Queued',
running: 'Running',
+10 -5
View File
@@ -308,6 +308,7 @@ export const app = {
taskCompleted: '任务执行完成',
taskFailed: '任务执行失败',
runtimeCompleted: 'Agent Runtime 已完成响应',
activityIncomplete: '父请求已结束,但该活动未报告完成状态',
answerSubmitted: '回答已提交,OpenCode 正在继续执行',
questionSkipped: '已跳过问题'
},
@@ -330,13 +331,17 @@ export const app = {
}
},
subagents: {
region: '子专家状态',
smart: '智能路由',
manual: '手动指定',
region: '子代理状态',
badge: '子代理',
smart: 'GoodBuddy 智能路由',
manual: 'GoodBuddy 指定专家',
native: 'OpenCode 原生',
fallbackTask: '{{name}} 子专家任务',
output: '专家输出',
task: '任务',
output: '子代理输出',
error: '执行说明',
noOutput: '该专家暂时没有可显示的输出。',
noOutput: '子代理暂时没有可显示的输出。',
incomplete: '父请求已结束,但子 Agent 未报告完成状态。',
states: {
queued: '等待中',
running: '进行中',
+114 -15
View File
@@ -4907,7 +4907,8 @@ button > svg {
}
.message-blocks .message-reasoning,
.message-blocks .tool-execution-list {
.message-blocks .tool-execution-list,
.message-blocks .subagent-status-list {
margin: 0;
}
@@ -5259,25 +5260,25 @@ button > svg {
.subagent-status-list {
display: grid;
margin-top: var(--space-2);
margin: var(--space-2) 0 var(--space-4);
gap: var(--space-2);
}
.subagent-status-card {
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
border-radius: var(--radius-card);
background: var(--surface-raised);
color: var(--text-secondary);
overflow: hidden;
}
.subagent-status-card > summary {
display: grid;
align-items: center;
padding: var(--space-2) var(--space-3);
align-items: start;
padding: var(--space-3) var(--space-4);
cursor: pointer;
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-3);
grid-template-columns: minmax(0, 1fr) auto auto;
list-style: none;
}
@@ -5294,38 +5295,130 @@ button > svg {
outline-offset: -2px;
}
.subagent-status-card--queued {
box-shadow: inset 3px 0 0 var(--warning);
}
.subagent-status-card--running {
border-color: var(--accent-selected);
box-shadow: inset 3px 0 0 var(--accent);
}
.subagent-status-card--completed {
box-shadow: inset 3px 0 0 var(--success);
}
.subagent-status-card--failed,
.subagent-status-card--cancelled {
border-color: var(--danger-border);
background: var(--danger-subtle);
box-shadow: inset 3px 0 0 var(--danger);
}
.subagent-status-card__identity {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.subagent-status-card__heading {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.subagent-status-card__badge {
display: inline-flex;
min-height: 22px;
padding: 2px var(--space-2);
align-items: center;
border: 1px solid var(--accent-selected);
border-radius: var(--radius-control);
background: var(--accent-subtle);
color: var(--accent);
font-size: var(--font-caption);
font-weight: 700;
gap: var(--space-1);
}
.subagent-status-card summary strong,
.subagent-status-card summary > span:last-child {
.subagent-status-card__heading > strong {
color: var(--text-primary);
font-size: var(--font-body);
font-size: var(--font-section-title);
overflow-wrap: anywhere;
}
.subagent-status-card summary small {
.subagent-status-card__task {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.subagent-status-card__task > small,
.subagent-status-card__source {
color: var(--text-muted);
font-size: var(--font-caption);
}
.subagent-status-card__task > span {
color: var(--text-primary);
font-size: var(--font-body);
line-height: 1.5;
overflow-wrap: anywhere;
}
.subagent-status-card__status {
display: inline-flex;
min-height: 24px;
padding: 2px var(--space-2);
align-items: center;
border-radius: var(--radius-control);
font-size: var(--font-caption);
font-weight: 700;
gap: var(--space-1);
white-space: nowrap;
}
.subagent-status-card__status--queued {
background: var(--warning-subtle);
color: var(--warning);
}
.subagent-status-card__status--running {
background: var(--accent-subtle);
color: var(--accent);
}
.subagent-status-card__status--completed {
background: var(--success-subtle);
color: var(--success);
}
.subagent-status-card__status--failed,
.subagent-status-card__status--cancelled {
background: var(--danger-subtle);
color: var(--danger);
}
.subagent-status-card__status--running > svg {
animation: subagent-status-spin 900ms linear infinite;
}
.subagent-status-card__chevron {
margin-top: var(--space-1);
color: var(--text-muted);
transition: transform var(--motion-fast) ease-out;
}
.subagent-status-card[open] .subagent-status-card__chevron {
transform: rotate(90deg);
}
.subagent-status-card__details {
display: grid;
padding: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-4);
border-top: 1px solid var(--border-subtle);
background: var(--surface-raised);
background: var(--surface-subtle);
gap: var(--space-3);
}
@@ -5349,6 +5442,12 @@ button > svg {
color: var(--danger);
}
@keyframes subagent-status-spin {
to {
transform: rotate(360deg);
}
}
.approval-card {
display: grid;
align-items: center;