From 7ab56b0ddbde314dc739ae90a6f4c55ea20700bf Mon Sep 17 00:00:00 2001 From: kingxiao630 <320199328+kingxiao630@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:30:45 +0800 Subject: [PATCH 1/2] fix(chat): keep the overflow menu reachable on settled complete Goals A completed Goal hid both the lifecycle action and the overflow menu, so goals.clear had no entry point and the completed ribbon stayed on screen forever. Suppress interactions only while the final task is still settling; once idle, restore the Edit/Remove menu so owners can clear the completed Goal. Refs #1447 --- .../src/components/chat/GoalRibbon.test.ts | 32 +++++++++++++++++++ .../src/components/chat/GoalRibbon.vue | 19 ++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/opensquilla-webui/src/components/chat/GoalRibbon.test.ts b/opensquilla-webui/src/components/chat/GoalRibbon.test.ts index d0b7528d02..12a4e64397 100644 --- a/opensquilla-webui/src/components/chat/GoalRibbon.test.ts +++ b/opensquilla-webui/src/components/chat/GoalRibbon.test.ts @@ -77,6 +77,10 @@ async function openActions(host: HTMLElement) { await nextTick() } +function openMenu(host: HTMLElement) { + host.querySelector('button[aria-label="Goal actions"]')?.click() +} + afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount() document.body.innerHTML = '' @@ -416,6 +420,34 @@ describe('GoalRibbon', () => { expect(host.querySelector('[aria-haspopup="menu"]')).toBeNull() }) + it('keeps the overflow menu reachable on a settled complete Goal', async () => { + const onClear = vi.fn() + const host = mountRibbon({ + goal: goal({ status: 'complete', activeTaskId: null, executionState: 'idle' }), + onClear, + }) + + expect(host.textContent).not.toContain('Finalizing result') + const trigger = host.querySelector('button[aria-label="Goal actions"]') + expect(trigger).not.toBeNull() + await openActions(host) + const items = [...host.querySelectorAll('[role="menuitem"]')] + expect(items.map(item => item.textContent?.trim())).toEqual(['Edit goal', 'Remove goal']) + items[1]?.click() + expect(onClear).toHaveBeenCalledOnce() + }) + + it('does not reopen the overflow menu when a complete Goal is still settling', async () => { + const host = mountRibbon({ + goal: goal({ status: 'complete', activeTaskId: 'task-1', executionState: 'working' }), + }) + + expect(host.querySelector('button[aria-label="Goal actions"]')).toBeNull() + openMenu(host) + await nextTick() + expect(host.querySelector('[role="menu"]')).toBeNull() + }) + it('uses touch-sized actions on narrow layouts', () => { expect(goalRibbonSource).toContain('min-height: 44px') expect(goalRibbonSource).toContain('min-width: 44px') diff --git a/opensquilla-webui/src/components/chat/GoalRibbon.vue b/opensquilla-webui/src/components/chat/GoalRibbon.vue index 8880082467..0a340eec94 100644 --- a/opensquilla-webui/src/components/chat/GoalRibbon.vue +++ b/opensquilla-webui/src/components/chat/GoalRibbon.vue @@ -78,7 +78,7 @@ @@ -86,7 +86,7 @@ {{ t('chat.goal.finalizing') }} @@ -26,17 +35,31 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/Icon.vue' -import type { GoalSnapshot } from '@/composables/chat/useChatGoals' +import { goalHasSettledTerminalOutcome, type GoalSnapshot } from '@/composables/chat/useChatGoals' const props = withDefaults(defineProps<{ goal: GoalSnapshot elapsed: string inline?: boolean + removable?: boolean + busy?: boolean }>(), { inline: false, + removable: false, + busy: false, }) +const emit = defineEmits<{ + clear: [goal: GoalSnapshot] +}>() + const { t } = useI18n() +const canRemove = computed(() => props.removable && goalHasSettledTerminalOutcome(props.goal)) + +function clearGoal() { + if (props.busy || !canRemove.value) return + emit('clear', props.goal) +} const titleText = computed(() => { if (!props.inline) return t('chat.goal.completeTitle') @@ -80,6 +103,7 @@ const metaText = computed(() => { } .goal-outcome--inline { display: inline-flex; + flex-wrap: wrap; width: auto; max-width: 100%; margin: 0; @@ -130,6 +154,34 @@ const metaText = computed(() => { text-overflow: ellipsis; white-space: nowrap; } +.goal-outcome__remove { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: var(--sp-1, 4px); + min-height: 28px; + padding: var(--sp-1, 4px) var(--sp-2, 8px); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted, var(--muted)); + font: inherit; + white-space: nowrap; + cursor: pointer; +} +.goal-outcome__remove:hover:not(:disabled) { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, transparent); +} +.goal-outcome__remove:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.goal-outcome__remove:disabled { + opacity: 0.5; + cursor: default; +} @media (max-width: 720px) { .goal-outcome { align-items: stretch; @@ -137,6 +189,20 @@ const metaText = computed(() => { } .goal-outcome--inline { display: flex; + flex-direction: row; + align-items: center; + } + .goal-outcome__summary { + flex-wrap: wrap; + } + .goal-outcome__title { + flex-shrink: 1; + white-space: normal; + } + .goal-outcome__remove { + align-self: flex-start; + min-width: 44px; + min-height: 44px; } } diff --git a/opensquilla-webui/src/views/ChatView.goal-outcome.test.ts b/opensquilla-webui/src/views/ChatView.goal-outcome.test.ts index a2423eee6a..6351a159d4 100644 --- a/opensquilla-webui/src/views/ChatView.goal-outcome.test.ts +++ b/opensquilla-webui/src/views/ChatView.goal-outcome.test.ts @@ -14,9 +14,11 @@ describe('ChatView Goal outcome placement', () => { ) }) - it('keeps settled outcomes read-only while active Goal controls remain available', () => { + it('connects settled and active Goal removal to the same guarded confirmation', () => { expect(chatViewSource).not.toContain('@goal-edit=') - expect(chatViewSource).not.toContain('@goal-clear=') + expect(chatViewSource).toContain('@goal-clear="clearGoal"') + expect(chatViewSource).toContain(':goal-removable="!shareMode && !forkTransition"') + expect(chatViewSource).toContain(':removable="!shareMode && !forkTransition"') expect(chatViewSource).toContain('