mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(team): harden readonly run interactions (#596)
This commit is contained in:
parent
9a3b95df53
commit
41ffe38699
@ -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
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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"')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user