fix(office-ui): native progress parity, ui_state lock hardening, approval-card idempotency
Native agent progress panel (company mode): - ws_handler: filter runtime bookkeeping noise (turn/status/member_inbox_updated), keep tool_completed as tool_call, thinking summary previews content, preserve raw thinking_delta fragments (no strip; skip whitespace-only) - frontend progressLog: summarize thinking by content preview; merge thinking by detail only so the 'Thinking' label never splices into text - AgentProgressBlock: add bottom "Show more (N earlier steps)" toggle ui_state.db "database is locked" hardening: - ws_handler: isolate engine progress/kanban/runtime-event callbacks so UI persistence failures never crash work items - chat_store: busy_timeout, _retry_locked backoff, idempotent insert_message (INSERT OR REPLACE), create_channel read-before-write to stop poll writes - server: flock single-instance guard for `opc ui` per OPC home Approval card duplicate-click bug: - EscalationPanel: disable buttons on click with Submitting state and 30s reconnect fallback - ws_handler: stale-escalation branch checks real card status (new chat_store.get_checkpoint_message); already-resolved cards get an accurate "already handled (decision: X)" reply without being re-marked stale; dedup identical helper messages within 120s to stop reply spam Company mode prompt: - add soft guidance that the runtime monitors state and re-activates roles, so leaders need not poll work items after delegation/review Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,7 +155,7 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
|
||||
const cfg = ENTRY_CONFIG[entry.type] || ENTRY_CONFIG.status_change
|
||||
|
||||
return (
|
||||
<div key={progressEntryKey(entry, i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
|
||||
<div key={progressEntryKey(entry, hiddenCount + i)} className={`ptl-entry${isLast ? ' ptl-entry-last' : ''}`}>
|
||||
<div className="ptl-connector">
|
||||
<div className="ptl-dot" style={{ color: cfg.color }}>
|
||||
{cfg.icon}
|
||||
@@ -183,13 +183,19 @@ export function AgentProgressBlock({ entries, agentStatus, currentTool, toolElap
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Collapse button (when expanded) ────────────── */}
|
||||
{/* ── Expand/collapse toggle (below timeline) ────── */}
|
||||
{expanded && filteredEntries.length > COLLAPSED_COUNT && (
|
||||
<button className="ptl-expand" onClick={() => setExpanded(false)}>
|
||||
<IconChevron down />
|
||||
<span>Show less</span>
|
||||
</button>
|
||||
)}
|
||||
{!expanded && hiddenCount > 0 && (
|
||||
<button className="ptl-expand" onClick={() => setExpanded(true)}>
|
||||
<IconChevron />
|
||||
<span>Show more ({hiddenCount} earlier step{hiddenCount > 1 ? 's' : ''})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ChatMessageMeta, HumanEscalationOption } from '../types/chat'
|
||||
import { MarkdownBody } from './MarkdownBody'
|
||||
|
||||
// If the server never confirms (click lost on a dropped connection), re-enable
|
||||
// the buttons after this long so the user can retry.
|
||||
const SUBMIT_CONFIRM_TIMEOUT_MS = 30000
|
||||
|
||||
interface EscalationPanelProps {
|
||||
meta: ChatMessageMeta
|
||||
onReply: (text: string) => void
|
||||
@@ -57,10 +61,20 @@ export const EscalationPanel = React.memo(function EscalationPanel({
|
||||
const worktreePath = String(meta.worktree_path ?? '').trim()
|
||||
const hasRuntimeState = activeSubagents.length > 0 || permissionRequests.length > 0 || !!worktreePath
|
||||
|
||||
const [submittedOptionId, setSubmittedOptionId] = useState('')
|
||||
const isSubmitting = !!submittedOptionId && !isResponded
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting) return
|
||||
const timer = window.setTimeout(() => setSubmittedOptionId(''), SUBMIT_CONFIRM_TIMEOUT_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [isSubmitting, submittedOptionId])
|
||||
|
||||
const handleReply = useCallback((option: HumanEscalationOption) => {
|
||||
if (isResponded) return
|
||||
if (isResponded || isSubmitting) return
|
||||
setSubmittedOptionId(option.id)
|
||||
onReply(option.label || option.id)
|
||||
}, [isResponded, onReply])
|
||||
}, [isResponded, isSubmitting, onReply])
|
||||
|
||||
return (
|
||||
<div className="ckpt-panel ckpt-escalation">
|
||||
@@ -109,15 +123,22 @@ export const EscalationPanel = React.memo(function EscalationPanel({
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}`}
|
||||
className={`ckpt-btn ${option.id.includes('deny') ? 'ckpt-btn-deny' : 'ckpt-btn-approve'}${isSubmitting ? ' ckpt-btn-submitting' : ''}`}
|
||||
onClick={() => handleReply(option)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{option.label || option.id}
|
||||
{isSubmitting && submittedOptionId === option.id ? 'Submitting…' : (option.label || option.id)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSubmitting && (
|
||||
<div className="ckpt-escalation-hint">
|
||||
Decision sent — waiting for server confirmation…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResponded && meta.default_action && (
|
||||
<div className="ckpt-escalation-hint">
|
||||
Default on timeout: <code>{meta.default_action}</code>
|
||||
|
||||
@@ -28,9 +28,41 @@ for (const [seq, detail] of [
|
||||
}
|
||||
|
||||
assert.equal(log.length, 1)
|
||||
assert.equal(log[0]?.summary, 'Thinking')
|
||||
assert.equal(log[0]?.summary, '我先联网抓取')
|
||||
assert.equal(log[0]?.detail, '我先联网抓取')
|
||||
|
||||
// Token-sized streaming fragments keep their whitespace when merged, and
|
||||
// entries without detail never splice their summary label into the text.
|
||||
let spacedLog = appendProgressEntry([], {
|
||||
timestamp: 100,
|
||||
type: 'thinking',
|
||||
summary: 'The user',
|
||||
detail: 'The user',
|
||||
turnId: 'rt-2:1',
|
||||
itemId: 'rt-2:1:thinking',
|
||||
seq: 1,
|
||||
})
|
||||
spacedLog = appendProgressEntry(spacedLog, {
|
||||
timestamp: 101,
|
||||
type: 'thinking',
|
||||
summary: 'wants to',
|
||||
detail: ' wants to',
|
||||
turnId: 'rt-2:1',
|
||||
itemId: 'rt-2:1:thinking',
|
||||
seq: 2,
|
||||
})
|
||||
spacedLog = appendProgressEntry(spacedLog, {
|
||||
timestamp: 102,
|
||||
type: 'thinking',
|
||||
summary: 'Thinking',
|
||||
turnId: 'rt-2:1',
|
||||
itemId: 'rt-2:1:thinking',
|
||||
seq: 3,
|
||||
})
|
||||
assert.equal(spacedLog.length, 1)
|
||||
assert.equal(spacedLog[0]?.detail, 'The user wants to')
|
||||
assert.equal(spacedLog[0]?.summary, 'The user wants to')
|
||||
|
||||
const unchanged = appendProgressEntry(log, {
|
||||
timestamp: 5,
|
||||
type: 'thinking',
|
||||
|
||||
@@ -27,9 +27,9 @@ function mergeText(left: string, right: string, kind: 'thinking' | 'tool_call'):
|
||||
}
|
||||
|
||||
function summarizeThinking(detail: string, fallback: string): string {
|
||||
void detail
|
||||
void fallback
|
||||
return 'Thinking'
|
||||
const text = detail.trim().replace(/\s+/g, ' ')
|
||||
if (!text) return fallback || 'Thinking'
|
||||
return text.length > 120 ? `${text.slice(0, 120).trimEnd()}...` : text
|
||||
}
|
||||
|
||||
function normalizeProgressEntry(entry: ProgressEntry): ProgressEntry {
|
||||
@@ -84,7 +84,10 @@ function isDuplicateProgress(left: ProgressEntry, right: ProgressEntry): boolean
|
||||
|
||||
function mergeProgress(left: ProgressEntry, right: ProgressEntry): ProgressEntry {
|
||||
if (left.type === 'thinking') {
|
||||
const detail = mergeText(left.detail ?? left.summary, right.detail ?? right.summary, 'thinking')
|
||||
// Merge detail text only: summary is a label/preview ("Thinking",
|
||||
// truncated excerpt), so falling back to it would splice label text
|
||||
// into the middle of the merged thinking stream.
|
||||
const detail = mergeText(left.detail ?? '', right.detail ?? '', 'thinking')
|
||||
return {
|
||||
timestamp: right.timestamp,
|
||||
type: 'thinking',
|
||||
|
||||
Reference in New Issue
Block a user