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 ed5b05f6d33..75ecd3e973c 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 bf72b3f1287..7e0229c4875 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -411,7 +411,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", @@ -529,6 +530,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.", @@ -3127,10 +3129,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/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); }); diff --git a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts index 6bbfb43f2dd..716af88653a 100644 --- a/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/persistenceRuntime.test.ts @@ -39,9 +39,11 @@ 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, + hasRetryablePendingChanges: false, localDraftStatus: 'ok', projectBoardAssignments: [], shouldRetry: false, @@ -102,6 +104,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 +515,76 @@ 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, + hasRetryablePendingChanges: 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("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)); vi.mocked(persistence.saveWorkbench).mockResolvedValueOnce({ - ...saveResult(aggregate.state), + ...saveResult(aggregate.state, 'conflicted'), + awaitsDecision: true, hasPendingChanges: true, shouldRetry: false, }); @@ -528,7 +596,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..93046a5e461 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,9 @@ export const createWorkbenchPersistenceRuntime = ({ } return; } - if (result.shouldRetry) { + // 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' }); @@ -264,8 +269,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.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 2a07c51d02f..6c8501074f4 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/durableSyncedPersistence.ts @@ -198,9 +198,16 @@ 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; + /** + * 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; @@ -1385,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) { @@ -1476,8 +1485,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; @@ -2039,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) { @@ -3306,6 +3321,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 @@ -3314,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/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..607d540a92b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.browser.test.tsx @@ -0,0 +1,145 @@ +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('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' }; + + 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..152ce78eb95 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} + {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 89aacbe4d7b..a5ce5615ffa 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -556,6 +556,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' } @@ -5796,7 +5797,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..d3d7cf63dff 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,58 @@ 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('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; + + 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 8c998829eca..f7351e169d7 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchStore.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchStore.ts @@ -653,6 +653,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'), @@ -734,11 +735,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': { @@ -850,6 +857,7 @@ const recordDiagnosticForAction = ( break; } case 'autosaveSucceeded': { + diagnostics.loggedRetryReason = null; recordLogEvent( 'debug', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -858,14 +866,31 @@ const recordDiagnosticForAction = ( break; } case 'autosavePending': { + if (diagnostics.loggedRetryReason === action.error) { + break; + } + diagnostics.loggedRetryReason = action.error; 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': { + diagnostics.loggedRetryReason = null; + 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; } case 'autosaveFailed': { + diagnostics.loggedRetryReason = null; recordLogEvent( 'error', { area: 'autosave', namespace: 'persistence', projectId: nextState.activeProjectId }, @@ -885,6 +910,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) { @@ -928,7 +954,7 @@ export const createWorkbenchStore = ( } setSnapshotState(nextState); - recordDiagnosticForAction(action, previousState, nextState); + recordDiagnosticForAction(action, previousState, nextState, diagnostics); }; const getState = (): WorkbenchState => state;