Explorar o código

fix(chat): 保持运行任务的停止入口可用

Samien hai 1 mes
pai
achega
3eb16f929d

+ 12 - 3
src/client/components/composer/ComposerShell.vue

@@ -87,9 +87,10 @@ const localError = ref('')
 let errorTimer: number | undefined
 
 const canSubmit = computed(() => !props.disabled && !props.sending && (text.value.trim().length > 0 || attachments.value.length > 0))
-// A running group still accepts new room messages. Only an ordinary session
-// turns its primary send affordance into an interrupt control.
-const showStop = computed(() => props.streaming && !text.value.trim() && !attachments.value.length && (props.stopWhileRunning || props.mode === 'chat'))
+const canStop = computed(() => props.streaming && (props.stopWhileRunning || props.mode === 'chat'))
+// A running session keeps an explicit interrupt control even when the composer
+// holds a draft, so resource waits can always be cancelled by the user.
+const showStop = computed(() => canStop.value && !text.value.trim() && !attachments.value.length)
 const compactModel = computed(() => props.modelLabel.split('/').filter(Boolean).at(-1) || props.modelLabel)
 const contextPercent = computed(() => props.contextLimit > 0 ? Math.min(100, Math.round(props.contextUsed / props.contextLimit * 100)) : 0)
 const hasContext = computed(() => props.contextLimit > 0)
@@ -539,6 +540,11 @@ defineExpose({
             {{ queueMode ? '排队' : 'Steer' }}
           </button>
           <span v-if="sending" class="composer-sending" role="status" aria-live="polite"><i />正在发送</span>
+          <span v-if="canStop && !showStop" class="composer-stop-hitbox">
+            <button class="composer-stop" type="button" aria-label="停止生成" title="停止生成" @click="emit('stop')">
+              <AppIcon name="stop" :size="15" />
+            </button>
+          </span>
           <span class="composer-send-hitbox">
             <button
               class="composer-send"
@@ -640,10 +646,13 @@ defineExpose({
 .queue-toggle { height: 26px; padding: 0 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 10px; }
 .queue-toggle:hover, .queue-toggle.active { background: var(--surface-hover); color: var(--text-primary); }
 .composer-send-hitbox { display: grid; place-items: center; width: 34px; height: 34px; }
+.composer-stop-hitbox { display: grid; place-items: center; width: 30px; height: 34px; }
 .composer-send { display: grid; place-items: center; width: 34px; min-width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--text-on-solid); cursor: pointer; box-shadow: 0 4px 12px rgba(41,36,39,.18); transition: transform 150ms ease, box-shadow 150ms ease; }
 .composer-send:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 7px 16px rgba(41,36,39,.22); }
 .composer-send:disabled { cursor: not-allowed; background: #9f9f9f; color: #fff; }
 .composer-send--stop { background: var(--accent); }
+.composer-stop { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; transition: color 150ms ease, border-color 150ms ease; }
+.composer-stop:hover { color: var(--danger); border-color: var(--danger); }
 .composer-sending { display: inline-flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; white-space: nowrap; }.composer-sending i { width: 5px; height: 5px; border-radius: 50%; background: var(--warning); animation: composer-pulse 1s ease-in-out infinite; }
 @keyframes composer-pulse { 50% { opacity: .25; transform: scale(.7); } }
 .composer-attachments { display: flex; width: 100%; max-width: 760px; margin: 0 auto 9px; flex-wrap: wrap; gap: 7px; }

+ 3 - 2
src/client/views/ConversationsView.vue

@@ -827,9 +827,10 @@ async function stopMember(id: string) {
   } catch (cause) { error.value = cause instanceof Error ? cause.message : '停止失败' }
 }
 async function control(action: 'stop' | 'reconcile') {
-  if (!run.value) return
+  const targetId = run.value?.id ?? active.value?.activeRunId ?? activeTask.value?.activeRunId
+  if (!targetId) return
   try {
-    await apiRequest(`/api/app/runs/${run.value.id}/${action}`, { method: 'POST', body: {} })
+    await apiRequest(`/api/app/runs/${targetId}/${action}`, { method: 'POST', body: {} })
     await load(undefined, true)
   } catch (e) {
     error.value = String(e)

+ 25 - 1
tests/client/composer.test.ts

@@ -1,7 +1,18 @@
 import { mount } from '@vue/test-utils'
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import ComposerShell from '@/components/composer/ComposerShell.vue'
 
+beforeEach(() => {
+  const values = new Map<string, string>()
+  vi.stubGlobal('localStorage', {
+    getItem: (key: string) => values.get(key) ?? null,
+    setItem: (key: string, value: string) => void values.set(key, value),
+    removeItem: (key: string) => void values.delete(key),
+  })
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
 describe('composer availability', () => {
   it('keeps the group-chat send action available while an Agent is running', () => {
     const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true } })
@@ -15,6 +26,19 @@ describe('composer availability', () => {
     expect(wrapper.find('[aria-label="发送消息"]').exists()).toBe(false)
   })
 
+  it('keeps an interrupt action available while a running session has a draft', async () => {
+    const wrapper = mount(ComposerShell, { props: { mode: 'group', streaming: true, stopWhileRunning: true } })
+    await wrapper.get('textarea').setValue('等待环境时补充的内容')
+
+    expect(wrapper.get('[aria-label="发送消息"]').attributes('disabled')).toBeUndefined()
+    const stop = wrapper.get('[aria-label="停止生成"]')
+    expect(stop.attributes('disabled')).toBeUndefined()
+    await stop.trigger('click')
+
+    expect(wrapper.emitted('stop')).toHaveLength(1)
+    expect(wrapper.emitted('send')).toBeUndefined()
+  })
+
   it('places an accessible blue fast-mode toggle beside the model control', () => {
     const wrapper = mount(ComposerShell, { props: { mode: 'chat', modelLabel: 'gpt-5.6-terra', fastMode: true } })
     const model = wrapper.get('.composer-tool--model')