diff --git a/docker/Dockerfile b/docker/Dockerfile
index f04a840b556..6be57bd8089 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -7,10 +7,12 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack use pnpm@10.x && corepack enable
-WORKDIR /build
+# Mirror the repository layout: the webv2 build reads the release version from invokeai/version.
+WORKDIR /build/invokeai/frontend/webv2
COPY invokeai/frontend/webv2/ ./
RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
+COPY invokeai/version/invokeai_version.py /build/invokeai/version/
RUN pnpm exec vite build
FROM web-builder AS legacy-web-builder
@@ -100,7 +102,7 @@ ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
CMD ["invokeai-web"]
# --link requires buldkit w/ dockerfile syntax 1.4, does not work with podman
-COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/webv2/dist
+COPY --link --from=web-builder /build/invokeai/frontend/webv2/dist ${INVOKEAI_SRC}/invokeai/frontend/webv2/dist
COPY --link --from=legacy-web-builder /legacy-build/dist ${INVOKEAI_SRC}/invokeai/frontend/webv1/dist
# add sources last to minimize image changes on code changes
diff --git a/docs/src/content/docs/development/Process/release-process.mdx b/docs/src/content/docs/development/Process/release-process.mdx
index 2764f4b101e..c6b22730fbc 100644
--- a/docs/src/content/docs/development/Process/release-process.mdx
+++ b/docs/src/content/docs/development/Process/release-process.mdx
@@ -21,7 +21,8 @@ The launcher uses GitHub as the source of truth for available releases.
Make a developer call-out for PRs to merge. Merge and test things
out. Create a branch with a name like user/chore/vX.X.X-prep and bump the version by editing
-`invokeai/version/invokeai_version.py` and commit locally.
+`invokeai/version/invokeai_version.py` and commit locally. The web UI reads its version from the same file when it is
+built, so there is no separate frontend version to update.
## Release Workflow
diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json
index 5dc3c67c054..b429ad38677 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": 893586,
+ "brotliBytes": 783834,
"cssRawBytes": 2159,
"fontRawBytes": 219480,
- "gzipBytes": 892942,
- "imageRawBytes": 156376,
- "initialRawBytes": 2118609,
- "largestAssetRawBytes": 749035,
+ "gzipBytes": 783016,
+ "imageRawBytes": 44398,
+ "initialRawBytes": 2012114,
+ "largestAssetRawBytes": 749225,
"otherAssetRawBytes": 0,
- "ownedRawBytes": 93082,
+ "ownedRawBytes": 94155,
"requestCount": 36,
"scriptRequestCount": 20,
"sourceOwners": [
@@ -197,6 +197,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/externalStore.ts",
@@ -325,7 +326,9 @@
"source:src/workbench/settings/launchpad.tsx",
"source:src/workbench/settings/settingsDialogStore.ts",
"source:src/workbench/settings/settingsSearchShortcut.ts",
- "source:src/workbench/settings/store.ts"
+ "source:src/workbench/settings/store.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts"
]
},
"owner": "app",
@@ -334,15 +337,15 @@
},
"editor": {
"baseline": {
- "brotliBytes": 1243382,
+ "brotliBytes": 1139046,
"cssRawBytes": 2159,
"fontRawBytes": 219480,
- "gzipBytes": 1237668,
- "imageRawBytes": 156376,
- "initialRawBytes": 3203516,
- "largestAssetRawBytes": 749035,
+ "gzipBytes": 1132999,
+ "imageRawBytes": 44398,
+ "initialRawBytes": 3111181,
+ "largestAssetRawBytes": 749225,
"otherAssetRawBytes": 0,
- "ownedRawBytes": 144128,
+ "ownedRawBytes": 143824,
"requestCount": 74,
"scriptRequestCount": 58,
"sourceOwners": [
@@ -715,6 +718,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/compareAndSwapRollback.ts",
@@ -1000,6 +1004,8 @@
"source:src/workbench/shell/topbar/useLayoutDrift.ts",
"source:src/workbench/shell/topbar/useTopbarShortcut.ts",
"source:src/workbench/shell/useMountedInstanceIds.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts",
"source:src/workbench/useNotify.ts",
"source:src/workbench/useOpenWorkbenchWidget.ts",
"source:src/workbench/widget-frame/FloatingWidgetLayer.tsx",
@@ -1066,7 +1072,7 @@
"source": "src/app/WorkbenchApp.tsx"
}
},
- "capturedAt": "2026-09-29",
+ "capturedAt": "2026-10-02",
"developmentInvalidation": {
"platformUiBarrel": {
"specifier": "@platform/ui",
diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json
index 20420c48665..b997a8f89be 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-01",
+ "capturedAt": "2026-10-02",
"routes": [
{
"activatedResourceBaseline": {
@@ -14,13 +14,13 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 128,
+ "domContentLoadedMedianMs": 128.09999999776483,
"id": "launchpad",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
"layoutSwitchMedianMs": 0,
- "loadMedianMs": 128.09999999776483,
- "longestTaskMaxMs": 81,
+ "loadMedianMs": 128.19999999925494,
+ "longestTaskMaxMs": 82,
"owner": "app",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:launchpad",
@@ -29,14 +29,14 @@
"cssRawBytes": 2159,
"fontRawBytes": 23664,
"imageRawBytes": 0,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 31,
- "scriptRawBytes": 1806891,
+ "scriptRawBytes": 1811680,
"scriptRequestCount": 27,
- "totalRawBytes": 2017245
+ "totalRawBytes": 2023258
},
- "routeReadyMedianMs": 266.29999999701977,
+ "routeReadyMedianMs": 266.8999999985099,
"scriptSourceOwnerSet": "launchpad-static",
"stateProfile": "empty"
},
@@ -52,13 +52,13 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 125,
+ "domContentLoadedMedianMs": 125.39999999850988,
"id": "launchpad",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
"layoutSwitchMedianMs": 0,
- "loadMedianMs": 125.09999999776483,
- "longestTaskMaxMs": 80,
+ "loadMedianMs": 125.59999999776483,
+ "longestTaskMaxMs": 79,
"owner": "app",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:launchpad",
@@ -67,14 +67,14 @@
"cssRawBytes": 2159,
"fontRawBytes": 23664,
"imageRawBytes": 0,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 31,
- "scriptRawBytes": 1806891,
+ "scriptRawBytes": 1811680,
"scriptRequestCount": 27,
- "totalRawBytes": 2017245
+ "totalRawBytes": 2023258
},
- "routeReadyMedianMs": 275.6000000014901,
+ "routeReadyMedianMs": 274.5999999977648,
"scriptSourceOwnerSet": "launchpad-static",
"stateProfile": "representative"
},
@@ -90,29 +90,29 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 129.30000000074506,
+ "domContentLoadedMedianMs": 125.69999999925494,
"id": "editor-minimal",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
"layoutSwitchMedianMs": 0,
- "loadMedianMs": 129.5,
- "longestTaskMaxMs": 160,
+ "loadMedianMs": 125.89999999850988,
+ "longestTaskMaxMs": 169,
"owner": "workbench",
- "projectSwitchMedianMs": 36.80000000074506,
+ "projectSwitchMedianMs": 35.900000002235174,
"readyMark": "invokeai:ready:widget:center:preview",
"remediationTicket": "deepen-widget-registry-loading",
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 89,
- "scriptRawBytes": 3900320,
+ "scriptRawBytes": 3907818,
"scriptRequestCount": 84,
- "totalRawBytes": 4267050
+ "totalRawBytes": 4163794
},
- "routeReadyMedianMs": 424.0999999977648,
+ "routeReadyMedianMs": 420.80000000074506,
"scriptSourceOwnerSet": "editor-minimal-static",
"stateProfile": "empty"
},
@@ -128,29 +128,29 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 125.60000000149012,
+ "domContentLoadedMedianMs": 130.30000000074506,
"id": "editor-minimal",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
"layoutSwitchMedianMs": 0,
- "loadMedianMs": 125.80000000074506,
- "longestTaskMaxMs": 162,
+ "loadMedianMs": 130.30000000074506,
+ "longestTaskMaxMs": 165,
"owner": "workbench",
- "projectSwitchMedianMs": 39.80000000074506,
+ "projectSwitchMedianMs": 43.400000002235174,
"readyMark": "invokeai:ready:widget:center:preview",
"remediationTicket": "deepen-widget-registry-loading",
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 89,
- "scriptRawBytes": 3900320,
+ "scriptRawBytes": 3907818,
"scriptRequestCount": 84,
- "totalRawBytes": 4267050
+ "totalRawBytes": 4163794
},
- "routeReadyMedianMs": 429.80000000074506,
+ "routeReadyMedianMs": 446.5,
"scriptSourceOwnerSet": "editor-minimal-static",
"stateProfile": "representative"
},
@@ -159,20 +159,20 @@
"cssRawBytes": 0,
"fontRawBytes": 0,
"imageRawBytes": 0,
- "largestAssetRawBytes": 310063,
+ "largestAssetRawBytes": 310062,
"otherRawBytes": 0,
"requestCount": 9,
- "scriptRawBytes": 507630,
+ "scriptRawBytes": 507632,
"scriptRequestCount": 9,
- "totalRawBytes": 507630
+ "totalRawBytes": 507632
},
- "domContentLoadedMedianMs": 128.30000000074506,
+ "domContentLoadedMedianMs": 128.60000000149012,
"id": "editor-canvas",
- "layoutAckMedianMs": 2.800000000745058,
- "layoutReturnSwitchMedianMs": 44.400000002235174,
- "layoutSwitchMedianMs": 86.89999999850988,
- "loadMedianMs": 128.5,
- "longestTaskMaxMs": 160,
+ "layoutAckMedianMs": 2.5,
+ "layoutReturnSwitchMedianMs": 46,
+ "layoutSwitchMedianMs": 84.89999999850988,
+ "loadMedianMs": 128.69999999925494,
+ "longestTaskMaxMs": 163,
"owner": "canvas",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:canvas",
@@ -180,15 +180,15 @@
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 98,
- "scriptRawBytes": 4407950,
+ "scriptRawBytes": 4415450,
"scriptRequestCount": 93,
- "totalRawBytes": 4774680
+ "totalRawBytes": 4671426
},
- "routeReadyMedianMs": 1163.800000000745,
+ "routeReadyMedianMs": 1157.2999999970198,
"scriptSourceOwnerSet": "editor-canvas-static",
"stateProfile": "empty"
},
@@ -197,20 +197,20 @@
"cssRawBytes": 0,
"fontRawBytes": 0,
"imageRawBytes": 0,
- "largestAssetRawBytes": 310063,
+ "largestAssetRawBytes": 310062,
"otherRawBytes": 0,
"requestCount": 9,
- "scriptRawBytes": 507630,
+ "scriptRawBytes": 507632,
"scriptRequestCount": 9,
- "totalRawBytes": 507630
+ "totalRawBytes": 507632
},
- "domContentLoadedMedianMs": 129.79999999701977,
+ "domContentLoadedMedianMs": 128.60000000149012,
"id": "editor-canvas",
- "layoutAckMedianMs": 2.300000000745058,
- "layoutReturnSwitchMedianMs": 52.5,
- "layoutSwitchMedianMs": 88,
- "loadMedianMs": 129.89999999850988,
- "longestTaskMaxMs": 199,
+ "layoutAckMedianMs": 2.599999997764826,
+ "layoutReturnSwitchMedianMs": 43.70000000298023,
+ "layoutSwitchMedianMs": 85.80000000074506,
+ "loadMedianMs": 128.69999999925494,
+ "longestTaskMaxMs": 203,
"owner": "canvas",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:canvas",
@@ -218,15 +218,15 @@
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 98,
- "scriptRawBytes": 4407950,
+ "scriptRawBytes": 4415450,
"scriptRequestCount": 93,
- "totalRawBytes": 4774680
+ "totalRawBytes": 4671426
},
- "routeReadyMedianMs": 1132.9000000022352,
+ "routeReadyMedianMs": 1128.199999999255,
"scriptSourceOwnerSet": "editor-canvas-static",
"stateProfile": "representative"
},
@@ -235,20 +235,20 @@
"cssRawBytes": 15413,
"fontRawBytes": 0,
"imageRawBytes": 0,
- "largestAssetRawBytes": 181637,
+ "largestAssetRawBytes": 181629,
"otherRawBytes": 0,
"requestCount": 7,
- "scriptRawBytes": 415221,
+ "scriptRawBytes": 415216,
"scriptRequestCount": 6,
- "totalRawBytes": 430634
+ "totalRawBytes": 430629
},
- "domContentLoadedMedianMs": 126.40000000223517,
+ "domContentLoadedMedianMs": 128.30000000074506,
"id": "editor-workflow",
- "layoutAckMedianMs": 2.5,
- "layoutReturnSwitchMedianMs": 34.100000001490116,
- "layoutSwitchMedianMs": 92.19999999925494,
- "loadMedianMs": 126.5,
- "longestTaskMaxMs": 167,
+ "layoutAckMedianMs": 2.400000002235174,
+ "layoutReturnSwitchMedianMs": 35.399999998509884,
+ "layoutSwitchMedianMs": 97.70000000298023,
+ "loadMedianMs": 128.40000000223517,
+ "longestTaskMaxMs": 160,
"owner": "workflow",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:workflow",
@@ -256,15 +256,15 @@
"resourceBaseline": {
"cssRawBytes": 17572,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 96,
- "scriptRawBytes": 4315541,
+ "scriptRawBytes": 4323034,
"scriptRequestCount": 90,
- "totalRawBytes": 4697684
+ "totalRawBytes": 4594423
},
- "routeReadyMedianMs": 1172.300000000745,
+ "routeReadyMedianMs": 1174.5,
"scriptSourceOwnerSet": "editor-workflow-static",
"stateProfile": "empty"
},
@@ -273,20 +273,20 @@
"cssRawBytes": 15413,
"fontRawBytes": 0,
"imageRawBytes": 0,
- "largestAssetRawBytes": 181637,
+ "largestAssetRawBytes": 181629,
"otherRawBytes": 0,
"requestCount": 7,
- "scriptRawBytes": 415221,
+ "scriptRawBytes": 415216,
"scriptRequestCount": 6,
- "totalRawBytes": 430634
+ "totalRawBytes": 430629
},
- "domContentLoadedMedianMs": 126.80000000074506,
+ "domContentLoadedMedianMs": 132.90000000223517,
"id": "editor-workflow",
- "layoutAckMedianMs": 2.699999999254942,
- "layoutReturnSwitchMedianMs": 43.80000000074506,
- "layoutSwitchMedianMs": 162.80000000074506,
- "loadMedianMs": 126.89999999850988,
- "longestTaskMaxMs": 435,
+ "layoutAckMedianMs": 2.400000002235174,
+ "layoutReturnSwitchMedianMs": 45.5,
+ "layoutSwitchMedianMs": 157.80000000074506,
+ "loadMedianMs": 133,
+ "longestTaskMaxMs": 428,
"owner": "workflow",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:workflow",
@@ -294,15 +294,15 @@
"resourceBaseline": {
"cssRawBytes": 17572,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 96,
- "scriptRawBytes": 4315541,
+ "scriptRawBytes": 4323034,
"scriptRequestCount": 90,
- "totalRawBytes": 4697684
+ "totalRawBytes": 4594423
},
- "routeReadyMedianMs": 1196.7000000029802,
+ "routeReadyMedianMs": 1201.0999999977648,
"scriptSourceOwnerSet": "editor-workflow-static",
"stateProfile": "representative"
},
@@ -318,13 +318,13 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 132.69999999925494,
+ "domContentLoadedMedianMs": 127.19999999925494,
"id": "editor-gallery",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
- "layoutSwitchMedianMs": 35.80000000074506,
- "loadMedianMs": 132.80000000074506,
- "longestTaskMaxMs": 174,
+ "layoutSwitchMedianMs": 36.100000001490116,
+ "loadMedianMs": 127.39999999850988,
+ "longestTaskMaxMs": 169,
"owner": "gallery",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:gallery",
@@ -332,15 +332,15 @@
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 89,
- "scriptRawBytes": 3900320,
+ "scriptRawBytes": 3907818,
"scriptRequestCount": 84,
- "totalRawBytes": 4267050
+ "totalRawBytes": 4163794
},
- "routeReadyMedianMs": 1390.300000000745,
+ "routeReadyMedianMs": 1376.300000000745,
"scriptSourceOwnerSet": "editor-minimal-static",
"stateProfile": "empty"
},
@@ -356,13 +356,13 @@
"scriptRequestCount": 0,
"totalRawBytes": 0
},
- "domContentLoadedMedianMs": 128.80000000074506,
+ "domContentLoadedMedianMs": 127.69999999925494,
"id": "editor-gallery",
"layoutAckMedianMs": 0,
"layoutReturnSwitchMedianMs": 0,
- "layoutSwitchMedianMs": 46.69999999925494,
- "loadMedianMs": 129,
- "longestTaskMaxMs": 159,
+ "layoutSwitchMedianMs": 44.79999999701977,
+ "loadMedianMs": 127.79999999701977,
+ "longestTaskMaxMs": 163,
"owner": "gallery",
"projectSwitchMedianMs": 0,
"readyMark": "invokeai:ready:widget:center:gallery",
@@ -370,15 +370,15 @@
"resourceBaseline": {
"cssRawBytes": 2159,
"fontRawBytes": 23664,
- "imageRawBytes": 156376,
- "largestAssetRawBytes": 749035,
- "otherRawBytes": 184531,
+ "imageRawBytes": 44398,
+ "largestAssetRawBytes": 749225,
+ "otherRawBytes": 185755,
"requestCount": 89,
- "scriptRawBytes": 3900320,
+ "scriptRawBytes": 3907818,
"scriptRequestCount": 84,
- "totalRawBytes": 4267050
+ "totalRawBytes": 4163794
},
- "routeReadyMedianMs": 1363.699999999255,
+ "routeReadyMedianMs": 1351.3999999985099,
"scriptSourceOwnerSet": "editor-minimal-static",
"stateProfile": "representative"
}
@@ -608,6 +608,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/externalStore.ts",
@@ -737,7 +738,9 @@
"source:src/workbench/settings/launchpad.tsx",
"source:src/workbench/settings/settingsDialogStore.ts",
"source:src/workbench/settings/settingsSearchShortcut.ts",
- "source:src/workbench/settings/store.ts"
+ "source:src/workbench/settings/store.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts"
],
"editor-minimal-static": [
"package:@ark-ui/react",
@@ -1252,6 +1255,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/compareAndSwapRollback.ts",
@@ -1703,6 +1707,8 @@
"source:src/workbench/shell/topbar/useLayoutDrift.ts",
"source:src/workbench/shell/topbar/useTopbarShortcut.ts",
"source:src/workbench/shell/useMountedInstanceIds.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts",
"source:src/workbench/useNotify.ts",
"source:src/workbench/useOpenWorkbenchWidget.ts",
"source:src/workbench/widget-frame/FloatingWidgetLayer.tsx",
@@ -2323,6 +2329,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/compareAndSwapRollback.ts",
@@ -2785,6 +2792,8 @@
"source:src/workbench/shell/topbar/useLayoutDrift.ts",
"source:src/workbench/shell/topbar/useTopbarShortcut.ts",
"source:src/workbench/shell/useMountedInstanceIds.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts",
"source:src/workbench/useCanvasProjectMutationDispatch.ts",
"source:src/workbench/useNotify.ts",
"source:src/workbench/useOpenWorkbenchWidget.ts",
@@ -3613,6 +3622,7 @@
"source:src/platform/react/useMountEffect.ts",
"source:src/platform/react/usePreservedScrollOffset.ts",
"source:src/platform/react/useZodForm.ts",
+ "source:src/platform/runtime/appMetadata.ts",
"source:src/platform/search/dateTokens.ts",
"source:src/platform/state/accountLifecycle.ts",
"source:src/platform/state/compareAndSwapRollback.ts",
@@ -4064,6 +4074,8 @@
"source:src/workbench/shell/topbar/useLayoutDrift.ts",
"source:src/workbench/shell/topbar/useTopbarShortcut.ts",
"source:src/workbench/shell/useMountedInstanceIds.ts",
+ "source:src/workbench/shell/useWhatsNew.ts",
+ "source:src/workbench/shell/whatsNewStore.ts",
"source:src/workbench/useNotify.ts",
"source:src/workbench/useOpenWorkbenchWidget.ts",
"source:src/workbench/widget-frame/FloatingWidgetLayer.tsx",
diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json
index 025608e4354..d1a64e162da 100644
--- a/invokeai/frontend/webv2/public/locales/en.json
+++ b/invokeai/frontend/webv2/public/locales/en.json
@@ -4593,5 +4593,42 @@
"estimate": "{{count, number}} selected · {{images}}, {{videos}}, about {{size}}",
"available": "{{images}}, {{videos}} unused · about {{size}} reclaimable"
}
+ },
+ "whatsNew": {
+ "alsoNew": "Also in this release",
+ "highlights": {
+ "canvas": {
+ "description": "A new engine with layer groups, blend modes, pressure-sensitive brushes, text, and PSD export.",
+ "title": "Rebuilt Canvas"
+ },
+ "interface": {
+ "description": "One workbench of widgets you can arrange, pop out into windows, or reach from the command palette.",
+ "title": "Redesigned interface"
+ },
+ "models": {
+ "description": "New video models like Wan-2.2, MiniMax H3, and LTX-2.5, plus larger Anima models.",
+ "title": "Expanded model support"
+ },
+ "projects": {
+ "description": "Autosaving projects keep your canvas, settings, and workflows together, and export as .invk files.",
+ "title": "Projects"
+ }
+ },
+ "items": [
+ "Generation settings presets.",
+ "For-loop workflows.",
+ "Search galleries by content or image similarity.",
+ "An interactive image map that groups your gallery by theme and autotags images.",
+ "Dynamic prompts, wildcards, and prompt templates.",
+ "Smaller, faster models with FP8, NVFP4, and int8 quantization.",
+ "Full color theming system with 6 presets.",
+ "Multi-GPU support.",
+ "Interactive intermediates manager.",
+ "Full tablet support.",
+ "And much more!"
+ ],
+ "readReleaseNotes": "Read Release Notes",
+ "readTheDocs": "Read the Docs",
+ "whatsNewInInvoke": "What's New in Invoke"
}
}
diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs
index c6e0cb1726d..c95441e8ef9 100644
--- a/invokeai/frontend/webv2/scripts/mock-backend.mjs
+++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs
@@ -97,6 +97,8 @@ const STARTER_MODEL = {
type: 'main',
};
+const MOCK_APP_VERSION = 'fixture';
+
const STARTER_MODELS_RESPONSE = {
starter_bundles: {
'sd-1': { models: [STARTER_MODEL], name: 'Stable Diffusion 1.5' },
@@ -113,8 +115,13 @@ const createState = (profile) => {
return {
boards: new Map(fixture.boards.map((board) => [board.board_id, clone(board)])),
- // Dismiss the alpha notice so journeys start on the requested page.
- clientState: new Map([['webv2:workbench-settings', JSON.stringify({ alphaNoticeAcknowledged: true })]]),
+ // Dismiss the alpha notice and What's New notes so journeys start on the requested page.
+ clientState: new Map([
+ [
+ 'webv2:workbench-settings',
+ JSON.stringify({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: MOCK_APP_VERSION }),
+ ],
+ ]),
images: new Map(fixture.images.map((image) => [image.image_name, clone(image)])),
models: new Map(fixture.models.map((model) => [model.key, clone(model)])),
mutationClock: 0,
@@ -867,7 +874,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
}
if (method === 'GET' && path === '/api/v1/app/version') {
- return json(200, { version: 'fixture' });
+ return json(200, { version: MOCK_APP_VERSION });
}
if (method === 'GET' && path === '/api/v1/app/generation_device_options') {
diff --git a/invokeai/frontend/webv2/scripts/run-accessibility-journeys.mjs b/invokeai/frontend/webv2/scripts/run-accessibility-journeys.mjs
index 6b6852a452b..1a02e31ba2b 100644
--- a/invokeai/frontend/webv2/scripts/run-accessibility-journeys.mjs
+++ b/invokeai/frontend/webv2/scripts/run-accessibility-journeys.mjs
@@ -534,16 +534,18 @@ const runTopbarMenuJourney = async (browser) => {
const appMenu = page.getByRole('menu', { exact: true, name: 'Open menu' });
const commandPaletteItem = page.getByRole('menuitem', { name: /^Command palette/ });
const settingsItem = appMenu.getByRole('menuitem', { name: /^Settings(?: \(.+\))?$/ });
+ const whatsNewItem = page.getByRole('menuitem', { exact: true, name: "What's New in Invoke" });
const documentationItem = page.getByRole('menuitem', { exact: true, name: 'Documentation' });
const discordItem = page.getByRole('menuitem', { exact: true, name: 'Discord' });
await commandPaletteItem.waitFor();
await settingsItem.waitFor();
+ await whatsNewItem.waitFor();
await documentationItem.waitFor();
await discordItem.waitFor();
const footerMetrics = await appMenu.evaluate((menu) => {
const menuBounds = menu.getBoundingClientRect();
- const footerItems = [...menu.querySelectorAll('[role="menuitem"]')].slice(-4);
+ const footerItems = [...menu.querySelectorAll('[role="menuitem"]')].slice(-5);
return {
itemsFit: footerItems.every((item) => {
@@ -567,7 +569,7 @@ const runTopbarMenuJourney = async (browser) => {
await appMenu.press('End');
assert.equal(await appMenu.getAttribute('aria-activedescendant'), await discordItem.getAttribute('id'));
- for (const item of [documentationItem, settingsItem, commandPaletteItem]) {
+ for (const item of [documentationItem, whatsNewItem, settingsItem, commandPaletteItem]) {
await appMenu.press('ArrowUp');
assert.equal(await appMenu.getAttribute('aria-activedescendant'), await item.getAttribute('id'));
}
diff --git a/invokeai/frontend/webv2/src/app/App.tsx b/invokeai/frontend/webv2/src/app/App.tsx
index 3d3016dd630..9513e3daefb 100644
--- a/invokeai/frontend/webv2/src/app/App.tsx
+++ b/invokeai/frontend/webv2/src/app/App.tsx
@@ -4,6 +4,7 @@ import { AppToaster } from '@platform/ui/toaster';
import { RouterProvider } from '@tanstack/react-router';
import { system } from '@theme/system';
import { useWorkbenchSettingsSelector } from '@workbench/settings/store';
+import { useWhatsNew } from '@workbench/shell/useWhatsNew';
import { lazy, Suspense } from 'react';
import { FeatureHintsAdapterProvider } from './FeatureHintsProvider';
@@ -28,6 +29,21 @@ const AlphaNoticeGate = () => {
) : null;
};
+const WhatsNewDialog = lazy(() =>
+ import('@workbench/shell/WhatsNewDialog').then((module) => ({ default: module.WhatsNewDialog }))
+);
+
+/** Load the What's New notes when a new version has not been seen yet, or when the app menu asks for them. */
+const WhatsNewGate = () => {
+ const { isOpen } = useWhatsNew();
+
+ return isOpen ? (
+
+
+
+ ) : null;
+};
+
export const App = () => (
@@ -35,6 +51,7 @@ export const App = () => (
+
diff --git a/invokeai/frontend/webv2/src/app/router.tsx b/invokeai/frontend/webv2/src/app/router.tsx
index e8cd8215540..2d2b4acd503 100644
--- a/invokeai/frontend/webv2/src/app/router.tsx
+++ b/invokeai/frontend/webv2/src/app/router.tsx
@@ -30,6 +30,7 @@ import { Launchpad } from '@workbench/launchpad/Launchpad';
import { ProjectFileOptionsProvider } from '@workbench/projects/components/ProjectFileOptionsProvider';
import { peekOpenProjectIds, type WorkbenchSearch } from '@workbench/projects/session';
import { loadWorkbenchSettings } from '@workbench/settings/store';
+import { loadAppVersion } from '@workbench/shell/whatsNewStore';
import { Fragment } from 'react';
import { SocketHubRuntime } from './SocketHubRuntime';
@@ -100,6 +101,8 @@ const authenticatedRoute = createRoute({
}
}
+ // What's New compares this with the account's last-seen version; it never holds up the route.
+ void loadAppVersion();
await loadWorkbenchSettings();
},
component: AuthenticatedLayout,
diff --git a/invokeai/frontend/webv2/src/assets/V7Logo.webp b/invokeai/frontend/webv2/src/assets/V7Logo.webp
new file mode 100644
index 00000000000..e9353f3e5df
Binary files /dev/null and b/invokeai/frontend/webv2/src/assets/V7Logo.webp differ
diff --git a/invokeai/frontend/webv2/src/platform/i18n/translationKeys.test.ts b/invokeai/frontend/webv2/src/platform/i18n/translationKeys.test.ts
index f649b8dc3e6..186a015985a 100644
--- a/invokeai/frontend/webv2/src/platform/i18n/translationKeys.test.ts
+++ b/invokeai/frontend/webv2/src/platform/i18n/translationKeys.test.ts
@@ -29,6 +29,11 @@ const flattenCatalog = (value: unknown, prefix = '', keys = new Set()):
const path = prefix ? `${prefix}.${segment}` : segment;
if (typeof child === 'object' && child !== null) {
+ // An array is read whole with `t('x', { returnObjects: true })`, so its own path is a key too.
+ if (Array.isArray(child)) {
+ keys.add(path);
+ }
+
flattenCatalog(child, path, keys);
continue;
}
diff --git a/invokeai/frontend/webv2/src/platform/runtime/appMetadata.ts b/invokeai/frontend/webv2/src/platform/runtime/appMetadata.ts
index 717f5cd11e2..125211580c0 100644
--- a/invokeai/frontend/webv2/src/platform/runtime/appMetadata.ts
+++ b/invokeai/frontend/webv2/src/platform/runtime/appMetadata.ts
@@ -1,2 +1,10 @@
/** Build-time application metadata with no domain dependencies. */
-export const APP_VERSION = '7.0';
+export const APP_VERSION = import.meta.env.APP_VERSION;
+
+export const DOCS_URL = 'https://invoke-ai.github.io/InvokeAI-7/';
+
+/** Release notes for a server version string (`7.0.0`, `7.0.0-rc1`), or the release list when it is unknown. */
+export const getReleaseNotesUrl = (version: string | null): string =>
+ version
+ ? `https://github.com/invoke-ai/InvokeAI/releases/tag/v${version}`
+ : 'https://github.com/invoke-ai/InvokeAI/releases';
diff --git a/invokeai/frontend/webv2/src/platform/ui/VendoredIcon.tsx b/invokeai/frontend/webv2/src/platform/ui/VendoredIcon.tsx
index bfd7c10303f..7f5d5dfb4c3 100644
--- a/invokeai/frontend/webv2/src/platform/ui/VendoredIcon.tsx
+++ b/invokeai/frontend/webv2/src/platform/ui/VendoredIcon.tsx
@@ -102,3 +102,13 @@ export const GradientIcon = createVendoredIcon({
title: 'Gradient',
viewBox: '0 0 32 32',
});
+
+export const LightbulbFilamentIcon = createVendoredIcon({
+ origin: 'Phosphor Icons, bold weight (MIT)',
+ paths: [
+ 'M180,232a12,12,0,0,1-12,12H88a12,12,0,0,1,0-24h80A12,12,0,0,1,180,232Zm40-128a92.47,92.47,0,0,1-37,73.73,7.81,7.81,0,0,0-3,6.27,20,20,0,0,1-20,20H96a20,20,0,0,1-20-20v-.23a7.76,7.76,0,0,0-3.25-6.2,91.36,91.36,0,0,1-36.75-73C35.73,54.69,76,13.2,125.79,12A92,92,0,0,1,220,104Zm-24,0a68,68,0,0,0-69.65-68C89.56,36.89,59.8,67.56,60,104.39a67.52,67.52,0,0,0,27.18,54h0A32.14,32.14,0,0,1,99.77,180H116V149L87.51,120.49a12,12,0,0,1,17-17L128,127l23.51-23.51a12,12,0,0,1,17,17L140,149v31h16.25a31.89,31.89,0,0,1,12.41-21.49A67.45,67.45,0,0,0,196,104Z',
+ ],
+ slug: 'lightbulb-filament',
+ title: 'Lightbulb',
+ viewBox: '0 0 256 256',
+});
diff --git a/invokeai/frontend/webv2/src/vite-env.d.ts b/invokeai/frontend/webv2/src/vite-env.d.ts
index feb9fa84f7e..3a070d8e825 100644
--- a/invokeai/frontend/webv2/src/vite-env.d.ts
+++ b/invokeai/frontend/webv2/src/vite-env.d.ts
@@ -2,3 +2,8 @@
/** Rewrites canvas pixel goldens instead of comparing them; set by `test:browser:update-goldens`. */
declare const __CANVAS_GOLDEN_UPDATE__: boolean;
+
+interface ImportMetaEnv {
+ /** The release version from `invokeai/version/invokeai_version.py`, injected by `vite.config.mts`. */
+ readonly APP_VERSION: string;
+}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx
index f4880879939..7f01c2b21d4 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx
@@ -1,7 +1,7 @@
import type { ElementType } from 'react';
import { chakra, HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react';
-import { APP_VERSION } from '@platform/runtime/appMetadata';
+import { APP_VERSION, DOCS_URL } from '@platform/runtime/appMetadata';
import { Button } from '@platform/ui/Button';
import { MenuContent } from '@platform/ui/Menu';
import { DiscordIcon, GithubIcon } from '@platform/ui/VendoredIcon';
@@ -22,7 +22,7 @@ interface HelpLink {
const GUIDES: HelpLink[] = [
{
- href: 'https://invoke-ai.github.io/InvokeAI/',
+ href: DOCS_URL,
icon: BookOpenTextIcon,
labelKey: 'launchpad.help.documentation',
value: 'documentation',
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/LaunchpadNav.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/LaunchpadNav.tsx
index 9d1a5e49557..98a95bbb0fc 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/LaunchpadNav.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/LaunchpadNav.tsx
@@ -3,7 +3,9 @@ import type { LucideIcon } from 'lucide-react';
import { Icon, Stack, Text } from '@chakra-ui/react';
import { Button } from '@platform/ui/Button';
+import { LightbulbFilamentIcon } from '@platform/ui/VendoredIcon';
import { Link } from '@tanstack/react-router';
+import { openWhatsNew } from '@workbench/shell/whatsNewStore';
import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -19,7 +21,7 @@ const FOOTER_MARGIN_TOP = { md: 'auto' } as const;
/**
* Sections are routes, so the rail is route links in visual order: Workspace, the open projects, Manage, then the
- * footer (Preferences). Tab and reading order follow what the rail shows.
+ * footer (Preferences, then the What's New and Help actions). Tab and reading order follow what the rail shows.
*/
export type LaunchpadNavGroupId = 'workspace' | 'manage' | 'footer';
@@ -68,6 +70,22 @@ const NavLink = ({ isActive, item }: { isActive: boolean; item: LaunchpadNavItem
);
+const WHATS_NEW_JUSTIFY = { justifyContent: 'start' } as const;
+
+/** An action, not a route: styled like the Help trigger beneath it rather than as a rail link. */
+const WhatsNewButton = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
+
const NavGroup = ({
activeId,
group,
@@ -131,6 +149,7 @@ export const LaunchpadNav = ({ activeId, items }: { activeId: string; items: Lau
{footerItems.map((item) => (
))}
+
diff --git a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts
index 6bb72cb54a7..e2b0cafe7b8 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts
+++ b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts
@@ -45,6 +45,8 @@ export interface StoredGeneratePreset {
export interface WorkbenchPreferences {
/** The alpha-build notice was dismissed; shown once per account until then. */
alphaNoticeAcknowledged: boolean;
+ /** Server version whose What's New notes were last dismissed; a different version shows them once. */
+ whatsNewSeenVersion: string | null;
themeId: WorkbenchThemeId;
reduceMotion: boolean;
showFocusRegionHighlight: boolean;
diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts
index 6c1fb92e258..eeed16f05cc 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/store.ts
+++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts
@@ -67,6 +67,7 @@ export const DEFAULT_PREFERENCES: WorkbenchPreferences = {
showPromptSyntaxHighlighting: true,
showFocusRegionHighlight: true,
themeId: DEFAULT_THEME_ID,
+ whatsNewSeenVersion: null,
workflowEdgeStyle: 'curved',
workflowEdgesBehindNodes: false,
workflowGroupNodesByCategory: true,
@@ -348,6 +349,10 @@ export const normalizeWorkbenchPreferences = (preferences?: WorkbenchPreferences
? preferences.showPromptSyntaxHighlighting
: DEFAULT_PREFERENCES.showPromptSyntaxHighlighting,
themeId: resolveWorkbenchThemeId(preferences?.themeId) ?? DEFAULT_PREFERENCES.themeId,
+ whatsNewSeenVersion:
+ typeof preferences?.whatsNewSeenVersion === 'string'
+ ? preferences.whatsNewSeenVersion
+ : DEFAULT_PREFERENCES.whatsNewSeenVersion,
workflowEdgeStyle:
preferences?.workflowEdgeStyle === 'square' || preferences?.workflowEdgeStyle === 'straight'
? 'square'
diff --git a/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.browser.test.tsx
new file mode 100644
index 00000000000..55794545c24
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.browser.test.tsx
@@ -0,0 +1,179 @@
+import { ChakraProvider } from '@chakra-ui/react';
+import { accountLifecycle } from '@platform/state/accountLifecycle';
+import { system } from '@theme/system';
+import { isHotkeyModalLayerActive } from '@workbench/hotkeys/modalLayer';
+import { createInstance } from 'i18next';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nextProvider, initReactI18next } from 'react-i18next';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { WhatsNewDialog } from './WhatsNewDialog';
+import * as store from './whatsNewStore';
+
+type Snapshot = {
+ preferences: { alphaNoticeAcknowledged: boolean; whatsNewSeenVersion: string | null };
+ status: 'idle' | 'ready';
+};
+
+const settings = vi.hoisted(() => ({
+ snapshot: {
+ preferences: { alphaNoticeAcknowledged: true, whatsNewSeenVersion: null },
+ status: 'idle',
+ } as Snapshot,
+ listeners: new Set<() => void>(),
+ patchWorkbenchPreferences: vi.fn(),
+}));
+
+vi.mock('@workbench/settings/store', async () => {
+ const { useSyncExternalStore } = await import('react');
+
+ return {
+ patchWorkbenchPreferences: settings.patchWorkbenchPreferences,
+ useWorkbenchSettingsSelector: (selector: (snapshot: Snapshot) => unknown) =>
+ useSyncExternalStore(
+ (listener) => {
+ settings.listeners.add(listener);
+ return () => settings.listeners.delete(listener);
+ },
+ () => selector(settings.snapshot)
+ ),
+ };
+});
+
+vi.mock('@platform/transport/http', () => ({
+ apiFetchJson: vi.fn((path: string) =>
+ path === '/api/v1/app/version'
+ ? Promise.resolve({ version: '7.0.0-rc1' })
+ : Promise.reject(new Error(`unexpected request ${path}`))
+ ),
+}));
+
+const catalog = (await fetch('/locales/en.json').then((response) => response.json())) as {
+ whatsNew: { highlights: Record; items: string[] };
+};
+
+const i18n = createInstance();
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: { en: { translation: catalog } },
+ interpolation: { escapeValue: false },
+});
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const setSnapshot = (next: Snapshot) =>
+ act(() => {
+ settings.snapshot = next;
+ settings.listeners.forEach((listener) => listener());
+ });
+
+const ready = (preferences: Snapshot['preferences']): Snapshot => ({ preferences, status: 'ready' });
+
+const title = () => i18n.t('whatsNew.whatsNewInInvoke');
+
+describe('WhatsNewDialog', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(async () => {
+ settings.patchWorkbenchPreferences.mockReset();
+ settings.snapshot = { preferences: { alphaNoticeAcknowledged: true, whatsNewSeenVersion: null }, status: 'idle' };
+ // An account switch drops the previous test's request and dismissal; the version is not account data.
+ accountLifecycle.invalidate();
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ await act(() =>
+ root.render(
+
+
+
+
+
+ )
+ );
+ });
+
+ afterEach(async () => {
+ await act(() => root.unmount());
+ host.remove();
+ });
+
+ it('shows once for an unseen version after preferences load, then records the version on dismissal', async () => {
+ await act(() => store.loadAppVersion());
+ // Not before the account's preferences are known: an account that saw these notes must not see them flash.
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
+
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: '6.14.2' }));
+ await expect.element(page.getByRole('dialog', { name: title() })).toBeVisible();
+ await expect.element(page.getByText('v7.0.0-rc1')).toBeVisible();
+ // An automatic open has no click behind it, so focus on a link would draw its keyboard ring; start on the panel.
+ await expect.element(page.getByRole('dialog', { name: title() })).toHaveFocus();
+ expect(isHotkeyModalLayerActive()).toBe(true);
+
+ // Every catalogued highlight renders with its title, and the smaller notes list beneath them.
+ for (const { description, title } of Object.values(catalog.whatsNew.highlights)) {
+ await expect.element(page.getByText(title, { exact: true })).toBeVisible();
+ await expect.element(page.getByText(description, { exact: true })).toBeVisible();
+ }
+ expect(
+ page
+ .getByRole('listitem')
+ .elements()
+ .map((item) => item.textContent)
+ ).toEqual(catalog.whatsNew.items);
+
+ expect(page.getByRole('link', { name: i18n.t('whatsNew.readReleaseNotes') }).element()).toHaveAttribute(
+ 'href',
+ 'https://github.com/invoke-ai/InvokeAI/releases/tag/v7.0.0-rc1'
+ );
+ expect(page.getByRole('link', { name: i18n.t('whatsNew.readTheDocs') }).element()).toHaveAttribute(
+ 'href',
+ 'https://invoke-ai.github.io/InvokeAI-7/'
+ );
+
+ await page.getByRole('button', { name: /close/i }).click();
+ expect(settings.patchWorkbenchPreferences).toHaveBeenCalledExactlyOnceWith({ whatsNewSeenVersion: '7.0.0-rc1' });
+
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: '7.0.0-rc1' }));
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ expect(isHotkeyModalLayerActive()).toBe(false);
+ });
+
+ it('waits for the alpha notice to be dismissed first', async () => {
+ await act(() => store.loadAppVersion());
+ await setSnapshot(ready({ alphaNoticeAcknowledged: false, whatsNewSeenVersion: null }));
+ await expect.element(page.getByText('v7.0.0-rc1')).not.toBeInTheDocument();
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
+
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: null }));
+ await expect.element(page.getByRole('dialog', { name: title() })).toBeVisible();
+ });
+
+ it('stays closed for a seen version until the app menu opens it, and reopening writes nothing', async () => {
+ await act(() => store.loadAppVersion());
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: '7.0.0-rc1' }));
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
+
+ await act(() => store.openWhatsNew());
+ await expect.element(page.getByRole('dialog', { name: title() })).toBeVisible();
+
+ await page.getByRole('button', { name: /close/i }).click();
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ expect(settings.patchWorkbenchPreferences).not.toHaveBeenCalled();
+ });
+
+ it('shows an unseen version again for the next account after one dismissed it', async () => {
+ await act(() => store.loadAppVersion());
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: null }));
+ await page.getByRole('button', { name: /close/i }).click();
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+
+ await act(() => {
+ accountLifecycle.invalidate();
+ });
+ await expect.element(page.getByRole('dialog', { name: title() })).toBeVisible();
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.lateVersion.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.lateVersion.browser.test.tsx
new file mode 100644
index 00000000000..2e258373630
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.lateVersion.browser.test.tsx
@@ -0,0 +1,120 @@
+import { ChakraProvider } from '@chakra-ui/react';
+import { system } from '@theme/system';
+import { createInstance } from 'i18next';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nextProvider, initReactI18next } from 'react-i18next';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { WhatsNewDialog } from './WhatsNewDialog';
+import * as store from './whatsNewStore';
+
+type Snapshot = {
+ preferences: { alphaNoticeAcknowledged: boolean; whatsNewSeenVersion: string | null };
+ status: 'idle' | 'ready';
+};
+
+const settings = vi.hoisted(() => ({
+ snapshot: {
+ preferences: { alphaNoticeAcknowledged: true, whatsNewSeenVersion: null },
+ status: 'idle',
+ } as Snapshot,
+ listeners: new Set<() => void>(),
+ patchWorkbenchPreferences: vi.fn(),
+}));
+
+vi.mock('@workbench/settings/store', async () => {
+ const { useSyncExternalStore } = await import('react');
+
+ return {
+ patchWorkbenchPreferences: settings.patchWorkbenchPreferences,
+ useWorkbenchSettingsSelector: (selector: (snapshot: Snapshot) => unknown) =>
+ useSyncExternalStore(
+ (listener) => {
+ settings.listeners.add(listener);
+ return () => settings.listeners.delete(listener);
+ },
+ () => selector(settings.snapshot)
+ ),
+ };
+});
+
+const http = vi.hoisted(() => ({ resolveVersion: (_value: { version: string }) => {} }));
+
+// The version read stays pending until the test resolves it.
+vi.mock('@platform/transport/http', () => ({
+ apiFetchJson: vi.fn((path: string) =>
+ path === '/api/v1/app/version'
+ ? new Promise((resolve) => {
+ http.resolveVersion = resolve;
+ })
+ : Promise.reject(new Error(`unexpected request ${path}`))
+ ),
+}));
+
+const catalog = (await fetch('/locales/en.json').then((response) => response.json())) as Record;
+
+const i18n = createInstance();
+await i18n.use(initReactI18next).init({
+ lng: 'en',
+ resources: { en: { translation: catalog } },
+ interpolation: { escapeValue: false },
+});
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const setSnapshot = (next: Snapshot) =>
+ act(() => {
+ settings.snapshot = next;
+ settings.listeners.forEach((listener) => listener());
+ });
+
+const ready = (preferences: Snapshot['preferences']): Snapshot => ({ preferences, status: 'ready' });
+
+const title = () => i18n.t('whatsNew.whatsNewInInvoke');
+
+/** Its own file: the store keeps the version for the page's lifetime, and this case needs it still unknown. */
+describe('WhatsNewDialog with a slow version read', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(async () => {
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ await act(() =>
+ root.render(
+
+
+
+
+
+ )
+ );
+ });
+
+ afterEach(async () => {
+ await act(() => root.unmount());
+ host.remove();
+ });
+
+ it('does not reopen notes closed by hand when the version arrives late', async () => {
+ await setSnapshot(ready({ alphaNoticeAcknowledged: true, whatsNewSeenVersion: '6.14.2' }));
+
+ // The boot read is still pending when the menu opens the notes.
+ void store.loadAppVersion();
+ await act(() => store.openWhatsNew());
+ await expect.element(page.getByRole('dialog', { name: title() })).toBeVisible();
+ await page.getByRole('button', { name: /close/i }).click();
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+
+ await act(async () => {
+ http.resolveVersion({ version: '7.0.0-rc1' });
+ await store.loadAppVersion();
+ });
+ expect(store.getWhatsNewSnapshot().version).toBe('7.0.0-rc1');
+ await expect.element(page.getByRole('dialog')).not.toBeInTheDocument();
+ expect(settings.patchWorkbenchPreferences).not.toHaveBeenCalled();
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.tsx b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.tsx
new file mode 100644
index 00000000000..522d741a3a2
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/shell/WhatsNewDialog.tsx
@@ -0,0 +1,176 @@
+import type { LucideIcon } from 'lucide-react';
+
+import v7LogoUrl from '@assets/V7Logo.webp';
+import { Badge, Box, Dialog, Flex, Grid, Image, Link, List, Portal, Stack, Text, VStack } from '@chakra-ui/react';
+import { useMountEffect } from '@platform/react/useMountEffect';
+import { DOCS_URL, getReleaseNotesUrl } from '@platform/runtime/appMetadata';
+import { Button, CloseButton } from '@platform/ui';
+import { registerHotkeyModalLayer } from '@workbench/hotkeys/modalLayer';
+import { patchWorkbenchPreferences } from '@workbench/settings/store';
+import { BookOpenIcon, BoxesIcon, BrushIcon, FolderIcon, PanelsTopLeftIcon, ScrollTextIcon } from 'lucide-react';
+import { useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { useWhatsNew } from './useWhatsNew';
+import { dismissWhatsNew } from './whatsNewStore';
+
+/** Headline features get an icon and a title; the `whatsNew.items` catalog lists the smaller notes beneath them. */
+const HIGHLIGHTS: readonly { descriptionKey: string; icon: LucideIcon; titleKey: string }[] = [
+ {
+ descriptionKey: 'whatsNew.highlights.interface.description',
+ icon: PanelsTopLeftIcon,
+ titleKey: 'whatsNew.highlights.interface.title',
+ },
+ {
+ descriptionKey: 'whatsNew.highlights.projects.description',
+ icon: FolderIcon,
+ titleKey: 'whatsNew.highlights.projects.title',
+ },
+ {
+ descriptionKey: 'whatsNew.highlights.canvas.description',
+ icon: BrushIcon,
+ titleKey: 'whatsNew.highlights.canvas.title',
+ },
+ {
+ descriptionKey: 'whatsNew.highlights.models.description',
+ icon: BoxesIcon,
+ titleKey: 'whatsNew.highlights.models.title',
+ },
+];
+
+const readItems = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
+
+/** Mounted only while the notes are open: workbench hotkeys stay quiet under them, as under the other dialogs. */
+const WhatsNewModalLayer = () => {
+ useMountEffect(() => registerHotkeyModalLayer('whats-new'));
+
+ return null;
+};
+
+export const WhatsNewDialog = () => {
+ const { t } = useTranslation();
+ const { isOpen, isUnseen, version } = useWhatsNew();
+ const items = readItems(t('whatsNew.items', { returnObjects: true }));
+ const contentRef = useRef(null);
+ // Start on the panel, not the first link: focus that opens without a click (the automatic showing) counts as
+ // keyboard focus and would ring the link. Tab still reaches the links, and Escape still closes.
+ const getInitialFocusEl = useCallback(() => contentRef.current, []);
+
+ const handleOpenChange = useCallback(
+ (event: { open: boolean }) => {
+ if (event.open) {
+ return;
+ }
+
+ dismissWhatsNew();
+
+ // Unseen implies loaded preferences; patching before they load would persist defaults over saved choices.
+ if (isUnseen) {
+ void patchWorkbenchPreferences({ whatsNewSeenVersion: version });
+ }
+ },
+ [isUnseen, version]
+ );
+
+ return (
+
+ {isOpen ? : null}
+
+
+
+
+
+
+
+
+ {t('whatsNew.whatsNewInInvoke')}
+ {version ? (
+
+ v{version}
+
+ ) : null}
+
+
+
+
+
+
+ {HIGHLIGHTS.map(({ descriptionKey, icon: HighlightIcon, titleKey }) => (
+
+
+
+
+
+
+ {t(titleKey)}
+
+
+ {t(descriptionKey)}
+
+
+
+ ))}
+
+ {items.length > 0 ? (
+
+
+ {t('whatsNew.alsoNew')}
+
+
+ {items.map((item, index) => (
+ // The catalog order is fixed for a build, so the index is a stable key.
+ {item}
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/AppMenu.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/AppMenu.tsx
index 67f8841bdb2..2628db703bf 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/topbar/AppMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/AppMenu.tsx
@@ -1,16 +1,17 @@
import { Badge, Box, chakra, HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react';
import { AccountMenuSection, useCapabilities, useHasAccountSection } from '@features/identity';
import { getQueueSummary } from '@features/queue/contracts';
-import { APP_VERSION } from '@platform/runtime/appMetadata';
+import { APP_VERSION, DOCS_URL } from '@platform/runtime/appMetadata';
import { IconButton } from '@platform/ui/Button';
import { InvokeMark } from '@platform/ui/InvokeMark';
import { MenuContent } from '@platform/ui/Menu';
import { Tooltip } from '@platform/ui/Tooltip';
-import { DiscordIcon } from '@platform/ui/VendoredIcon';
+import { DiscordIcon, LightbulbFilamentIcon } from '@platform/ui/VendoredIcon';
import { useNavigate } from '@tanstack/react-router';
import { OPEN_COMMAND_PALETTE_HOTKEY } from '@workbench/hotkeys/catalog';
import { openCommandPalette } from '@workbench/palette/paletteStore';
import { openWorkbenchSettings } from '@workbench/settings/settingsDialogStore';
+import { openWhatsNew } from '@workbench/shell/whatsNewStore';
import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget';
import { useActiveProjectId, useActiveProjectSelector } from '@workbench/WorkbenchContext';
import {
@@ -19,11 +20,11 @@ import {
BoxIcon,
ChevronDownIcon,
FolderIcon,
+ HouseIcon,
ListOrderedIcon,
SearchIcon,
SettingsIcon,
TypeIcon,
- type LucideIcon,
} from 'lucide-react';
import { useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
@@ -31,7 +32,6 @@ import { useTranslation } from 'react-i18next';
import { useTopbarShortcut } from './useTopbarShortcut';
const MENU_POSITIONING = { placement: 'bottom-start' } as const;
-const DOCS_URL = 'https://invoke-ai.github.io/InvokeAI/';
const DISCORD_URL = 'https://discord.gg/ZmtBAhwWhy';
export const AppMenu = () => {
@@ -43,6 +43,9 @@ export const AppMenu = () => {
const openWorkbenchWidget = useOpenWorkbenchWidget();
const queuedCount = useActiveProjectSelector((project) => getQueueSummary(project.queue.items).total);
+ const openHome = useCallback(() => {
+ void navigate({ to: '/' });
+ }, [navigate]);
const openProjects = useCallback(() => {
void navigate({ to: '/projects' });
}, [navigate]);
@@ -77,6 +80,11 @@ export const AppMenu = () => {
+
+
+ {t('launchpad.sections.home')}
+
+
{t('topbar.appMenu.manage')}
@@ -121,6 +129,12 @@ export const AppMenu = () => {
+
void;
value: string;
diff --git a/invokeai/frontend/webv2/src/workbench/shell/useWhatsNew.ts b/invokeai/frontend/webv2/src/workbench/shell/useWhatsNew.ts
new file mode 100644
index 00000000000..84c71c4b8f6
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/shell/useWhatsNew.ts
@@ -0,0 +1,29 @@
+import { useWorkbenchSettingsSelector } from '@workbench/settings/store';
+
+import { useWhatsNewSnapshot } from './whatsNewStore';
+
+export interface WhatsNewState {
+ isOpen: boolean;
+ /** Whether this showing is the automatic one for a version the account has not dismissed yet. */
+ isUnseen: boolean;
+ /** The server version, or null while unknown. */
+ version: string | null;
+}
+
+/**
+ * Show the notes once per server version for each account, after its preferences are known and the alpha
+ * notice is out of the way, and whenever the app menu asks for them.
+ */
+export const useWhatsNew = (): WhatsNewState => {
+ const { isDismissed, isRequested, version } = useWhatsNewSnapshot();
+ const isUnseen = useWorkbenchSettingsSelector(
+ (snapshot) =>
+ !isDismissed &&
+ version !== null &&
+ snapshot.status === 'ready' &&
+ snapshot.preferences.alphaNoticeAcknowledged &&
+ snapshot.preferences.whatsNewSeenVersion !== version
+ );
+
+ return { isOpen: isRequested || isUnseen, isUnseen, version };
+};
diff --git a/invokeai/frontend/webv2/src/workbench/shell/whatsNewStore.ts b/invokeai/frontend/webv2/src/workbench/shell/whatsNewStore.ts
new file mode 100644
index 00000000000..180ecadc89c
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/shell/whatsNewStore.ts
@@ -0,0 +1,62 @@
+import { createLogger } from '@platform/logging/logger';
+import { registerAccountOwnedResource } from '@platform/state/accountLifecycle';
+import { createExternalStore } from '@platform/state/externalStore';
+import { apiFetchJson } from '@platform/transport/http';
+
+const logger = createLogger({ area: 'whats-new', namespace: 'app' });
+
+export interface WhatsNewSnapshot {
+ /**
+ * Closed during this page load. A version that arrives after a manual close must not reopen the notes the user
+ * just dismissed; the next load still shows an unseen version.
+ */
+ isDismissed: boolean;
+ /** Opened from the app menu; the automatic first-run showing is derived from preferences instead. */
+ isRequested: boolean;
+ /**
+ * The server version, or null while unknown. Held here rather than in the query cache: the gate lives above
+ * the router, and account transitions clear that cache without remounting it.
+ */
+ version: string | null;
+}
+
+const store = createExternalStore({ isDismissed: false, isRequested: false, version: null });
+
+let versionLoad: Promise | null = null;
+
+// The server version is not account data; only the request to show the notes follows the account.
+registerAccountOwnedResource({
+ clear: () => store.patchSnapshot({ isDismissed: false, isRequested: false }),
+ name: 'whats-new',
+});
+
+/** The version only changes across a server restart, so one successful read per page load is enough. */
+export const loadAppVersion = (): Promise => {
+ if (store.getSnapshot().version !== null) {
+ return Promise.resolve();
+ }
+
+ versionLoad ??= apiFetchJson<{ version: string }>('/api/v1/app/version')
+ .then((response) => store.patchSnapshot({ version: response.version }))
+ .catch((error: unknown) => {
+ // The notes stay closed until a later load succeeds; nothing else depends on them.
+ logger.warn({ error, message: 'Failed to load the app version', name: 'app.whats-new.version-failed' });
+ })
+ .finally(() => {
+ versionLoad = null;
+ });
+
+ return versionLoad;
+};
+
+export const openWhatsNew = (): void => {
+ store.patchSnapshot({ isRequested: true });
+ // A failed boot read leaves the version out of the header and links; try again while the notes are open.
+ void loadAppVersion();
+};
+
+export const dismissWhatsNew = (): void => store.patchSnapshot({ isDismissed: true, isRequested: false });
+
+export const getWhatsNewSnapshot = (): WhatsNewSnapshot => store.getSnapshot();
+
+export const useWhatsNewSnapshot = (): WhatsNewSnapshot => store.useSnapshot();
diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts
index e92fb1d44fa..9d55e5489b0 100644
--- a/invokeai/frontend/webv2/vite.config.mts
+++ b/invokeai/frontend/webv2/vite.config.mts
@@ -1,5 +1,6 @@
import babel from '@rolldown/plugin-babel';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
+import { readFileSync } from 'node:fs';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
@@ -17,6 +18,19 @@ const ALLOWED_HOSTS = process.env.INVOKEAI_DEV_HOSTS?.split(',')
.filter(Boolean);
const PROJECT_ROOT = fileURLToPath(new URL('.', import.meta.url));
+// The Python package owns the release version, and the release workflow checks it against the tag. Fail the build
+// rather than ship a guessed version if the file moves or its format changes.
+const VERSION_FILE = fileURLToPath(new URL('../../version/invokeai_version.py', import.meta.url));
+const readAppVersion = (): string => {
+ const version = /^__version__\s*=\s*["']([^"']+)["']/m.exec(readFileSync(VERSION_FILE, 'utf8'))?.[1];
+
+ if (!version) {
+ throw new Error(`No __version__ assignment found in ${VERSION_FILE}.`);
+ }
+
+ return version;
+};
+
// Group eager dependencies shared by both routes to avoid extra chunk requests.
const ROUTE_SHARED_MODULES = [
'/features/fonts/data/keys.ts',
@@ -40,6 +54,10 @@ const ROUTE_SHARED_MODULES = [
'/platform/ui/settings/contracts.ts',
'/platform/core/concurrency.ts',
'/platform/query/client.ts',
+ // The What's New gate mounts at boot; the app menu and the lazy dialog share these with it.
+ '/platform/runtime/appMetadata.ts',
+ '/workbench/shell/useWhatsNew.ts',
+ '/workbench/shell/whatsNewStore.ts',
'/platform/time/serverTimestamp.ts',
'/platform/transport/connectionStore.ts',
'/platform/transport/socketHub.ts',
@@ -314,6 +332,8 @@ const getLegacyChunkName = (id: string): string | null => {
export default defineConfig({
define: {
+ // An env define, not a global: Vitest browser mode re-encodes string globals, wrapping them in quotes.
+ 'import.meta.env.APP_VERSION': JSON.stringify(readAppVersion()),
// Use a boolean: Vitest browser mode treats string defines as truthy string literals.
__CANVAS_GOLDEN_UPDATE__: false,
},