fix(team): harden readonly run interactions (#596)

This commit is contained in:
matevip 2026-08-15 00:54:19 -04:00
parent 9a3b95df53
commit 41ffe38699
9 changed files with 102 additions and 16 deletions

View File

@ -219,7 +219,7 @@
<p class="error-card__action">{{ errorAction }}</p>
<div class="error-card__footer">
<span v-if="errorCode" class="error-card__code">{{ errorCode }}</span>
<button v-if="errorRetryable" class="error-card__retry" type="button" @click="$emit('regenerate')">
<button v-if="errorRetryable && !readonly" class="error-card__retry" type="button" @click="$emit('regenerate')">
<el-icon><RefreshRight /></el-icon>
{{ $t('chat.retry') }}
</button>
@ -242,7 +242,7 @@
</div>
<p class="incomplete-card__description">{{ $t('chat.incompleteDescription') }}</p>
<div class="incomplete-card__footer">
<button class="incomplete-card__retry" type="button" @click="$emit('regenerate')">
<button v-if="!readonly" class="incomplete-card__retry" type="button" @click="$emit('regenerate')">
<el-icon><RefreshRight /></el-icon>
{{ $t('chat.incompleteRetry') }}
</button>
@ -423,7 +423,7 @@
</button>
<!-- 重新生成仅会话末尾的 assistant 回答服务端只支持对末条回答重生成 -->
<button
v-if="role === 'assistant' && !isGenerating && isLast"
v-if="role === 'assistant' && !isGenerating && isLast && !readonly"
class="action-btn"
type="button"
:title="$t('chat.regenerate')"
@ -434,7 +434,7 @@
<!-- 回退到此处删除本条及之后的所有消息仅已持久化的消息可回退
客户端临时 id 带下划线持久化雪花 id 是纯数字 -->
<button
v-if="!isGenerating && canRewind"
v-if="!isGenerating && canRewind && !readonly"
class="action-btn"
type="button"
:title="$t('chat.rewindHere')"
@ -620,6 +620,7 @@ interface Props {
assistantIcon?: string
userIcon?: string
showCursor?: boolean
readonly?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -627,6 +628,7 @@ const props = withDefaults(defineProps<Props>(), {
assistantIcon: '🤖',
userIcon: 'U',
showCursor: false,
readonly: false,
})
const emit = defineEmits<{
@ -1379,6 +1381,7 @@ const feedbackInfo = computed<FeedbackInfo | undefined>(() => {
})
function handleFeedbackAction(action: string) {
if (props.readonly && (action === 'retry' || action === 'regenerate')) return
if (action === 'retry' || action === 'regenerate') {
emit('regenerate')
return

View File

@ -86,6 +86,7 @@
:assistant-icon="assistantIcon"
:user-icon="userIcon"
:show-cursor="showCursorForMessage(item.message)"
:readonly="readonly"
@regenerate="$emit('regenerate', item.message)"
@rewind="$emit('rewind', item.message)"
@toggle-thinking="(expanded) => $emit('toggle-thinking', item.message, expanded)"
@ -176,6 +177,7 @@ interface Props {
selectedTeamTaskId?: string | null
teamRunsHasMore?: boolean
teamRunsLoadingMore?: boolean
readonly?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -190,6 +192,7 @@ const props = withDefaults(defineProps<Props>(), {
loadingOlder: false,
teamRunsHasMore: false,
teamRunsLoadingMore: false,
readonly: false,
})
const emit = defineEmits<{

View File

@ -6,8 +6,11 @@ import type { Message } from '@/types'
vi.mock('../MessageBubble.vue', () => ({
default: defineComponent({
props: ['message'],
setup: props => () => h('div', { 'data-message-id': String(props.message.id) }, props.message.content),
props: ['message', 'readonly'],
setup: props => () => h('div', {
'data-message-id': String(props.message.id),
'data-readonly': String(Boolean(props.readonly)),
}, props.message.content),
}),
}))
vi.mock('../CompressionSummary.vue', () => ({ default: defineComponent({ setup: () => () => h('div') }) }))
@ -63,6 +66,12 @@ afterEach(() => {
})
describe('MessageList team run timeline', () => {
it('passes readonly state to every message action surface', () => {
const host = mount({ messages: [message('1', 'assistant', 'result')], readonly: true })
expect(host.querySelector('[data-message-id="1"]')?.getAttribute('data-readonly')).toBe('true')
})
it('preserves legacy rendering when teamRuns is not provided', () => {
const host = mount({ messages: [
message('1', 'user', 'hello'),

View File

@ -34,7 +34,7 @@ const { t } = useI18n()
<TeamRunStatus :status="group.run.status" :started-at="group.run.startedAt" :completed-at="group.run.completedAt" show-duration />
</span>
<TeamRunProgress :progress="group.run.progress" compact />
<el-icon :size="15"><ArrowRight /></el-icon>
<el-icon class="agent-run-group__arrow" :size="15"><ArrowRight /></el-icon>
</button>
</header>
<div class="agent-run-group__lead" style="min-width: 0">
@ -68,5 +68,5 @@ const { t } = useI18n()
.agent-run-group__lead { display: grid; grid-template-columns: auto minmax(0,1fr) minmax(0,.6fr); align-items:center; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--mc-border-light); background: rgba(71, 85, 105, 0.035); color: var(--mc-text-tertiary); font-size: 11px; }
.agent-run-group__lead-name,.agent-run-group__phase{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.agent-run-group__lead-name{color:var(--mc-text-secondary)}.agent-run-group__phase{text-align:right}
.agent-run-group__empty { margin: 0; padding: 12px; border-top: 1px solid var(--mc-border-light); color: var(--mc-text-tertiary); font-size: 11px; }
@media(max-width:520px){.agent-run-group__open{grid-template-columns:minmax(0,1fr) auto;gap:8px}.agent-run-group__open>el-icon{display:none}.agent-run-group__lead{grid-template-columns:auto minmax(0,1fr)}.agent-run-group__phase{grid-column:2;text-align:left}.agent-run-group__copy>span{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}}
@media(max-width:520px){.agent-run-group__open{grid-template-columns:minmax(0,1fr) auto;gap:8px}.agent-run-group__arrow{display:none}.agent-run-group__lead{grid-template-columns:auto minmax(0,1fr)}.agent-run-group__phase{grid-column:2;text-align:left}.agent-run-group__copy>span{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}}
</style>

View File

@ -62,4 +62,15 @@ describe('AgentRunGroups', () => {
await nextTick()
expect(opened).toEqual(['20'])
})
it('uses a stable class for the mobile-only run arrow', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const app = createApp(AgentRunGroups, { groups: [group] })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: messages } }))
app.mount(host)
apps.push(app)
expect(host.querySelector('.agent-run-group__arrow')).not.toBeNull()
})
})

View File

@ -226,6 +226,35 @@ describe('useTeamRunHistory', () => {
await pending
expect(history.selectedRunId.value).toBe('2')
expect(history.selectedTaskId.value).toBe('task-b')
expect(history.detailLoading.value).toBe(false)
expect(history.detailError.value).toBeNull()
})
it('keeps detail loading and errors scoped to the currently selected run', async () => {
const detailA = deferred<unknown>()
const detailB = deferred<unknown>()
const get = vi.fn((id: string) => id === '1' ? detailA.promise : detailB.promise)
const history = useTeamRunHistory({
api: { listByTeam: vi.fn().mockResolvedValue({ data: [
{ ...run('1', '2026-01-01'), projectionCompleteness: 'summary' },
{ ...run('2', '2026-01-02'), projectionCompleteness: 'summary' },
] }), get }, subscribe: () => vi.fn(),
})
await history.open('10')
history.select('1')
const pendingA = history.ensureSelectedRunDetail('1', null, '10')
history.select('2')
const pendingB = history.ensureSelectedRunDetail('2', null, '10')
detailA.reject(new Error('run A failed'))
await pendingA
expect(history.detailLoading.value).toBe(true)
expect(history.detailError.value).toBeNull()
detailB.resolve({ data: { ...run('2', '2026-01-02'), projectionCompleteness: 'full' } })
await pendingB
expect(history.detailLoading.value).toBe(false)
})
it('loads the new paged team history response', async () => {
const history = useTeamRunHistory({

View File

@ -58,10 +58,10 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
const refreshTimers = new Map<string, unknown>()
const runRevisions = new Map<string, number>()
const runRequestSequences = new Map<string, number>()
const detailRequestSequences = new Map<string, number>()
let generation = 0
let revision = 0
let selectionRevision = 0
let detailRequestSequence = 0
let unsubscribe: (() => void) | null = null
function merge(run: TeamRun) {
@ -95,13 +95,14 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
const requestSequence = (runRequestSequences.get(requestKey) ?? 0) + 1
const requestRevision = runRevisions.get(runId) ?? 0
runRequestSequences.set(requestKey, requestSequence)
const detailRequestSequence = silent ? null : (detailRequestSequences.get(runId) ?? 0) + 1
if (detailRequestSequence !== null) detailRequestSequences.set(runId, detailRequestSequence)
const requestSelectionRevision = selectionRevision
const currentDetailRequest = silent ? null : ++detailRequestSequence
const isLatestRequest = () => expectedGeneration === generation
&& runRequestSequences.get(requestKey) === requestSequence
const isLatestDetailRequest = () => !silent
&& expectedGeneration === generation
&& detailRequestSequences.get(runId) === detailRequestSequence
&& detailRequestSequence === currentDetailRequest
&& selectionRevision === requestSelectionRevision
try {
if (!silent) {
detailLoading.value = true
@ -153,7 +154,7 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
runs.value = []
runRevisions.clear()
runRequestSequences.clear()
detailRequestSequences.clear()
detailRequestSequence++
revision = 0
loading.value = true
error.value = null
@ -202,6 +203,8 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
function select(runId: string | null, taskId: string | null = null) {
selectionRevision++
detailLoading.value = false
detailError.value = null
selectedRunId.value = runId
selectedTaskId.value = taskId
}
@ -229,7 +232,7 @@ export function useTeamRunHistory(options: TeamRunHistoryOptions = {}) {
runs.value = []
runRevisions.clear()
runRequestSequences.clear()
detailRequestSequences.clear()
detailRequestSequence++
loading.value = false
error.value = null
detailLoading.value = false

View File

@ -128,6 +128,7 @@
:selected-team-task-id="teamRunRouteQuery.taskId || null"
:team-runs-has-more="Boolean(teamRunsNextCursor)"
:team-runs-loading-more="teamRunsLoadingMore"
:readonly="workerConversationReadOnly"
@regenerate="handleRegenerate"
@rewind="handleRewind"
@suggestion-click="sendSuggestion"
@ -656,6 +657,7 @@ function reconcileCurrentConversation() {
const { isDragging, onDragEnter, onDragLeave, onDrop } = useFileDrop(processDroppedItems)
async function processDroppedItems(e: DragEvent) {
if (workerConversationReadOnly.value) return
const dtFiles = Array.from(e.dataTransfer?.files || [])
const items = Array.from(e.dataTransfer?.items || [])
@ -697,6 +699,7 @@ async function processDroppedItems(e: DragEvent) {
}
function handleDirectoryAttach(dirFiles: File[]) {
if (workerConversationReadOnly.value) return
if (!currentConversationId.value) {
newConversation()
}
@ -2096,7 +2099,8 @@ function handleStopStream() {
}
async function handleRegenerate(message: Message) {
if (isGenerating.value || !currentConversationId.value || !selectedAgentId.value) return
if (workerConversationReadOnly.value
|| isGenerating.value || !currentConversationId.value || !selectedAgentId.value) return
const idx = messages.value.indexOf(message)
if (idx >= 0) {
// The server drops the trailing assistant block and reuses the persisted
@ -2120,7 +2124,7 @@ async function handleRegenerate(message: Message) {
}
async function handleRewind(message: Message) {
if (isGenerating.value || !currentConversationId.value) return
if (workerConversationReadOnly.value || isGenerating.value || !currentConversationId.value) return
const idx = messages.value.indexOf(message)
if (idx < 0) return
const count = messages.value.length - idx
@ -2248,6 +2252,7 @@ function resetStreamingState() {
// ============ ============
async function handleFileSelect(files: File[]) {
if (workerConversationReadOnly.value) return
if (!currentConversationId.value) {
newConversation()
}

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import chatConsoleSource from '../ChatConsole.vue?raw'
describe('worker conversation write guards', () => {
it('guards regenerate rewind and file upload handlers in readonly worker sessions', () => {
for (const handler of [
'handleRegenerate',
'handleRewind',
'handleFileSelect',
'processDroppedItems',
'handleDirectoryAttach',
]) {
const start = chatConsoleSource.indexOf(`function ${handler}`)
expect(start).toBeGreaterThan(-1)
const body = chatConsoleSource.slice(start, start + 500)
expect(body).toContain('workerConversationReadOnly.value')
}
})
it('passes readonly state into the message list', () => {
expect(chatConsoleSource).toContain(':readonly="workerConversationReadOnly"')
})
})