From 86cf47f78436a0c72500416fcea3d3b2ba85948c Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Wed, 14 Oct 2026 21:38:59 -0600 Subject: [PATCH 1/5] fix(autosave): say which open project needs a decision instead of reporting an error A save that leaves a project with a sync conflict or schema refusal now shows "Needs attention", and the tooltip and project switcher name the project and the reason. Other leftover pending work retries instead of sticking; every chip status is translated, and session-save failures are logged. --- .../Users Guide/02.Projects/introduction.mdx | 2 + .../frontend/webv2/public/locales/en.json | 14 +- .../src/workbench/persistenceRuntime.test.ts | 33 ++++- .../webv2/src/workbench/persistenceRuntime.ts | 10 +- .../webv2/src/workbench/projectContracts.ts | 8 +- .../projects/durableSyncedPersistence.ts | 10 +- .../webv2/src/workbench/projects/syncStore.ts | 21 +++ .../topbar/ProjectSwitcher.browser.test.tsx | 36 ++++- .../shell/topbar/ProjectSwitcher.tsx | 27 +++- .../AutosaveStatusWidgetView.browser.test.tsx | 128 ++++++++++++++++++ .../AutosaveStatusWidgetView.tsx | 123 +++++++++++++---- .../webv2/src/workbench/workbenchState.ts | 13 +- .../src/workbench/workbenchStore.test.ts | 31 ++++- .../webv2/src/workbench/workbenchStore.ts | 14 +- 14 files changed, 425 insertions(+), 45 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx diff --git a/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx b/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx index 36ac9747983..1bc542a1960 100644 --- a/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx +++ b/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx @@ -100,6 +100,8 @@ Because projects live on the server, the same project can be open in two browser A similar banner appears if a project you have open is deleted from another tab or computer. Your work stays available until you choose what to do with it. +The banner shows above the project it concerns. While any open project is waiting for your decision, the status bar reads **Autosave: Needs attention**: hover it to see which projects and why. The project switcher marks those projects **Needs attention**; switch to each one to see its banner. Your other projects keep saving in the meantime. + :::tip To avoid conflicts, work on a project in one tab at a time. Use the switcher to move between projects within that tab. ::: diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index a71f0883be6..8dc4ecd727c 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -410,7 +410,8 @@ "unsavedLayoutChanges": "Unsaved layout changes" }, "projectSwitcher": { - "trigger": "Switch project. Current project: {{name}}" + "trigger": "Switch project. Current project: {{name}}", + "triggerNeedsAttention": "Switch project. Current project: {{name}}. An open project needs attention" }, "queue": { "actions": "Queue actions", @@ -528,6 +529,7 @@ "malformed": "\"{{name}}\" is damaged and was not opened. Its saved copy is untouched and can be exported.", "unsupportedVersion": "\"{{name}}\" was saved by a newer version of Invoke and cannot be opened here." }, + "needsAttention": "Needs attention", "newProject": "New project", "newProjectStart": "Choose how to start", "noSavedProjects": "No saved projects yet.", @@ -2941,10 +2943,20 @@ } }, "autosaveStatus": { + "attention": "Needs attention", + "attentionDescription": "These open projects can't sync until you choose what to keep. Switch to each one to resolve it:", + "attentionItem": "{{name}} — {{reason}}", "chipLabel": "Autosave: {{status}}", + "error": "Failed", + "idle": "Idle", "label": "Autosave", "lastSaved": "Last saved: {{time}}", "pending": "Unsaved changes", + "reasonDeleted": "deleted on the server", + "reasonRevision": "changed elsewhere", + "reasonSchema": "needs a newer version of Invoke", + "retrying": "The last save didn't finish. Retrying automatically.", + "saving": "Saving…", "status": "Status: {{status}}" }, "canvas": { diff --git a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts index 6bbfb43f2dd..36fe6906eed 100644 --- a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts @@ -39,6 +39,7 @@ const snapshot = (state: WorkbenchState, savedAt = '2026-07-17T00:00:00.000Z'): }); const saveResult = (state: WorkbenchState, savedAt?: string): WorkbenchSaveResult => ({ + awaitsDecision: false, conflicts: [], error: null, hasPendingChanges: false, @@ -102,6 +103,7 @@ const createAggregate = (initialState = createInitialWorkbenchState()) => { reportLoadError: (error) => events.push(`load-error:${error}`), reportLoadUnavailable: (error) => events.push(`load-unavailable:${error}`), saveFailed: (error) => events.push(`save-failed:${error}`), + saveNeedsAttention: (savedAt) => events.push(`save-needs-attention:${savedAt}`), savePending: (error) => events.push(`save-pending:${error}`), saveScheduled: () => events.push('save-scheduled'), saveStarted: () => events.push('save-started'), @@ -512,11 +514,37 @@ describe('Workbench persistence runtime', () => { expect(aggregate.events).toContain('save-succeeded:acknowledged'); }); + it('retries pending work that awaits no decision instead of asking for attention', async () => { + const aggregate = createAggregate(); + const { persistence } = createPersistence(() => Promise.resolve(null)); + // A newer draft was staged while the push was in flight: acknowledged, yet still pending, with nothing to resolve. + vi.mocked(persistence.saveWorkbench) + .mockResolvedValueOnce({ ...saveResult(aggregate.state, 'rebased'), hasPendingChanges: true }) + .mockResolvedValueOnce(saveResult(aggregate.state, 'acknowledged')); + const clock = new FakeClock(); + const runtime = createWorkbenchPersistenceRuntime({ aggregate: aggregate.port, clock, persistence }); + runtime.start(); + await flushPromises(); + aggregate.edit(); + clock.runAll(); + await flushPromises(); + + expect(aggregate.events.some((event) => event.startsWith('save-needs-attention:'))).toBe(false); + expect(aggregate.events).not.toContain('save-succeeded:rebased'); + expect(aggregate.events).toContain('save-pending:Autosave is pending and will retry.'); + + clock.runAll(); + await flushPromises(); + expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2); + expect(aggregate.events.at(-1)).toBe('save-succeeded:acknowledged'); + }); + it('leaves a non-retrying conflict save in an explicit attention state', async () => { const aggregate = createAggregate(); const { persistence } = createPersistence(() => Promise.resolve(null)); vi.mocked(persistence.saveWorkbench).mockResolvedValueOnce({ - ...saveResult(aggregate.state), + ...saveResult(aggregate.state, 'conflicted'), + awaitsDecision: true, hasPendingChanges: true, shouldRetry: false, }); @@ -528,7 +556,8 @@ describe('Workbench persistence runtime', () => { clock.runAll(); await flushPromises(); - expect(aggregate.events).toContain('save-pending:Autosave requires your attention.'); + expect(aggregate.events).toContain('save-needs-attention:conflicted'); + expect(aggregate.events.some((event) => event.startsWith('save-pending:'))).toBe(false); expect(aggregate.events.some((event) => event.startsWith('save-succeeded:'))).toBe(false); clock.runAll(); expect(persistence.saveWorkbench).toHaveBeenCalledOnce(); diff --git a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts index ed93c8f1aa2..3db74f2c77a 100644 --- a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts +++ b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts @@ -21,6 +21,9 @@ export interface PersistenceAggregatePort { /** Persisted projects the canvas version gate refused; they are absent from the hydrated state. */ reportRefusedProjects(refused: readonly RefusedWorkbenchProject[]): void; saveFailed(error: string): void; + /** The save finished, but an open project awaits a user decision before it can sync. */ + saveNeedsAttention(savedAt: string): void; + /** The save could not finish and will retry on its own. */ savePending(error: string): void; /** Persisted content changed and its save is scheduled: the workbench is dirty until that save is acknowledged. */ saveScheduled(): void; @@ -253,7 +256,8 @@ export const createWorkbenchPersistenceRuntime = ({ } return; } - if (result.shouldRetry) { + // Pending work that awaits no decision (a draft staged while its push was in flight) is pushed by a retry. + if (result.shouldRetry || (result.hasPendingChanges && !result.awaitsDecision)) { const message = 'Autosave is pending and will retry.'; aggregate.savePending(message); publish({ error: message, phase: 'idle' }); @@ -264,8 +268,8 @@ export const createWorkbenchPersistenceRuntime = ({ failedRevision = null; scheduledRevision = null; retryAttempt = 0; - if (result.hasPendingChanges) { - aggregate.savePending('Autosave requires your attention.'); + if (result.awaitsDecision) { + aggregate.saveNeedsAttention(result.snapshot.savedAt); } else { aggregate.saveSucceeded(result.snapshot.savedAt); } diff --git a/invokeai/frontend/webv2/src/workbench/projectContracts.ts b/invokeai/frontend/webv2/src/workbench/projectContracts.ts index 379665bbf54..f65e66dd1f8 100644 --- a/invokeai/frontend/webv2/src/workbench/projectContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/projectContracts.ts @@ -157,8 +157,12 @@ export interface UndoRedoHistory { } export interface AutosaveState { - /** `pending`: persisted content changed since the last acknowledged save and its save has not started yet. */ - status: 'idle' | 'pending' | 'saving' | 'saved' | 'error'; + /** + * `pending`: persisted content changed since the last acknowledged save and its save has not started yet, or a save + * that could not finish will retry. `attention`: the last save finished for every other project, but an open project + * awaits a user decision (a sync conflict or an unsupported schema); the project sync snapshot says which. + */ + status: 'idle' | 'pending' | 'saving' | 'saved' | 'attention' | 'error'; lastSavedAt?: string; error?: string; } diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts index 2a07c51d02f..36515d9bbfe 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts @@ -198,6 +198,8 @@ const awaitDatabaseDeletion = async ( }; export interface DurableWorkbenchSaveResult { + /** An open project holds a conflict or schema refusal that only the user can resolve; it does not retry. */ + awaitsDecision: boolean; conflicts: ProjectConflictInfo[]; error: string | null; hasPendingChanges: boolean; @@ -1476,8 +1478,13 @@ export const createDurableSyncedWorkbenchPersistence = ( await api.saveSession(state, editorSessionId, draftLineages, owner.signal); assertOwner(); lastSessionJson = sessionJson; - } catch { + } catch (error) { assertOwner(); + logger.warn({ + error, + message: 'The workbench session could not be saved; autosave will retry.', + name: 'persistence.session-save-failed', + }); sessionSavePending = true; hasPending = true; return false; @@ -3306,6 +3313,7 @@ export const createDurableSyncedWorkbenchPersistence = ( const projectBoardAssignments = [...pendingBoardAssignments.values()]; pendingBoardAssignments.clear(); return { + awaitsDecision: conflicts.size > 0 || schemaRefusals.size > 0, conflicts: [...conflicts.values()], error: firstPushError instanceof Error diff --git a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts index 32dc7a580bc..8366e97f716 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/syncStore.ts @@ -92,6 +92,27 @@ export const useProjectSync = (): ProjectSyncSnapshot => store.useSnapshot(); export const useProjectSyncSelector = store.useSelector; +/** Why a project's local work cannot sync until the user decides what to keep. */ +export type ProjectSyncAttention = 'deleted' | 'revision' | 'schema'; + +export const getProjectSyncAttention = (info: ProjectSyncInfo | undefined): ProjectSyncAttention | null => + info?.schemaRefusal ? 'schema' : (info?.conflict?.kind ?? null); + +const selectProjectSyncAttention = (snapshot: ProjectSyncSnapshot): Record => { + const attention: Record = {}; + for (const [projectId, info] of Object.entries(snapshot.projects)) { + const reason = getProjectSyncAttention(info); + if (reason) { + attention[projectId] = reason; + } + } + return attention; +}; + +/** The projects awaiting a decision, by id; compared shallowly, so sync reports that change nothing keep identity. */ +export const useProjectSyncAttention = (): Readonly> => + store.useSelector(selectProjectSyncAttention); + export const getProjectSyncSnapshot = store.getSnapshot; export const subscribeProjectSync = store.subscribe; diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.browser.test.tsx index 78fd5aee564..a0a479e1b22 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.browser.test.tsx @@ -2,6 +2,7 @@ import type { Project } from '@workbench/projectContracts'; import { ChakraProvider } from '@chakra-ui/react'; import { system } from '@theme/system'; +import { reportProjectSync } from '@workbench/projects/syncStore'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -76,7 +77,7 @@ vi.mock('@workbench/WorkbenchContext', () => ({ useWorkbenchSelector: (selector: (snapshot: unknown) => unknown) => selector({ backendConnection: { status: 'connected' }, - projects: [harness.makeProject()], + projects: [harness.makeProject(), { id: 'project-2', name: 'Other project', queue: { items: [] } }], }), })); vi.mock('react-i18next', () => ({ @@ -116,12 +117,45 @@ afterEach(async () => { setProjectSwitcherOpen(false); root?.unmount(); }); + reportProjectSync({ hasPendingChanges: false, localDraftStatus: 'ok', projects: {}, recoverableDrafts: [] }); host?.remove(); host = null; root = null; }); describe('ProjectSwitcher', () => { + it('marks the open project that awaits a sync decision, and says so on the trigger', async () => { + await act(() => + reportProjectSync({ + hasPendingChanges: true, + localDraftStatus: 'ok', + projects: { + 'project-1': { isPendingPush: false, revision: 5 }, + 'project-2': { + conflict: { detectedAt: '2026-10-14T09:00:00.000Z', kind: 'deleted' }, + isPendingPush: true, + revision: 3, + }, + }, + recoverableDrafts: [], + }) + ); + + await vi.waitFor(() => { + const rows = [...document.querySelectorAll('[role="menuitemradio"]')].map((row) => [ + row.getAttribute('data-value'), + row.textContent?.includes('projects.needsAttention'), + ]); + expect(rows).toEqual([ + ['project-1', false], + ['project-2', true], + ]); + expect( + document.querySelector('button[aria-label="topbar.projectSwitcher.triggerNeedsAttention"]') + ).not.toBeNull(); + }); + }); + it('keeps the rename dialog open after selecting Rename', async () => { let renameItem: HTMLElement | undefined; await vi.waitFor(() => { diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.tsx index 71595a833e5..da3c9e6331f 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/ProjectSwitcher.tsx @@ -3,7 +3,7 @@ import type { DeleteProjectBoards } from '@workbench/projects/api'; import type { ProjectSummary } from '@workbench/projects/library'; import type { MouseEvent } from 'react'; -import { Icon, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { HStack, Icon, Menu, Portal, Stack, Text } from '@chakra-ui/react'; import { flushGenerateDrafts } from '@features/generation/react'; import { useModelLoads } from '@features/models'; import { getProjectQueueIndicatorState } from '@features/queue/contracts'; @@ -18,6 +18,7 @@ import { QueueCircularProgress } from '@workbench/components/QueueProgressIndica import { formatRelativeTime } from '@workbench/launchpad/formatRelativeTime'; import { OpenProjectDialog } from '@workbench/projects/components'; import { refreshProjectLibrary, renameLibraryProject, useProjectLibrarySelector } from '@workbench/projects/library'; +import { useProjectSyncAttention } from '@workbench/projects/syncStore'; import { useProjectActions } from '@workbench/projects/useProjectActions'; import { useExportOpenProject } from '@workbench/projects/useProjectFileActions'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -36,6 +37,7 @@ import { PencilIcon, PlusIcon, Trash2Icon, + TriangleAlertIcon, XIcon, } from 'lucide-react'; import { lazy, Suspense, useCallback, useRef, useState } from 'react'; @@ -63,6 +65,7 @@ export const ProjectSwitcher = () => { const activeProjectName = useActiveProjectSelector((project) => project.name); const librarySummaries = useProjectLibrarySelector((snapshot) => snapshot.summaries); const openProjects = useWorkbenchSelector((snapshot) => snapshot.projects); + const syncAttention = useProjectSyncAttention(); const backendConnectionStatus = useWorkbenchSelector((snapshot) => snapshot.backendConnection.status); const modelLoads = useModelLoads(); const queries = useWorkbenchQueries(); @@ -177,6 +180,7 @@ export const ProjectSwitcher = () => { ); const openProjectIds = new Set(openProjects.map((project) => project.id)); + const hasProjectNeedingAttention = openProjects.some((project) => syncAttention[project.id] !== undefined); const openProjectSummaries = openProjects; const recentSummaries = librarySummaries .filter((project) => !openProjectIds.has(project.id)) @@ -189,12 +193,20 @@ export const ProjectSwitcher = () => { @@ -239,6 +251,7 @@ export const ProjectSwitcher = () => { key={project.id} backendConnectionStatus={backendConnectionStatus} loadingModelsCount={modelLoads.length} + needsAttention={syncAttention[project.id] !== undefined} project={project} /> ))} @@ -305,12 +318,16 @@ export const ProjectSwitcher = () => { const OpenProjectRow = ({ backendConnectionStatus, loadingModelsCount, + needsAttention, project, }: { backendConnectionStatus: string; loadingModelsCount: number; + /** The project cannot sync until the user resolves a conflict or schema refusal in it. */ + needsAttention: boolean; project: Project; }) => { + const { t } = useTranslation(); const baseState = getProjectQueueIndicatorState({ isConnected: backendConnectionStatus === 'connected', loadingModelsCount, @@ -332,6 +349,12 @@ const OpenProjectRow = ({ + {needsAttention ? ( + + + {t('projects.needsAttention')} + + ) : null} ); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx new file mode 100644 index 00000000000..f1676192a85 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx @@ -0,0 +1,128 @@ +import type { AutosaveState } from '@workbench/projectContracts'; +import type { WidgetViewProps } from '@workbench/widgetContracts'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { reportProjectSync } from '@workbench/projects/syncStore'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const harness = vi.hoisted(() => ({ + autosave: { status: 'idle' } as AutosaveState, + projects: [ + { id: 'portraits', name: 'Portraits' }, + { id: 'landscapes', name: 'Landscapes' }, + ], +})); + +// The frame barrel reaches the whole workbench context; the chip itself is the platform control. +vi.mock('@workbench/widget-frame', async () => ({ + StatusWidgetChip: (await import('@platform/ui/StatusWidgetChip')).StatusWidgetChip, +})); +vi.mock('@workbench/WorkbenchContext', () => ({ + useWorkbenchSelector: (selector: (snapshot: unknown) => unknown) => + selector({ autosave: harness.autosave, projects: harness.projects }), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { name?: string; reason?: string; status?: string }) => + options?.name ? `${options.name}: ${options.reason}` : options?.status ? `${key}(${options.status})` : key, + }), +})); + +import { AutosaveStatusWidgetView } from './AutosaveStatusWidgetView'; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const render = async (presentation: 'compact' | 'tooltip'): Promise => { + await act(() => + root?.render( + + + + ) + ); + return host!; +}; + +const reportSync = (projects: Parameters[0]['projects']): void => + reportProjectSync({ hasPendingChanges: true, localDraftStatus: 'ok', projects, recoverableDrafts: [] }); + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + host = null; + root = null; + reportProjectSync({ hasPendingChanges: false, localDraftStatus: 'ok', projects: {}, recoverableDrafts: [] }); +}); + +describe('AutosaveStatusWidgetView', () => { + it('names each open project awaiting a decision and why, instead of reporting an error', async () => { + harness.autosave = { status: 'attention' }; + reportSync({ + // A project no longer open is not listed: the user cannot switch to it to resolve it. + closed: { + conflict: { detectedAt: '2026-10-14T09:00:00.000Z', kind: 'deleted' }, + isPendingPush: true, + revision: 2, + }, + landscapes: { isPendingPush: false, revision: 4 }, + portraits: { + conflict: { detectedAt: '2026-10-14T09:00:00.000Z', kind: 'revision', serverRevision: 7 }, + isPendingPush: true, + revision: 6, + }, + }); + + expect((await render('compact')).textContent).toBe( + 'widgets.autosaveStatus.chipLabel(widgets.autosaveStatus.attention)' + ); + + const items = [...(await render('tooltip')).querySelectorAll('li')].map((item) => item.textContent); + expect(items).toEqual(['Portraits: widgets.autosaveStatus.reasonRevision']); + expect(host?.textContent).not.toContain('widgets.autosaveStatus.error'); + }); + + it('reports a schema refusal as needing a newer client', async () => { + harness.autosave = { status: 'attention' }; + reportSync({ + landscapes: { + isPendingPush: true, + revision: 4, + schemaRefusal: { kind: 'canvas', maxCanvasSchemaVersion: 1, minimumCanvasSchemaVersion: 2 }, + }, + }); + + const items = [...(await render('tooltip')).querySelectorAll('li')].map((item) => item.textContent); + expect(items).toEqual(['Landscapes: widgets.autosaveStatus.reasonSchema']); + }); + + it('shows a retrying save as unsaved changes without surfacing the internal reason', async () => { + harness.autosave = { error: 'Autosave is pending and will retry.', status: 'pending' }; + + expect((await render('compact')).textContent).toBe( + 'widgets.autosaveStatus.chipLabel(widgets.autosaveStatus.pending)' + ); + const tooltip = (await render('tooltip')).textContent; + expect(tooltip).toContain('widgets.autosaveStatus.retrying'); + expect(tooltip).not.toContain('Autosave is pending and will retry.'); + }); + + it('keeps the failure message for a save that failed', async () => { + harness.autosave = { error: 'Backend rejected the revision', status: 'error' }; + + expect((await render('compact')).textContent).toBe( + 'widgets.autosaveStatus.chipLabel(widgets.autosaveStatus.error)' + ); + expect((await render('tooltip')).textContent).toContain('Backend rejected the revision'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx index 73982345ad5..8c5a0d65c4d 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx @@ -1,49 +1,114 @@ +import type { AutosaveState } from '@workbench/projectContracts'; +import type { ProjectSyncAttention } from '@workbench/projects/syncStore'; import type { WidgetViewProps } from '@workbench/widgetContracts'; +import type { TFunction } from 'i18next'; -import { Stack, Text } from '@chakra-ui/react'; +import { List, Stack, Text } from '@chakra-ui/react'; +import { useProjectSyncAttention } from '@workbench/projects/syncStore'; import { StatusWidgetChip } from '@workbench/widget-frame'; import { useWorkbenchSelector } from '@workbench/WorkbenchContext'; import { CloudAlertIcon, CloudCheckIcon, CloudUploadIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +const getStatusLabel = (status: AutosaveState['status'], t: TFunction): string => { + switch (status) { + case 'attention': + return t('widgets.autosaveStatus.attention'); + case 'error': + return t('widgets.autosaveStatus.error'); + case 'idle': + return t('widgets.autosaveStatus.idle'); + case 'pending': + return t('widgets.autosaveStatus.pending'); + case 'saved': + return t('common.saved'); + case 'saving': + return t('widgets.autosaveStatus.saving'); + } +}; + +const getAttentionReasonLabel = (reason: ProjectSyncAttention, t: TFunction): string => { + switch (reason) { + case 'deleted': + return t('widgets.autosaveStatus.reasonDeleted'); + case 'revision': + return t('widgets.autosaveStatus.reasonRevision'); + case 'schema': + return t('widgets.autosaveStatus.reasonSchema'); + } +}; + export const AutosaveStatusWidgetView = ({ presentation }: WidgetViewProps) => { const { t } = useTranslation(); const autosave = useWorkbenchSelector((snapshot) => snapshot.autosave); + const label = getStatusLabel(autosave.status, t); + + if (presentation === 'tooltip') { + return ; + } + const icon = - autosave.status === 'error' + autosave.status === 'error' || autosave.status === 'attention' ? CloudAlertIcon : autosave.status === 'pending' || autosave.status === 'saving' ? CloudUploadIcon : CloudCheckIcon; - const label = - autosave.status === 'saved' - ? t('common.saved') - : autosave.status === 'pending' - ? t('widgets.autosaveStatus.pending') - : autosave.status; + const tone = autosave.status === 'error' ? 'error' : autosave.status === 'attention' ? 'warning' : undefined; - if (presentation === 'tooltip') { - return ( - - - {t('widgets.autosaveStatus.label')} + return ( + + {t('widgets.autosaveStatus.chipLabel', { status: label })} + + ); +}; + +const AutosaveStatusDetails = ({ autosave, label }: { autosave: AutosaveState; label: string }) => { + const { t } = useTranslation(); + const attention = useProjectSyncAttention(); + // Names by id, compared shallowly, so edits that leave every name alone do not rerender the tooltip. + const projectNames = useWorkbenchSelector((snapshot) => + Object.fromEntries(snapshot.projects.map((project) => [project.id, project.name])) + ); + const attentionProjects = Object.entries(projectNames).flatMap(([projectId, name]) => { + const reason = attention[projectId]; + return reason ? [{ name, projectId, reason }] : []; + }); + + return ( + + + {t('widgets.autosaveStatus.label')} + + + {t('widgets.autosaveStatus.status', { status: label })} + + {autosave.lastSavedAt ? ( + + {t('widgets.autosaveStatus.lastSaved', { time: new Date(autosave.lastSavedAt).toLocaleString() })} + ) : null} + {autosave.status === 'attention' && attentionProjects.length > 0 ? ( + + {t('widgets.autosaveStatus.attentionDescription')} + + {attentionProjects.map(({ name, projectId, reason }) => ( + + {t('widgets.autosaveStatus.attentionItem', { name, reason: getAttentionReasonLabel(reason, t) })} + + ))} + + + ) : null} + {autosave.status === 'pending' && autosave.error ? ( - {t('widgets.autosaveStatus.status', { status: label })} + {t('widgets.autosaveStatus.retrying')} - {autosave.lastSavedAt ? ( - - {t('widgets.autosaveStatus.lastSaved', { time: autosave.lastSavedAt })} - - ) : null} - {autosave.error ? ( - - {autosave.error} - - ) : null} - - ); - } - - return {t('widgets.autosaveStatus.chipLabel', { status: label })}; + ) : null} + {autosave.status === 'error' && autosave.error ? ( + + {autosave.error} + + ) : null} + + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 4a233f8afb0..c44c5edb399 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -538,6 +538,7 @@ type WorkbenchReducerAction = | { type: 'autosaveScheduled' } | { type: 'autosaveStarted' } | { type: 'autosavePending'; error: string } + | { type: 'autosaveNeedsAttention'; savedAt: string } | { type: 'autosaveSucceeded'; savedAt: string } | { type: 'autosaveFailed'; error: string } | { type: 'markAllNotificationsRead' } @@ -5645,7 +5646,17 @@ export const __workbenchReducerInternal = ( return { ...state, autosave: { status: 'saving' } }; } case 'autosavePending': { - return { ...state, autosave: { error: action.error, status: 'error' } }; + return { + ...state, + autosave: { + ...(state.autosave.lastSavedAt ? { lastSavedAt: state.autosave.lastSavedAt } : {}), + error: action.error, + status: 'pending', + }, + }; + } + case 'autosaveNeedsAttention': { + return { ...state, autosave: { lastSavedAt: action.savedAt, status: 'attention' } }; } case 'autosaveSucceeded': { return { ...state, autosave: { lastSavedAt: action.savedAt, status: 'saved' } }; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts index 373fc8280dc..fd3b80d8aef 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts @@ -489,7 +489,8 @@ describe('createWorkbenchStore', () => { const projectId = store.getSnapshot().activeProject.id; store.internal.persistence.saveStarted(); - store.internal.persistence.savePending('Autosave requires your attention.'); + store.internal.persistence.savePending('Autosave is pending and will retry.'); + store.internal.persistence.saveNeedsAttention('2026-10-14T09:00:00.000Z'); store.internal.persistence.saveFailed('Backend rejected the revision'); store.commands.queue.setStatus({ error: 'Out of memory', projectId, queueItemId: 'missing', status: 'failed' }); @@ -507,11 +508,37 @@ describe('createWorkbenchStore', () => { name: 'persistence.autosave-failed', source: { area: 'autosave', namespace: 'persistence', projectId }, }, - { level: 'warn', name: 'persistence.autosave-pending' }, + { level: 'warn', name: 'persistence.autosave-needs-attention' }, + { + context: { reason: 'Autosave is pending and will retry.' }, + level: 'warn', + name: 'persistence.autosave-pending', + }, { level: 'debug', name: 'persistence.autosave-started' }, ]); }); + it('reports a retrying save as pending and a project awaiting a decision as attention, not as failures', () => { + const store = createWorkbenchStore(); + const persistence = store.internal.persistence; + + persistence.saveSucceeded('2026-10-14T09:00:00.000Z'); + persistence.savePending('Autosave is pending and will retry.'); + + expect(store.getSnapshot().autosave).toEqual({ + error: 'Autosave is pending and will retry.', + lastSavedAt: '2026-10-14T09:00:00.000Z', + status: 'pending', + }); + + persistence.saveStarted(); + persistence.saveNeedsAttention('2026-10-14T09:00:05.000Z'); + + // Every other project did save, so the time stays visible while one project waits. + expect(store.getSnapshot().autosave).toEqual({ lastSavedAt: '2026-10-14T09:00:05.000Z', status: 'attention' }); + expect(store.getSnapshot().notifications).toEqual([]); + }); + it('records accepted widget failures into diagnostics once', () => { const store = createWorkbenchStore(); const projectId = store.getSnapshot().activeProject.id; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index d1e89b9641e..38226a2ec99 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -651,6 +651,7 @@ const createPersistenceAdapter = (dispatch: WorkbenchDispatch, getState: () => W return { ok: true }; }, saveFailed: command('autosaveFailed', (error: string) => ({ error })), + saveNeedsAttention: command('autosaveNeedsAttention', (savedAt: string) => ({ savedAt })), savePending: command('autosavePending', (error: string) => ({ error })), saveScheduled: command('autosaveScheduled'), saveStarted: command('autosaveStarted'), @@ -859,7 +860,18 @@ const recordDiagnosticForAction = ( recordLogEvent( 'warn', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, - { context: { reason: action.error }, message: 'Autosave needs attention', name: 'persistence.autosave-pending' } + { context: { reason: action.error }, message: 'Autosave will retry', name: 'persistence.autosave-pending' } + ); + break; + } + case 'autosaveNeedsAttention': { + recordLogEvent( + 'warn', + { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, + { + message: 'Autosave needs attention: an open project has a sync conflict or an unsupported schema', + name: 'persistence.autosave-needs-attention', + } ); break; } From 51c6d8d67e28122069bd1507cea7c1730bf68931 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 17 Oct 2026 02:23:26 -0600 Subject: [PATCH 2/5] fix(autosave): retry pending work per project and stop the save loop after a partly failed delete A save that supersedes a deleted project's pending push now clears it, so a delete whose local cleanup failed no longer keeps autosave retrying every 30 s; deleting a project also forgets its schema refusal. The save result reports retryable pending work separately from decisions, so one project's conflict no longer blocks another project's push, and attention is shown once nothing retryable is left. The autosave retry warning is logged once per retry cycle instead of on every attempt. --- .../src/workbench/persistenceRuntime.test.ts | 42 ++++- .../webv2/src/workbench/persistenceRuntime.ts | 5 +- .../projects/durableSyncedPersistence.test.ts | 151 ++++++++++++++++++ .../projects/durableSyncedPersistence.ts | 11 ++ .../src/workbench/workbenchStore.test.ts | 21 +++ .../webv2/src/workbench/workbenchStore.ts | 18 ++- 6 files changed, 243 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts index 36fe6906eed..716af88653a 100644 --- a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts @@ -43,6 +43,7 @@ const saveResult = (state: WorkbenchState, savedAt?: string): WorkbenchSaveResul conflicts: [], error: null, hasPendingChanges: false, + hasRetryablePendingChanges: false, localDraftStatus: 'ok', projectBoardAssignments: [], shouldRetry: false, @@ -519,7 +520,11 @@ describe('Workbench persistence runtime', () => { const { persistence } = createPersistence(() => Promise.resolve(null)); // A newer draft was staged while the push was in flight: acknowledged, yet still pending, with nothing to resolve. vi.mocked(persistence.saveWorkbench) - .mockResolvedValueOnce({ ...saveResult(aggregate.state, 'rebased'), hasPendingChanges: true }) + .mockResolvedValueOnce({ + ...saveResult(aggregate.state, 'rebased'), + hasPendingChanges: true, + hasRetryablePendingChanges: true, + }) .mockResolvedValueOnce(saveResult(aggregate.state, 'acknowledged')); const clock = new FakeClock(); const runtime = createWorkbenchPersistenceRuntime({ aggregate: aggregate.port, clock, persistence }); @@ -539,6 +544,41 @@ describe('Workbench persistence runtime', () => { expect(aggregate.events.at(-1)).toBe('save-succeeded:acknowledged'); }); + it("retries another project's pending work while one awaits a decision, then asks for attention", async () => { + const aggregate = createAggregate(); + const { persistence } = createPersistence(() => Promise.resolve(null)); + vi.mocked(persistence.saveWorkbench) + .mockResolvedValueOnce({ + ...saveResult(aggregate.state, 'first'), + awaitsDecision: true, + hasPendingChanges: true, + hasRetryablePendingChanges: true, + }) + .mockResolvedValueOnce({ + ...saveResult(aggregate.state, 'second'), + awaitsDecision: true, + hasPendingChanges: true, + hasRetryablePendingChanges: false, + }); + const clock = new FakeClock(); + const runtime = createWorkbenchPersistenceRuntime({ aggregate: aggregate.port, clock, persistence }); + runtime.start(); + await flushPromises(); + aggregate.edit(); + clock.runAll(); + await flushPromises(); + + expect(aggregate.events).toContain('save-pending:Autosave is pending and will retry.'); + expect(aggregate.events).not.toContain('save-needs-attention:first'); + + clock.runAll(); + await flushPromises(); + expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2); + expect(aggregate.events.at(-1)).toBe('save-needs-attention:second'); + clock.runAll(); + expect(persistence.saveWorkbench).toHaveBeenCalledTimes(2); + }); + it('leaves a non-retrying conflict save in an explicit attention state', async () => { const aggregate = createAggregate(); const { persistence } = createPersistence(() => Promise.resolve(null)); diff --git a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts index 3db74f2c77a..93046a5e461 100644 --- a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts +++ b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.ts @@ -256,8 +256,9 @@ export const createWorkbenchPersistenceRuntime = ({ } return; } - // Pending work that awaits no decision (a draft staged while its push was in flight) is pushed by a retry. - if (result.shouldRetry || (result.hasPendingChanges && !result.awaitsDecision)) { + // Pending work that no decision blocks (a draft staged while its push was in flight) is pushed by a retry, even + // while another project awaits a decision; attention is reported once nothing retryable is left. + if (result.shouldRetry || result.hasRetryablePendingChanges) { const message = 'Autosave is pending and will retry.'; aggregate.savePending(message); publish({ error: message, phase: 'idle' }); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts index 41f58adfd61..69de3803b7f 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.test.ts @@ -138,6 +138,14 @@ const createApi = () => { return api; }; +const canvasSchemaRefusal = () => + new ApiError( + JSON.stringify({ + detail: { code: 'canvas_schema_unsupported', max_canvas_schema_version: 3, minimum_canvas_schema_version: 4 }, + }), + 412 + ); + const createService = ( owner: AccountScope, api: DurableProjectPersistenceApi, @@ -1443,6 +1451,14 @@ describe('durable project persistence', () => { expect(result.conflicts).toEqual([ expect.objectContaining({ kind: 'revision', projectId: project.id, serverRevision: 2 }), ]); + // Only the user can resolve it: the save reports a decision, and nothing a retry could push. + expect(result).toMatchObject({ + awaitsDecision: true, + error: null, + hasPendingChanges: true, + hasRetryablePendingChanges: false, + shouldRetry: false, + }); expect(api.createProject).not.toHaveBeenCalled(); await expect(draftStore.get(project.id, 'editor-1')).resolves.toMatchObject({ draft: { conflict: { kind: 'revision', serverRevision: 2 }, state: 'conflict' }, @@ -1450,6 +1466,141 @@ describe('durable project persistence', () => { }); }); + it('reports a schema refusal as a decision that no retry can clear', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(owner, api); + const loaded = await service.loadWorkbench(); + vi.mocked(api.updateProject).mockRejectedValueOnce(canvasSchemaRefusal()); + + const result = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }])); + + expect(result).toMatchObject({ + awaitsDecision: true, + conflicts: [], + error: null, + hasPendingChanges: true, + hasRetryablePendingChanges: false, + shouldRetry: false, + }); + expect(getProjectSyncSnapshot().projects[project.id]).toMatchObject({ + schemaRefusal: { kind: 'canvas', maxCanvasSchemaVersion: 3, minimumCanvasSchemaVersion: 4 }, + }); + }); + + it('keeps retrying another project while one awaits a decision, then reports only the decision', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const draftStore = createMemoryProjectDraftStore(); + const conflicted = createDraftProject([]); + const rebased = createDraftProject([]); + api.records.set(conflicted.id, toRecord(conflicted)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: conflicted.id, + openProjectIds: [conflicted.id], + }); + const service = createService(owner, api, draftStore); + const loaded = await service.loadWorkbench(); + api.records.set(conflicted.id, toRecord({ ...conflicted, name: 'remote edit' }, 2)); + const originalCreate = api.createProject; + // A newer draft of the second project is staged while its creation is in flight, so it stays pending once acknowledged. + vi.mocked(api.createProject).mockImplementationOnce(async (request) => { + await draftStore.stage({ + baseRevision: null, + documentJson: JSON.stringify({ ...request.data, name: 'newer edit' }), + documentSchemaVersion: 3, + editorSessionId: 'editor-1', + generation: 2, + projectId: rebased.id, + updatedAt: Date.parse(now) + 1, + writerToken: 'writer-1', + }); + return originalCreate(request, owner); + }); + + const first = await service.saveWorkbench( + stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }, rebased]) + ); + + expect(first.conflicts).toEqual([expect.objectContaining({ kind: 'revision', projectId: conflicted.id })]); + expect(first).toMatchObject({ awaitsDecision: true, hasPendingChanges: true, hasRetryablePendingChanges: true }); + + const second = await service.saveWorkbench( + stateWith([ + { ...loaded.state.projects[0]!, name: 'local edit' }, + { ...rebased, name: 'newer edit' }, + ]) + ); + + expect(api.records.get(rebased.id)).toMatchObject({ name: 'newer edit', revision: 2 }); + expect(second.conflicts).toEqual([expect.objectContaining({ kind: 'revision', projectId: conflicted.id })]); + expect(second).toMatchObject({ awaitsDecision: true, hasPendingChanges: true, hasRetryablePendingChanges: false }); + }); + + it('stops reporting pending work for a project whose deletion left only the local draft behind', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const backingStore = createMemoryProjectDraftStore(); + const draftStore: ProjectDraftStore = { + ...backingStore, + delete: vi.fn(() => Promise.resolve({ kind: 'unavailable' as const })), + }; + const project = createDraftProject([]); + vi.mocked(api.createProject).mockRejectedValueOnce(new ApiError('Service unavailable.', 503)); + const service = createService(owner, api, draftStore); + + const unsynced = await service.saveWorkbench(stateWith([project])); + expect(unsynced).toMatchObject({ error: null, hasPendingChanges: true, shouldRetry: true }); + + // The server delete commits, so the project stays fenced as deleted even though the local removal failed. + await expect(service.deleteProjectOnServer(project.id)).rejects.toThrow('Retry deletion.'); + expect(api.deleteProject).toHaveBeenCalledWith(project.id, expect.anything(), 'release'); + + const later = await service.saveWorkbench(stateWith([project])); + + expect(later).toMatchObject({ + awaitsDecision: false, + error: null, + hasPendingChanges: false, + hasRetryablePendingChanges: false, + shouldRetry: false, + }); + expect(service.hasPendingChanges()).toBe(false); + expect(api.createProject).toHaveBeenCalledOnce(); + }); + + it('forgets a schema refusal when its project is deleted', async () => { + const owner = captureAccountScope(); + const api = createApi(); + const project = createDraftProject([]); + api.records.set(project.id, toRecord(project)); + vi.mocked(api.loadSession).mockResolvedValue({ + account: createInitialWorkbenchState().account, + activeProjectId: project.id, + openProjectIds: [project.id], + }); + const service = createService(owner, api); + const loaded = await service.loadWorkbench(); + vi.mocked(api.updateProject).mockRejectedValueOnce(canvasSchemaRefusal()); + const refused = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }])); + expect(refused.awaitsDecision).toBe(true); + + await service.deleteProjectOnServer(project.id); + // The library broker closes the tab separately; an autosave can land first. + const result = await service.saveWorkbench(stateWith([{ ...loaded.state.projects[0]!, name: 'local edit' }])); + + expect(result).toMatchObject({ awaitsDecision: false, hasPendingChanges: false }); + expect(getProjectSyncSnapshot().projects[project.id]?.schemaRefusal).toBeUndefined(); + }); + it('restores an unresolved conflict and its local document after reload', async () => { const owner = captureAccountScope(); const api = createApi(); diff --git a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts index 36515d9bbfe..6c8501074f4 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts @@ -203,6 +203,11 @@ export interface DurableWorkbenchSaveResult { conflicts: ProjectConflictInfo[]; error: string | null; hasPendingChanges: boolean; + /** + * Pending work that no decision blocks (an unsaved session, or a draft staged while its push was in flight): a retry + * pushes it, even while another project awaits a decision. + */ + hasRetryablePendingChanges: boolean; localDraftStatus: LocalDraftStatus; projectBoardAssignments: ProjectBoardAssignment[]; shouldRetry: boolean; @@ -1387,6 +1392,8 @@ export const createDurableSyncedWorkbenchPersistence = ( await settleJournaledDocument(plan.project.id, await getDraftStore()); } if (deletedProjectIds.has(plan.project.id)) { + // A pending push that the deletion overtook would otherwise keep every later save pending. + pendingProjectIds.delete(plan.project.id); return { documentJson: plan.serialized.documentJson, kind: 'superseded' }; } if (!plan.needsPush) { @@ -2046,6 +2053,7 @@ export const createDurableSyncedWorkbenchPersistence = ( draftEditorSessionIds.delete(resolvedProjectId); syncEntries.delete(resolvedProjectId); conflicts.delete(resolvedProjectId); + schemaRefusals.delete(resolvedProjectId); projectsRequiringDocumentUpgrade.delete(resolvedProjectId); pendingProjectIds.delete(resolvedProjectId); for (const [key, handoff] of pendingRetargetHandoffs) { @@ -3322,6 +3330,9 @@ export const createDurableSyncedWorkbenchPersistence = ( ? 'A project could not be saved.' : null, hasPendingChanges: hasPending, + hasRetryablePendingChanges: + sessionSavePending || + [...pendingProjectIds].some((projectId) => !conflicts.has(projectId) && !schemaRefusals.has(projectId)), localDraftStatus, projectBoardAssignments, shouldRetry, diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts index fd3b80d8aef..d3d7cf63dff 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.test.ts @@ -518,6 +518,27 @@ describe('createWorkbenchStore', () => { ]); }); + it('warns once per retry cycle while autosave keeps retrying', () => { + const store = createWorkbenchStore(); + const persistence = store.internal.persistence; + const pendingWarnings = () => + getLogSnapshot().entries.filter((entry) => entry.name === 'persistence.autosave-pending'); + + for (let attempt = 0; attempt < 3; attempt += 1) { + persistence.saveStarted(); + persistence.savePending('Autosave is pending and will retry.'); + } + expect(pendingWarnings()).toHaveLength(1); + expect(store.getSnapshot().autosave.status).toBe('pending'); + + persistence.saveStarted(); + persistence.saveSucceeded('2026-10-14T09:00:00.000Z'); + persistence.saveStarted(); + persistence.savePending('Autosave is pending and will retry.'); + + expect(pendingWarnings()).toHaveLength(2); + }); + it('reports a retrying save as pending and a project awaiting a decision as attention, not as failures', () => { const store = createWorkbenchStore(); const persistence = store.internal.persistence; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts index 38226a2ec99..45e5029f5fd 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -733,11 +733,17 @@ const hasPersistedStateChanged = (previous: WorkbenchState, next: WorkbenchState const getDiagnosticProjectId = (state: WorkbenchState, projectId?: string): string | undefined => projectId ?? getActiveProject(state)?.id; +/** The reason of the retry warning last logged: a retrying save repeats it until a save finishes or fails. */ +interface AutosaveDiagnosticState { + loggedRetryReason: string | null; +} + /** The store is the one diagnostic owner for reported errors, widget failures and autosave outcomes. */ const recordDiagnosticForAction = ( action: WorkbenchAction, previousState: WorkbenchState, - nextState: WorkbenchState + nextState: WorkbenchState, + diagnostics: AutosaveDiagnosticState ): void => { switch (action.type) { case 'recordError': { @@ -849,6 +855,7 @@ const recordDiagnosticForAction = ( break; } case 'autosaveSucceeded': { + diagnostics.loggedRetryReason = null; recordLogEvent( 'debug', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -857,6 +864,10 @@ const recordDiagnosticForAction = ( break; } case 'autosavePending': { + if (diagnostics.loggedRetryReason === action.error) { + break; + } + diagnostics.loggedRetryReason = action.error; recordLogEvent( 'warn', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -865,6 +876,7 @@ const recordDiagnosticForAction = ( break; } case 'autosaveNeedsAttention': { + diagnostics.loggedRetryReason = null; recordLogEvent( 'warn', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -876,6 +888,7 @@ const recordDiagnosticForAction = ( break; } case 'autosaveFailed': { + diagnostics.loggedRetryReason = null; recordLogEvent( 'error', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -895,6 +908,7 @@ export const createWorkbenchStore = ( let persistedRevision = 0; let invalidateLayoutPresetActivation = (): void => undefined; const snapshotStore = createExternalStore(createSnapshot(state, hasHydrated)); + const diagnostics: AutosaveDiagnosticState = { loggedRetryReason: null }; const setSnapshotState = (nextState: WorkbenchState, nextHasHydrated = hasHydrated): void => { if (Object.is(nextState, state) && nextHasHydrated === hasHydrated) { @@ -938,7 +952,7 @@ export const createWorkbenchStore = ( } setSnapshotState(nextState); - recordDiagnosticForAction(action, previousState, nextState); + recordDiagnosticForAction(action, previousState, nextState, diagnostics); }; const getState = (): WorkbenchState => state; From e02c1241302e7a1104dc98443471f4dfda996851 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 17 Oct 2026 16:04:11 -0600 Subject: [PATCH 3/5] chore(perf): re-record route byte baselines for the autosave attention status Editor routes grow about 44 KB over the 2026-10-09 capture (this change plus earlier main growth), which reached the 1% hard ceiling; request counts and source owners are unchanged. --- .../performance/architecture-baseline.json | 22 +- .../webv2/performance/browser-baseline.json | 218 +++++++++--------- 2 files changed, 120 insertions(+), 120 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index e8a49c4183f..5798e96fe0e 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 794977, + "brotliBytes": 796120, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 793689, + "gzipBytes": 794924, "imageRawBytes": 44398, - "initialRawBytes": 2042114, - "largestAssetRawBytes": 749029, + "initialRawBytes": 2047538, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 98621, + "ownedRawBytes": 99028, "requestCount": 35, "scriptRequestCount": 19, "sourceOwners": [ @@ -346,15 +346,15 @@ }, "editor": { "baseline": { - "brotliBytes": 1171873, + "brotliBytes": 1178109, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1165072, + "gzipBytes": 1171315, "imageRawBytes": 44398, - "initialRawBytes": 3208701, - "largestAssetRawBytes": 749029, + "initialRawBytes": 3231594, + "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, - "ownedRawBytes": 150716, + "ownedRawBytes": 152065, "requestCount": 72, "scriptRequestCount": 56, "sourceOwners": [ @@ -1092,7 +1092,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-17", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 9e45cb28593..be02d06c1d6 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,6 +1,6 @@ { "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome", - "capturedAt": "2026-10-09", + "capturedAt": "2026-10-17", "routes": [ { "activatedResourceBaseline": { @@ -14,13 +14,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 130.60000000149012, + "domContentLoadedMedianMs": 124.20000000298023, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 130.79999999701977, - "longestTaskMaxMs": 82, + "loadMedianMs": 124.20000000298023, + "longestTaskMaxMs": 79, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -29,14 +29,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1848626, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2078351 }, - "routeReadyMedianMs": 275.80000000447035, + "routeReadyMedianMs": 263.79999999701977, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -52,13 +52,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.10000000149012, + "domContentLoadedMedianMs": 124.79999999701977, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.20000000298023, - "longestTaskMaxMs": 81, + "loadMedianMs": 125, + "longestTaskMaxMs": 79, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -67,14 +67,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 30, - "scriptRawBytes": 1842561, + "scriptRawBytes": 1848626, "scriptRequestCount": 26, - "totalRawBytes": 2068864 + "totalRawBytes": 2078351 }, - "routeReadyMedianMs": 277, + "routeReadyMedianMs": 271.59999999403954, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -90,29 +90,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 126.59999999403954, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 125.70000000298023, - "longestTaskMaxMs": 173, + "loadMedianMs": 126.70000000298023, + "longestTaskMaxMs": 170, "owner": "workbench", - "projectSwitchMedianMs": 38.30000000447035, + "projectSwitchMedianMs": 35.6000000089407, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4103879, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4378002 }, - "routeReadyMedianMs": 430.19999999552965, + "routeReadyMedianMs": 433.79999999701977, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -128,29 +128,29 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 129.60000000149012, + "domContentLoadedMedianMs": 125.30000001192093, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 129.69999999552965, - "longestTaskMaxMs": 180, + "loadMedianMs": 125.40000000596046, + "longestTaskMaxMs": 167, "owner": "workbench", - "projectSwitchMedianMs": 44.099999994039536, + "projectSwitchMedianMs": 41.6000000089407, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4103879, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4378002 }, - "routeReadyMedianMs": 466.3999999985099, + "routeReadyMedianMs": 441.6000000089407, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -159,20 +159,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305463, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 516607, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 516607 }, - "domContentLoadedMedianMs": 125.5, + "domContentLoadedMedianMs": 122.70000000298023, "id": "editor-canvas", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 57.899999998509884, - "layoutSwitchMedianMs": 76.60000000149012, - "loadMedianMs": 125.60000000149012, - "longestTaskMaxMs": 179, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 58, + "layoutSwitchMedianMs": 76, + "loadMedianMs": 122.79999999701977, + "longestTaskMaxMs": 169, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -181,14 +181,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4620486, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4894609 }, - "routeReadyMedianMs": 1153.7999999970198, + "routeReadyMedianMs": 1169.2999999970198, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -197,20 +197,20 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 305162, + "largestAssetRawBytes": 305463, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 514518, + "scriptRawBytes": 516607, "scriptRequestCount": 9, - "totalRawBytes": 514518 + "totalRawBytes": 516607 }, - "domContentLoadedMedianMs": 126.59999999403954, + "domContentLoadedMedianMs": 128.5, "id": "editor-canvas", - "layoutAckMedianMs": 3.100000001490116, - "layoutReturnSwitchMedianMs": 52.70000000298023, - "layoutSwitchMedianMs": 72.20000000298023, - "loadMedianMs": 126.69999999552965, - "longestTaskMaxMs": 196, + "layoutAckMedianMs": 2.7999999970197678, + "layoutReturnSwitchMedianMs": 53.20000000298023, + "layoutSwitchMedianMs": 75, + "loadMedianMs": 128.69999998807907, + "longestTaskMaxMs": 188, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -219,14 +219,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 97, - "scriptRawBytes": 4578127, + "scriptRawBytes": 4620486, "scriptRequestCount": 92, - "totalRawBytes": 4848828 + "totalRawBytes": 4894609 }, - "routeReadyMedianMs": 1147.1000000014901, + "routeReadyMedianMs": 1174, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -235,20 +235,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 187100, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 420925, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 436338 }, - "domContentLoadedMedianMs": 125, + "domContentLoadedMedianMs": 127.59999999403954, "id": "editor-workflow", - "layoutAckMedianMs": 2.7000000029802322, - "layoutReturnSwitchMedianMs": 38.600000001490116, - "layoutSwitchMedianMs": 82.60000000149012, - "loadMedianMs": 125.09999999403954, - "longestTaskMaxMs": 174, + "layoutAckMedianMs": 3.300000011920929, + "layoutReturnSwitchMedianMs": 37.6000000089407, + "layoutSwitchMedianMs": 76.79999999701977, + "loadMedianMs": 127.70000000298023, + "longestTaskMaxMs": 177, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -257,14 +257,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4524804, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4814340 }, - "routeReadyMedianMs": 1171.6000000014901, + "routeReadyMedianMs": 1166.0999999940395, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -273,20 +273,20 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 187094, + "largestAssetRawBytes": 187100, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 420919, + "scriptRawBytes": 420925, "scriptRequestCount": 6, - "totalRawBytes": 436332 + "totalRawBytes": 436338 }, - "domContentLoadedMedianMs": 123.70000000298023, + "domContentLoadedMedianMs": 128.90000000596046, "id": "editor-workflow", - "layoutAckMedianMs": 2.6999999955296516, - "layoutReturnSwitchMedianMs": 41.80000000447035, - "layoutSwitchMedianMs": 128.80000000447035, - "loadMedianMs": 123.80000000447035, - "longestTaskMaxMs": 259, + "layoutAckMedianMs": 3, + "layoutReturnSwitchMedianMs": 44.900000005960464, + "layoutSwitchMedianMs": 130.70000000298023, + "loadMedianMs": 129, + "longestTaskMaxMs": 283, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -295,14 +295,14 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 95, - "scriptRawBytes": 4484528, + "scriptRawBytes": 4524804, "scriptRequestCount": 89, - "totalRawBytes": 4770642 + "totalRawBytes": 4814340 }, - "routeReadyMedianMs": 1192.8999999985099, + "routeReadyMedianMs": 1236.1000000089407, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -318,13 +318,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 125.79999999701977, + "domContentLoadedMedianMs": 129.09999999403954, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 41.100000001490116, - "loadMedianMs": 125.89999999850988, - "longestTaskMaxMs": 174, + "layoutSwitchMedianMs": 38, + "loadMedianMs": 129.29999999701977, + "longestTaskMaxMs": 169, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -333,14 +333,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4103879, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4378002 }, - "routeReadyMedianMs": 1431.3999999985099, + "routeReadyMedianMs": 1393.699999988079, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -356,13 +356,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 126.89999999850988, + "domContentLoadedMedianMs": 124.79999999701977, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 45.399999998509884, - "loadMedianMs": 127.09999999403954, - "longestTaskMaxMs": 170, + "layoutSwitchMedianMs": 45.79999999701977, + "loadMedianMs": 124.8999999910593, + "longestTaskMaxMs": 169, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -371,14 +371,14 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 44398, - "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "largestAssetRawBytes": 752421, + "otherRawBytes": 203902, "requestCount": 88, - "scriptRawBytes": 4063609, + "scriptRawBytes": 4103879, "scriptRequestCount": 83, - "totalRawBytes": 4334310 + "totalRawBytes": 4378002 }, - "routeReadyMedianMs": 1396.3999999985099, + "routeReadyMedianMs": 1394.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } From acb8cbea17ed9ac2c07fa097048873f55f515600 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 18 Oct 2026 13:45:13 -0600 Subject: [PATCH 4/5] test(palette): wait for pending modal cleanup Wait for passive effect cleanup after the lazy dialog appears to avoid a timing-dependent browser test failure. --- .../palette/WorkbenchCommandPalette.pending.browser.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPalette.pending.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPalette.pending.browser.test.tsx index ad1dcdd7f70..67dcb97f0e2 100644 --- a/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPalette.pending.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/palette/WorkbenchCommandPalette.pending.browser.test.tsx @@ -56,5 +56,6 @@ it('suspends shortcuts from the moment the palette opens, while its module is st }); await expect.poll(() => host.querySelector('[data-testid="palette"]')).not.toBeNull(); // The stand-in steps aside for the loaded dialog, which this test replaces with one that announces nothing. - expect(isModalPresent()).toBe(false); + // Its passive effect cleanup can run after the loaded dialog reaches the DOM. + await expect.poll(() => isModalPresent()).toBe(false); }); From ec59202f52fedab5c6d596d622c39903d3520104 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 18 Oct 2026 14:42:19 -0600 Subject: [PATCH 5/5] fix(autosave): list projects needing a decision while others retry The autosave tooltip now names conflicted or schema-refused open projects whatever the save status. Co-Authored-By: Claude Opus 5.5 --- .../AutosaveStatusWidgetView.browser.test.tsx | 17 +++++++++++++++++ .../AutosaveStatusWidgetView.tsx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx index f1676192a85..607d540a92b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx @@ -117,6 +117,23 @@ describe('AutosaveStatusWidgetView', () => { expect(tooltip).not.toContain('Autosave is pending and will retry.'); }); + it('names a conflicted open project while another project is still retrying', async () => { + harness.autosave = { error: 'Autosave is pending and will retry.', status: 'pending' }; + reportSync({ + landscapes: { isPendingPush: true, revision: 4 }, + portraits: { + conflict: { detectedAt: '2026-10-14T09:00:00.000Z', kind: 'deleted' }, + isPendingPush: true, + revision: 6, + }, + }); + + const tooltip = await render('tooltip'); + const items = [...tooltip.querySelectorAll('li')].map((item) => item.textContent); + expect(items).toEqual(['Portraits: widgets.autosaveStatus.reasonDeleted']); + expect(tooltip.textContent).toContain('widgets.autosaveStatus.retrying'); + }); + it('keeps the failure message for a save that failed', async () => { harness.autosave = { error: 'Backend rejected the revision', status: 'error' }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx index 8c5a0d65c4d..152ce78eb95 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx @@ -87,7 +87,7 @@ const AutosaveStatusDetails = ({ autosave, label }: { autosave: AutosaveState; l {t('widgets.autosaveStatus.lastSaved', { time: new Date(autosave.lastSavedAt).toLocaleString() })} ) : null} - {autosave.status === 'attention' && attentionProjects.length > 0 ? ( + {attentionProjects.length > 0 ? ( {t('widgets.autosaveStatus.attentionDescription')}