diff --git a/.github/workflows/prcheck.yml b/.github/workflows/prcheck.yml
index cd4cd643a..173448b67 100644
--- a/.github/workflows/prcheck.yml
+++ b/.github/workflows/prcheck.yml
@@ -100,6 +100,10 @@ jobs:
- name: Test renderer
run: pnpm run test:renderer
+ env:
+ # Reused jsdom workers accumulate heap across suites and breach the
+ # 4GB Node default on 16GB runners; 6GB keeps two workers in budget.
+ NODE_OPTIONS: --max-old-space-size=6144
build:
runs-on: ubuntu-24.04
diff --git a/src/renderer/src/components/message/LiveDelegationToolCallCard.vue b/src/renderer/src/components/message/LiveDelegationToolCallCard.vue
index 13af5d667..9bc83e9fa 100644
--- a/src/renderer/src/components/message/LiveDelegationToolCallCard.vue
+++ b/src/renderer/src/components/message/LiveDelegationToolCallCard.vue
@@ -14,6 +14,21 @@
{{ slotId }}
+ ()
const emit = defineEmits<{ toggleDetails: [] }>()
diff --git a/src/renderer/src/components/message/MessageBlockAction.vue b/src/renderer/src/components/message/MessageBlockAction.vue
index 347229e0d..0993505ab 100644
--- a/src/renderer/src/components/message/MessageBlockAction.vue
+++ b/src/renderer/src/components/message/MessageBlockAction.vue
@@ -35,7 +35,24 @@
{{ t('components.messageBlockAction.continue') }}
+
+ {{
+ resolvedPermissionStatus === 'granted'
+ ? t('components.messageBlockPermissionRequest.granted')
+ : t('components.messageBlockPermissionRequest.denied')
+ }}
+
+
{{ t('components.messageBlockAction.continued') }}
@@ -48,7 +65,10 @@ import { useI18n } from 'vue-i18n'
import { Icon } from '@iconify/vue'
import { DcButton } from '@dc-ui/components/button'
import { computed, ref, onMounted, onUnmounted } from 'vue'
-import type { DisplayAssistantMessageBlock } from '@/features/chat-page/model/displayMessage'
+import {
+ type DisplayAssistantMessageBlock,
+ getResolvedPermissionStatus
+} from '@/features/chat-page/model/displayMessage'
const { t } = useI18n()
@@ -67,6 +87,7 @@ const progressTimer = ref(null)
const currentTime = ref(Date.now())
const isReadOnly = computed(() => props.isReadOnly === true)
const isRateLimitBlock = computed(() => props.block.action_type === 'rate_limit')
+const resolvedPermissionStatus = computed(() => getResolvedPermissionStatus(props.block))
const elapsedSeconds = computed(() => {
if (!isRateLimitBlock.value) {
return 0
diff --git a/src/renderer/src/components/message/MessageBlockActivityGroup.vue b/src/renderer/src/components/message/MessageBlockActivityGroup.vue
index 381957e6f..45810cce9 100644
--- a/src/renderer/src/components/message/MessageBlockActivityGroup.vue
+++ b/src/renderer/src/components/message/MessageBlockActivityGroup.vue
@@ -51,6 +51,9 @@
:thread-id="threadId"
:read-only="readOnly"
render-mode="tool-only"
+ :permission-status="
+ block.tool_call?.id ? permissionStatusByToolCallId?.[block.tool_call.id] : undefined
+ "
/>
}>()
const emit = defineEmits<{
diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue
index f4e040b23..76781e144 100644
--- a/src/renderer/src/components/message/MessageBlockToolCall.vue
+++ b/src/renderer/src/components/message/MessageBlockToolCall.vue
@@ -8,6 +8,7 @@
:details-id="detailsId"
:details-expanded="isExpanded"
:read-only="readOnly"
+ :permission-status="permissionStatus"
@toggle-details="toggleExpanded"
/>
+
+ {{
+ permissionStatus === 'granted' ? t('toolCall.badge.allowed') : t('toolCall.badge.denied')
+ }}
+
()
type ExpansionSource = 'auto' | 'manual' | null
diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue
index abb756075..2e265abeb 100644
--- a/src/renderer/src/components/message/MessageItemAssistant.vue
+++ b/src/renderer/src/components/message/MessageItemAssistant.vue
@@ -48,6 +48,7 @@
:reasoning-count="item.reasoningCount"
:tool-call-count="item.toolCallCount"
:read-only="isReadOnly"
+ :permission-status-by-tool-call-id="permissionStatusByToolCallId"
@toggle-collapse="handleCollapseToggle"
/>
'
},
+ 'shield-x': {
+ body: ''
+ },
'shopping-bag': {
body: ''
},
diff --git a/src/renderer/src/lib/icons/icon-whitelist.generated.ts b/src/renderer/src/lib/icons/icon-whitelist.generated.ts
index 043316e0b..e10c84ebd 100644
--- a/src/renderer/src/lib/icons/icon-whitelist.generated.ts
+++ b/src/renderer/src/lib/icons/icon-whitelist.generated.ts
@@ -223,6 +223,7 @@ export const GENERATED_ICON_WHITELIST: Record {
expect(wrapper.emitted('continue')).toEqual([['s1', 'm1']])
})
+ const createPermissionBlock = (
+ status: DisplayAssistantMessageBlock['status'],
+ overrides: Partial = {}
+ ): DisplayAssistantMessageBlock =>
+ createBlock({
+ action_type: 'tool_call_permission',
+ status,
+ tool_call: { id: 'tc1', name: 'run_command' },
+ ...overrides
+ })
+
+ it('renders granted permission outcome instead of the generic continued label', () => {
+ const wrapper = mount(MessageBlockAction, {
+ props: {
+ messageId: 'm1',
+ conversationId: 's1',
+ block: createPermissionBlock('granted')
+ }
+ })
+
+ const label = wrapper.find('[data-testid="permission-resolved-label"]')
+ expect(label.exists()).toBe(true)
+ expect(label.attributes('data-permission-status')).toBe('granted')
+ expect(wrapper.text()).toContain('components.messageBlockPermissionRequest.granted')
+ expect(wrapper.text()).not.toContain('components.messageBlockAction.continued')
+ })
+
+ it('renders denied permission outcome without any success affordance', () => {
+ const wrapper = mount(MessageBlockAction, {
+ props: {
+ messageId: 'm1',
+ conversationId: 's1',
+ block: createPermissionBlock('denied', { content: 'User denied the request.' })
+ }
+ })
+
+ const label = wrapper.find('[data-testid="permission-resolved-label"]')
+ expect(label.attributes('data-permission-status')).toBe('denied')
+ expect(wrapper.text()).toContain('components.messageBlockPermissionRequest.denied')
+ expect(wrapper.text()).not.toContain('components.messageBlockAction.continued')
+ expect(wrapper.findAll('button')).toHaveLength(0)
+ })
+
+ it('keeps denied rendering identical in read-only history', () => {
+ const wrapper = mount(MessageBlockAction, {
+ props: {
+ messageId: 'm1',
+ conversationId: 's1',
+ isReadOnly: true,
+ block: createPermissionBlock('denied')
+ }
+ })
+
+ expect(
+ wrapper.find('[data-testid="permission-resolved-label"]').attributes('data-permission-status')
+ ).toBe('denied')
+ })
+
+ it('does not mark pending permission requests as resolved', () => {
+ const wrapper = mount(MessageBlockAction, {
+ props: {
+ messageId: 'm1',
+ conversationId: 's1',
+ block: createPermissionBlock('pending', { extra: { needsUserAction: true } })
+ }
+ })
+
+ expect(wrapper.find('[data-testid="permission-resolved-label"]').exists()).toBe(false)
+ })
+
it('renders a compact rate limit status block', () => {
const wrapper = mount(MessageBlockAction, {
props: {
diff --git a/test/renderer/components/message/MessageBlockToolCall.test.ts b/test/renderer/components/message/MessageBlockToolCall.test.ts
index f42994e07..22a05ae3d 100644
--- a/test/renderer/components/message/MessageBlockToolCall.test.ts
+++ b/test/renderer/components/message/MessageBlockToolCall.test.ts
@@ -1073,4 +1073,118 @@ describe('MessageBlockToolCall', () => {
expect(selectSessionMock).toHaveBeenCalledWith('child-alpha')
})
+
+ it('renders the resolved permission badge on the tool pill', () => {
+ const wrapper = mount(MessageBlockToolCall, {
+ props: {
+ block: createBlock(),
+ permissionStatus: 'granted'
+ }
+ })
+
+ const badge = wrapper.find('[data-testid="tool-call-permission-badge"]')
+ expect(badge.exists()).toBe(true)
+ expect(badge.attributes('data-permission-status')).toBe('granted')
+ expect(badge.text()).toBe('toolCall.badge.allowed')
+ })
+
+ it('renders a denied permission badge distinct from success styling', () => {
+ const wrapper = mount(MessageBlockToolCall, {
+ props: {
+ block: createBlock({ status: 'error' }),
+ permissionStatus: 'denied'
+ }
+ })
+
+ const badge = wrapper.find('[data-testid="tool-call-permission-badge"]')
+ expect(badge.attributes('data-permission-status')).toBe('denied')
+ expect(badge.text()).toBe('toolCall.badge.denied')
+ })
+
+ it('renders no permission badge when no permission outcome is associated', () => {
+ const wrapper = mount(MessageBlockToolCall, {
+ props: {
+ block: createBlock()
+ }
+ })
+
+ expect(wrapper.find('[data-testid="tool-call-permission-badge"]').exists()).toBe(false)
+ })
+
+ it('renders the granted outcome inside the live delegation card', () => {
+ const wrapper = mount(MessageBlockToolCall, {
+ props: {
+ threadId: 'parent-1',
+ permissionStatus: 'granted',
+ block: createBlock({
+ extra: { toolSource: 'agent' },
+ tool_call: {
+ id: 'spawn-1',
+ name: LIVE_DELEGATION_AGENT_TOOL_NAME,
+ server_name: LIVE_DELEGATION_AGENT_TOOL_SERVER_NAME,
+ params: JSON.stringify({
+ operation: 'spawn',
+ slotId: 'reviewer',
+ title: 'Review architecture',
+ prompt: 'Inspect module boundaries.'
+ }),
+ response: JSON.stringify({
+ delegation: {
+ schemaVersion: 1,
+ id: 'delegation-1',
+ parentSessionId: 'parent-1',
+ childSessionId: 'child-1',
+ slotId: 'reviewer',
+ targetAgentId: 'deepchat',
+ title: 'Review architecture',
+ status: 'running',
+ lastTurnSeq: 1,
+ createdAt: 10,
+ updatedAt: 20,
+ revision: 2,
+ summaryPreview: null,
+ errorPreview: null
+ },
+ turns: []
+ })
+ }
+ })
+ }
+ })
+
+ const card = wrapper.get('[data-testid="live-delegation-tool-card-delegation-1"]')
+ const badge = card.get('[data-testid="tool-call-permission-badge"]')
+ expect(badge.attributes('data-permission-status')).toBe('granted')
+ expect(badge.text()).toBe('toolCall.badge.allowed')
+ })
+
+ it('renders the denied outcome inside the live delegation card', () => {
+ const wrapper = mount(MessageBlockToolCall, {
+ props: {
+ threadId: 'parent-1',
+ permissionStatus: 'denied',
+ block: createBlock({
+ status: 'error',
+ extra: { toolSource: 'agent' },
+ tool_call: {
+ id: 'spawn-1',
+ name: LIVE_DELEGATION_AGENT_TOOL_NAME,
+ server_name: LIVE_DELEGATION_AGENT_TOOL_SERVER_NAME,
+ params: JSON.stringify({
+ operation: 'spawn',
+ slotId: 'reviewer',
+ title: 'Review architecture',
+ prompt: 'Inspect module boundaries.'
+ }),
+ response: ''
+ }
+ })
+ }
+ })
+
+ const card = wrapper.get('[data-testid="live-delegation-tool-card-pending"]')
+ const badge = card.get('[data-testid="tool-call-permission-badge"]')
+ expect(badge.attributes('data-permission-status')).toBe('denied')
+ expect(badge.text()).toBe('toolCall.badge.denied')
+ })
})
diff --git a/test/renderer/components/message/MessageItemAssistant.test.ts b/test/renderer/components/message/MessageItemAssistant.test.ts
index c9cf9be1a..1b51549ff 100644
--- a/test/renderer/components/message/MessageItemAssistant.test.ts
+++ b/test/renderer/components/message/MessageItemAssistant.test.ts
@@ -742,4 +742,101 @@ describe('MessageItemAssistant', () => {
expect(memoryActivity.openTurnMemories).not.toHaveBeenCalled()
})
+
+ describe('resolved permission projection', () => {
+ const createPermissionActionBlock = (
+ status: DisplayAssistantMessageBlock['status'],
+ toolCallId = 'tc1'
+ ): DisplayAssistantMessageBlock => ({
+ type: 'action',
+ action_type: 'tool_call_permission',
+ status,
+ timestamp: 1,
+ tool_call: { id: toolCallId, name: 'run_command' }
+ })
+
+ const ToolCallStub = defineComponent({
+ name: 'MessageBlockToolCall',
+ props: {
+ permissionStatus: {
+ type: String,
+ default: undefined
+ }
+ },
+ template:
+ ''
+ })
+
+ const mountWith = (content: DisplayAssistantMessageBlock[]) =>
+ mount(MessageItemAssistant, {
+ props: {
+ message: createMessage('pending', content),
+ isCapturingImage: false,
+ isInGeneratingThread: true
+ },
+ global: {
+ ...global,
+ stubs: {
+ ...global.stubs,
+ MessageBlockToolCall: ToolCallStub
+ }
+ }
+ })
+
+ it('merges the granted outcome into the tool card and hides the action card', () => {
+ const wrapper = mountWith([
+ createPermissionActionBlock('granted'),
+ createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } })
+ ])
+
+ expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false)
+ expect(
+ wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status')
+ ).toBe('granted')
+ })
+
+ it('merges the denied outcome into the tool card', () => {
+ const wrapper = mountWith([
+ createPermissionActionBlock('denied'),
+ createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } })
+ ])
+
+ expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false)
+ expect(
+ wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status')
+ ).toBe('denied')
+ })
+
+ it('merges the outcome only into the tool call with the matching id', () => {
+ const wrapper = mountWith([
+ createPermissionActionBlock('granted', 'tc2'),
+ createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } }),
+ createToolCallBlock({ tool_call: { id: 'tc2', name: 'write_file' } })
+ ])
+
+ expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(false)
+ const stubs = wrapper.findAll('[data-testid="tool-call-stub"]')
+ expect(stubs).toHaveLength(2)
+ expect(stubs[0].attributes('data-permission-status')).toBe('')
+ expect(stubs[1].attributes('data-permission-status')).toBe('granted')
+ })
+
+ it('keeps the standalone action card when the tool card is missing', () => {
+ const wrapper = mountWith([createPermissionActionBlock('denied', 'tc-missing')])
+
+ expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(true)
+ })
+
+ it('keeps pending permission action blocks visible and unmerged', () => {
+ const wrapper = mountWith([
+ createPermissionActionBlock('pending'),
+ createToolCallBlock({ tool_call: { id: 'tc1', name: 'run_command' } })
+ ])
+
+ expect(wrapper.findComponent({ name: 'MessageBlockAction' }).exists()).toBe(true)
+ expect(
+ wrapper.find('[data-testid="tool-call-stub"]').attributes('data-permission-status')
+ ).toBe('')
+ })
+ })
})