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, },