Skip to content

Commit d31ec0f

Browse files
authored
reset button (#15014)
In ongoing and failed prompts, there is a new "refresh" button, which cancels prompt (if ongoing) and re-schedules it. <img width="841" height="343" alt="image" src="https://github.com/user-attachments/assets/77fecd84-3ebd-484d-8abd-ca9aa949602a" />
1 parent f364fe2 commit d31ec0f

6 files changed

Lines changed: 483 additions & 11 deletions

File tree

app/gui/integration-test/project-view/aiPlaceholder.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ test.use({ aiAvailable: true })
77
const aiPendingNode = (page: Page) => page.locator('.AiPendingNode')
88
const aiPendingStatus = (node: ReturnType<typeof aiPendingNode>) =>
99
node.locator('[data-testid="ai-pending-status"]')
10+
const aiPendingRefresh = (node: ReturnType<typeof aiPendingNode>) =>
11+
node.locator('[data-testid="ai-pending-refresh"]')
1012

1113
/** Switch the AI mock into deferred mode so each `generateComponent` waits for the test to drive it. */
1214
async function enableDeferredAiMock(page: Page): Promise<void> {
@@ -319,3 +321,107 @@ test('after a cancel, a freshly submitted prompt is dispatched and completes', a
319321
await expect(placeholders).toHaveCount(0)
320322
await expect(locate.graphNodeByBinding(page, 'ai_component1')).toBeVisible()
321323
})
324+
325+
test('refresh button is hidden while queued and visible once running or failed', async ({
326+
editorPage,
327+
page,
328+
}) => {
329+
await editorPage
330+
await enableDeferredAiMock(page)
331+
332+
// Two prompts: first runs, second sits queued behind it.
333+
await openAiPrompt(page, 'first')
334+
await openAiPrompt(page, 'second')
335+
const placeholders = aiPendingNode(page)
336+
await expect(placeholders).toHaveCount(2)
337+
338+
// Queued placeholder must not offer refresh.
339+
await expect(aiPendingRefresh(placeholders.nth(1))).toHaveCount(0)
340+
// The first placeholder is still queued (no `started` event yet) — also hidden.
341+
await expect(aiPendingRefresh(placeholders.nth(0))).toHaveCount(0)
342+
343+
// Drive `started` on the first; refresh appears for the now-running entry only.
344+
const probe = await probeAiMock(page)
345+
await emitProgress(page, { requestId: probe.ids[0]!, kind: 'started' })
346+
await expect(aiPendingRefresh(placeholders.nth(0))).toHaveCount(1)
347+
await expect(aiPendingRefresh(placeholders.nth(1))).toHaveCount(0)
348+
349+
// Fail the first; refresh stays visible on the failed entry.
350+
await failAi(page, probe.ids[0]!, 'Mock failure')
351+
const failed = page.locator('.AiPendingNode.failed')
352+
await expect(failed).toHaveCount(1)
353+
await expect(aiPendingRefresh(failed)).toHaveCount(1)
354+
})
355+
356+
test('refresh on a failed placeholder re-queues the same prompt', async ({ editorPage, page }) => {
357+
await editorPage
358+
await enableDeferredAiMock(page)
359+
360+
await openAiPrompt(page, 'first')
361+
const placeholders = aiPendingNode(page)
362+
await expect(placeholders).toHaveCount(1)
363+
const { ids } = await probeAiMock(page)
364+
const failedId = ids[0]!
365+
await failAi(page, failedId, 'Mock failure for testing')
366+
367+
const failed = page.locator('.AiPendingNode.failed')
368+
await expect(failed).toHaveCount(1)
369+
370+
// Click refresh: the failed placeholder is dismissed and a fresh request is enqueued.
371+
await aiPendingRefresh(failed).click()
372+
await expect(failed).toHaveCount(0)
373+
await expect(placeholders).toHaveCount(1)
374+
375+
// A new request id appears in the mock — proves a fresh IPC went out.
376+
await expect
377+
.poll(
378+
async () => {
379+
const p = await probeAiMock(page)
380+
return p.ids.find((id) => id !== failedId)
381+
},
382+
{ timeout: 10_000 },
383+
)
384+
.not.toBeUndefined()
385+
const probe = await probeAiMock(page)
386+
const newId = probe.ids.find((id) => id !== failedId)!
387+
388+
await emitProgress(page, { requestId: newId, kind: 'started' })
389+
await resolveAi(page, newId)
390+
await expect(placeholders).toHaveCount(0)
391+
await expect(locate.graphNodeByBinding(page, 'ai_component1')).toBeVisible()
392+
})
393+
394+
test('refresh on a running placeholder cancels the in-flight prompt and re-queues it', async ({
395+
editorPage,
396+
page,
397+
}) => {
398+
await editorPage
399+
await enableDeferredAiMock(page)
400+
401+
await openAiPrompt(page, 'first')
402+
const placeholders = aiPendingNode(page)
403+
await expect(placeholders).toHaveCount(1)
404+
const firstProbe = await probeAiMock(page)
405+
const firstId = firstProbe.ids[0]!
406+
await emitProgress(page, { requestId: firstId, kind: 'started' })
407+
408+
await aiPendingRefresh(placeholders.nth(0)).click()
409+
// Original request is cancelled and a new one is dispatched.
410+
await expect
411+
.poll(
412+
async () => {
413+
const p = await probeAiMock(page)
414+
return p.ids.find((id) => id !== firstId)
415+
},
416+
{ timeout: 10_000 },
417+
)
418+
.not.toBeUndefined()
419+
const afterRefresh = await probeAiMock(page)
420+
expect(afterRefresh.cancels).toEqual([firstId])
421+
const secondId = afterRefresh.ids.find((id) => id !== firstId)!
422+
423+
await emitProgress(page, { requestId: secondId, kind: 'started' })
424+
await resolveAi(page, secondId)
425+
await expect(placeholders).toHaveCount(0)
426+
await expect(locate.graphNodeByBinding(page, 'ai_component1')).toBeVisible()
427+
})

app/gui/src/project-view/components/GraphEditor/AiPendingNode.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { AiPending } from '@/stores/ongoingAiPrompts'
55
import { computed } from 'vue'
66
77
const { pending } = defineProps<{ pending: AiPending }>()
8-
const emit = defineEmits<{ cancel: [] }>()
8+
const emit = defineEmits<{ cancel: []; refresh: [] }>()
99
1010
const transform = computed(() => {
1111
const { x, y } = pending.position
@@ -32,6 +32,14 @@ const transform = computed(() => {
3232
phase="loading-medium"
3333
/>
3434
<span class="prompt">{{ pending.prompt }}</span>
35+
<SvgButton
36+
v-if="pending.status === 'running' || pending.status === 'failed'"
37+
class="refresh"
38+
data-testid="ai-pending-refresh"
39+
name="refresh"
40+
title="Retry AI prompt"
41+
@activate="emit('refresh')"
42+
/>
3543
<SvgButton class="cancel" name="close" title="Cancel AI prompt" @activate="emit('cancel')" />
3644
</div>
3745
</div>
@@ -70,6 +78,8 @@ const transform = computed(() => {
7078
align-items: center;
7179
gap: 8px;
7280
min-height: var(--node-base-height, 32px);
81+
/* Roughly match a default node's footprint so the placeholder reads as node-shaped, not pill-shaped. */
82+
min-width: 300px;
7383
padding: 6px 12px;
7484
border-radius: var(--node-border-radius, 16px);
7585
background-color: var(--color-node-background-pending, #d8d8d8);
@@ -87,12 +97,14 @@ const transform = computed(() => {
8797
}
8898
8999
.prompt {
100+
/* Absorb skeleton slack so trailing buttons stay flush right when content is shorter than min-width. */
101+
flex: 1 1 auto;
90102
white-space: nowrap;
91103
overflow: hidden;
92104
text-overflow: ellipsis;
93-
max-width: 320px;
94105
}
95106
107+
.refresh,
96108
.cancel {
97109
flex: 0 0 auto;
98110
}

app/gui/src/project-view/components/GraphEditor/GraphNodes.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const layerStyle = computed(() => ({
9696
:key="entry.id"
9797
:pending="entry"
9898
@cancel="aiPrompts.cancel(entry.id)"
99+
@refresh="aiPrompts.refresh(entry.id)"
99100
/>
100101
</div>
101102
</template>

0 commit comments

Comments
 (0)