diff --git a/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx b/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx
index 5b5e79b7021..acd63f0b516 100644
--- a/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx
+++ b/docs/src/content/docs/Users Guide/08.Workflows/editor-reference.mdx
@@ -57,7 +57,7 @@ On **Yours**, **Sort by** orders the list by **Last updated**, **Last opened**,
Opening a library workflow adds an independent copy to the project; the library's version is untouched. If the project already has a copy, opening the template asks what to do: **Open copy** switches to the existing copy, **Add new copy** makes another one, and **Replace copy** replaces the copy's graph with the library version while keeping its name. A replace can be undone in one step.
-Changes to a project's workflows never change the library by themselves. **Save to library…**, in the editor's header, saves the workflow as a new library template with the name you give it, including its form and its current settings as defaults. For a workflow opened from one of your own templates, **Update library template** replaces that template instead. If the template has changed since you opened it, you can save as a new template instead, or review the current one and replace it deliberately. Replacing a template also affects workflows that use it through the **Call Saved Workflow** node.
+Changes to a project's workflows never change the library by themselves. **Save to library…**, in the editor's header, saves the workflow as a new library template with the name you give it, including its form and its current settings as defaults. Owners can update their own linked saved workflow templates, and multi-user administrators can update any user's saved non-bundled template; bundled templates remain read-only. If the template has changed since you opened it, you can save as a new template instead, or review the current one and replace it deliberately. Replacing a template also affects workflows that use it through the **Call Saved Workflow** node.
Add comma-separated tags, such as `portrait, SDXL, upscaling`, in the workflow's **Details**; they become filters in the library. Each workflow also keeps its own undo history while the project is open.
diff --git a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx
index f40b1a6b810..97802a9d74e 100644
--- a/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx
+++ b/docs/src/content/docs/configuration/Multi-User Mode/user-guide.mdx
@@ -4,7 +4,8 @@ description: How to use InvokeAI in multi-user mode as an end user.
sidebar:
order: 3
---
-import { Steps } from '@astrojs/starlight/components'
+
+import { Steps } from '@astrojs/starlight/components';
## Overview
@@ -48,15 +49,17 @@ If you're the first person to access a fresh InvokeAI installation in multi-user
Now:
-1. Enter your email address (this will be your login name)
-2. Create a display name (this will be the name other users see)
-3. Choose a strong password. The following criteria are required with `strict_password_checking: true`.
- - At least 8 characters long
- - Contains uppercase letters
- - Contains lowercase letters
- - Contains numbers
-4. Confirm your password
-5. Click **Create Administrator Account**
+
+1. Enter your email address (this will be your login name).
+
+2. Create a display name (this will be the name other users see).
+
+3. Choose a strong password. With `strict_password_checking: true`, it must have at least 8 characters, including uppercase letters, lowercase letters, and numbers.
+
+4. Confirm your password.
+
+5. Click **Create Administrator Account**.
+
With `strict_password_checking` disabled, you'll be warned if you choose a
@@ -81,10 +84,15 @@ This will take you to the User Management screen...
The User Management screen also allows you to:
+
1. Temporarily change a user's status to Inactive, preventing them from logging in to Invoke.
+
2. Edit a user (by clicking on the pencil icon) to change the user's display name or password.
+
3. Permanently delete a user.
+
4. Grant a user Administrator privileges.
+
---
@@ -119,7 +127,7 @@ As a regular user, you can:
- Customize your UI preferences (theme, hotkeys, etc.)
- View available models (read-only access to Model Manager)
- Open boards other users have given them access to, and add to the ones they can contribute to
-- View and use workflows marked as shared by other users
+- Browse public workflows in the **Shared** library tab, including your own public workflows
You cannot:
@@ -144,7 +152,7 @@ Administrators have all regular user capabilities, plus:
- Full model management (add, delete, configure models)
- Create and manage user accounts
- View and manage all users' generation queues
-- View and manage all users' boards, images, and workflows (including system-owned legacy content)
+- View all users' workflows in the Library; rename, delete, and visibility controls are shown only to each workflow's owner
- Access system configuration
- Grant or revoke admin privileges
@@ -156,12 +164,12 @@ Administrators have all regular user capabilities, plus:
In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit, in the Library or inside a project. What another user may do with your board depends on their **role** on it:
-| Role | Can |
-| --- | --- |
-| **Viewer** | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. |
+| Role | Can |
+| --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| **Viewer** | Browse the board, open and download its images and videos, copy them to their own boards, and use them in their own generations. |
| **Contributor** | Everything a viewer can, and add their own images and videos to the board. They can remove, star or delete only what they added. |
-| **Editor** | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. |
-| **Owner** | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. |
+| **Editor** | Everything a contributor can, and remove or star anyone's items, rename the board, set its cover and archive it. |
+| **Owner** | Everything, including changing who has access, moving the board between the Library and a project, and deleting it. |
A board's **general access** sets the role every other user gets:
@@ -189,14 +197,16 @@ If an InvokeAI instance was in multiuser mode and then restarted in single user
### Workflows
-Each user has their own private workflow library. Workflows you create are visible only to you by default.
+In multi-user mode, new workflows saved to the Library are private by default. To change a saved workflow's visibility, open **Yours**, select it, and use **Visibility** in the detail panel to choose **Private** or **Public**. A public workflow is visible to signed-in users of this same InvokeAI instance; it is not published to the public internet.
+
+You can also choose **Public workflow** when using **Save to Library** to create a public Library copy of a project workflow. This does not make the project-only workflow public. Changing a Library record's visibility does not publish unsaved project edits; use **Update Template** to update the Library copy's content. **Update Template** preserves that copy's visibility.
-You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them.
+Public workflows appear in **Shared**, including your own public workflows. Callable public workflows can also appear in the **Call Saved Workflow** picker.
-To share a workflow, open it and use the sharing controls to toggle its public/shared status.
+Other users can browse, open, duplicate, or download your public workflows. In the Library, only the workflow's owner is offered rename, delete, and visibility controls.
-:::caution[Preexisting workflows after enabling multi-user mode]
-When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them.
+:::note[Preexisting Library workflows]
+User workflows already in the Library before per-user workflow ownership was introduced are migrated as public records owned by the internal system account. They appear in **Shared**. The Library does not offer rename, delete, or visibility controls for these records to other accounts, but users can still open, duplicate, or download them.
:::
### The Generation Queue
@@ -276,9 +286,13 @@ These settings are stored per-user and won't affect other users.
**Possible Causes:**
-1. **Filter Applied:** Check if a filter is hiding content
-2. **Wrong User:** Ensure you're logged in with the correct account
-3. **Archived Board:** Check the "Show Archived" option
+
+1. **Filter Applied:** Check if a filter is hiding content.
+
+2. **Wrong User:** Ensure you're logged in with the correct account.
+
+3. **Archived Board:** Check the "Show Archived" option.
+
**Solution:**
@@ -329,7 +343,7 @@ Not unless you give them access. Boards start **restricted**: only you and admin
### Can I share my workflows with others?
-Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them.
+Yes. In multi-user mode, make a saved Library workflow public from **Yours**, or choose **Public workflow** when saving a new Library copy. Public workflows are visible to signed-in users of the same InvokeAI instance and appear in **Shared**. Other users can open, duplicate, or download them; only the owner is offered rename, delete, and visibility controls in the Library. For a project copy linked to a public workflow, its owner or a ready administrator can use **Update Template**; other users can choose **Save as new** to create their own Library workflow.
### How long do sessions last?
diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json
index 58f58f6d92e..4167765928f 100644
--- a/invokeai/frontend/webv2/public/locales/en.json
+++ b/invokeai/frontend/webv2/public/locales/en.json
@@ -5215,6 +5215,11 @@
"openProjectChanged": "The project changed while \"{{name}}\" was loading; nothing was added.",
"openProjectWorkflowHint": "Makes it the active workflow",
"previewGraph": "Preview graph",
+ "private": "Private",
+ "public": "Public",
+ "publicVisibilityHelp": "Visible to users of this InvokeAI instance.",
+ "publicVisibilityPartial": "The template was saved, but its public visibility could not be confirmed ({{message}}). Retry to make it public.",
+ "publicWorkflow": "Public workflow",
"projectWorkflowCount_one": "{{count}} workflow in this project",
"projectWorkflowCount_other": "{{count}} workflows in this project",
"remove": "Remove",
@@ -5261,6 +5266,7 @@
"savedCreatedBody": "Saved \"{{name}}\" to the library.",
"savedUpdatedBody": "Updated \"{{name}}\" in the library.",
"searchPlaceholder": "Search names, tags, or descriptions",
+ "shared": "Shared",
"sourceBundled": "From a bundled template (read-only)",
"sourceNone": "Not linked to a library template",
"sourceRevision": "Linked to library template at revision {{revision}}",
@@ -5278,6 +5284,9 @@
"thumbnailUpdateFailed": "Failed to update thumbnail",
"thumbnailUpdated": "Thumbnail updated",
"thumbnailUpdating": "Updating thumbnail…",
+ "visibility": "Visibility",
+ "visibilityUpdateFailed": "Failed to update workflow visibility",
+ "visibilityUnknown": "Visibility unknown",
"title": "Workflows",
"unavailableBundledBody": "\"{{name}}\" came from a bundled template, which cannot be changed. Save it as a new template instead.",
"unavailableForbiddenBody": "The library template behind \"{{name}}\" belongs to someone else. Save it as a new template instead.",
diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
index c50a9ae4054..8f2f79ff058 100644
--- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
+++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
@@ -1469,6 +1469,85 @@ test('runtime config PATCH keeps the other writable field and validates the queu
});
});
+test('workflow creation defaults public for single-user callers and private for multiuser callers', async () => {
+ const backend = await startMockBackend(0, { profile: 'empty' });
+
+ try {
+ const createWorkflow = (name) =>
+ getJson(backend, '/api/v1/workflows', {
+ body: JSON.stringify({ workflow: { name, nodes: [], tags: 'local' } }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ });
+
+ const singleUser = await createWorkflow('Single-user template');
+ assert.equal(singleUser.is_public, true);
+
+ await getJson(backend, '/__faults', {
+ body: JSON.stringify({ intermediatesCaller: 'user' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ });
+
+ const multiuser = await createWorkflow('Multiuser template');
+ assert.equal(multiuser.is_public, false);
+ } finally {
+ await backend.close();
+ }
+});
+
+test('workflow visibility PATCH mirrors the shared tag without changing revision and filters lists', async () => {
+ const backend = await startMockBackend(0, { profile: 'empty' });
+
+ try {
+ const createWorkflow = (name) =>
+ getJson(backend, '/api/v1/workflows', {
+ body: JSON.stringify({ workflow: { name, nodes: [], tags: 'local' } }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ });
+ const singleUser = await createWorkflow('Visible template');
+ const target = await createWorkflow('Toggle template');
+ const revision = target.revision;
+ const patchVisibility = (isPublic) =>
+ getJson(backend, `/api/v1/workflows/i/${encodeURIComponent(target.workflow_id)}/is_public`, {
+ body: JSON.stringify({ is_public: isPublic }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+ const listNames = async (isPublic) => {
+ const params = new URLSearchParams({ categories: 'user', is_public: String(isPublic), per_page: '100' });
+
+ return (await getJson(backend, `/api/v1/workflows/?${params}`)).items.map((item) => item.name).sort();
+ };
+ const tagsOf = (value) =>
+ String(value ?? '')
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter(Boolean)
+ .sort();
+
+ const hidden = await patchVisibility(false);
+ assert.equal(hidden.is_public, false);
+ assert.equal(hidden.revision, revision);
+ assert.deepEqual(tagsOf(hidden.tags), ['local']);
+ assert.deepEqual(tagsOf(hidden.workflow.tags), ['local']);
+ assert.deepEqual(await listNames(true), ['Visible template']);
+ assert.deepEqual(await listNames(false), ['Toggle template']);
+
+ const shared = await patchVisibility(true);
+ assert.equal(shared.is_public, true);
+ assert.equal(shared.revision, revision);
+ assert.deepEqual(tagsOf(shared.tags), ['local', 'shared']);
+ assert.deepEqual(tagsOf(shared.workflow.tags), ['local', 'shared']);
+ assert.deepEqual(await listNames(true), ['Toggle template', 'Visible template']);
+ assert.deepEqual(await listNames(false), []);
+ assert.equal(singleUser.is_public, true);
+ } finally {
+ await backend.close();
+ }
+});
+
test('hosted-provider credentials and the starter catalog agree with the hosted fixture records', async () => {
await withRepresentativeBackend(async (backend) => {
const { models } = await getJson(backend, '/api/v2/models/');
diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs
index 635aad27394..df7a6fb6082 100644
--- a/invokeai/frontend/webv2/scripts/mock-backend.mjs
+++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs
@@ -2898,6 +2898,18 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
+ const workflowTagsForVisibility = (value, isPublic) => {
+ const tags = String(value ?? '')
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => tag && tag !== 'shared');
+
+ if (isPublic) {
+ tags.push('shared');
+ }
+
+ return tags.join(',');
+ };
if (path === '/api/v1/workflows/tags') {
const categories = url.searchParams.getAll('categories');
@@ -2927,6 +2939,37 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
return json(200, counts);
}
+ const visibilityMatch = /^\/api\/v1\/workflows\/i\/([^/]+)\/is_public$/.exec(path);
+ if (visibilityMatch) {
+ if (method !== 'PATCH') {
+ return json(405, { detail: `No mock for ${method} ${path}` });
+ }
+
+ const workflowId = decodeURIComponent(visibilityMatch[1]);
+ const workflow = state.workflows.get(workflowId);
+
+ if (!workflow) {
+ return json(404, { detail: 'Workflow not found' });
+ }
+ if (workflow.category === 'default') {
+ return json(403, { detail: 'Bundled workflows cannot be modified' });
+ }
+
+ const body = await readJsonBody(request);
+ if (typeof body.is_public !== 'boolean') {
+ return json(422, { detail: 'is_public must be a boolean' });
+ }
+
+ workflow.is_public = body.is_public;
+ workflow.tags = workflowTagsForVisibility(workflow.tags, body.is_public);
+ workflow.workflow = {
+ ...workflow.workflow,
+ tags: workflowTagsForVisibility(workflow.workflow?.tags, body.is_public),
+ };
+
+ return json(200, workflowRecord(workflow));
+ }
+
const workflowMatch = /^\/api\/v1\/workflows\/i\/([^/]+)(\/opened_at|\/thumbnail)?$/.exec(path);
if (workflowMatch) {
const workflowId = decodeURIComponent(workflowMatch[1]);
@@ -3052,7 +3095,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
category: 'user',
created_at: timestamp(state),
description: content.description ?? '',
- is_public: true,
+ is_public: !isMultiuserCaller(faults),
name: content.name ?? '',
opened_at: null,
revision: 1,
@@ -3074,6 +3117,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
const categories = url.searchParams.getAll('categories');
const query = url.searchParams.get('query')?.trim().toLocaleLowerCase() ?? '';
const tags = url.searchParams.getAll('tags');
+ const isPublic = url.searchParams.get('is_public');
// The API pages from 0; the client asks for page 0 first.
const page = Math.max(0, Number(url.searchParams.get('page') ?? 0) || 0);
const perPage = Math.max(1, Number(url.searchParams.get('per_page') ?? 20) || 20);
@@ -3091,7 +3135,8 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
(workflow) =>
(categories.length === 0 || categories.includes(workflow.category)) &&
(!query || `${workflow.name} ${workflow.description}`.toLocaleLowerCase().includes(query)) &&
- (tags.length === 0 || tags.some((tag) => workflowTags(workflow).includes(tag)))
+ (tags.length === 0 || tags.some((tag) => workflowTags(workflow).includes(tag))) &&
+ (isPublic === null || String(workflow.is_public) === isPublic)
)
.sort(
(left, right) =>
diff --git a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs
index 70a62a9bf3b..a545080e762 100644
--- a/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs
+++ b/invokeai/frontend/webv2/scripts/run-project-file-journey.mjs
@@ -733,7 +733,11 @@ const runWorkflowCollection = async ({ browser, contexts, errors, imported }) =>
record.data.workflows.entries.map((entry) => entry.document.name),
['Empty Workflow', 'Journey Saved']
);
- assert.deepEqual(record.data.workflows.entries[1].source, { libraryWorkflowId: saved.workflow_id, revision: 1 });
+ assert.deepEqual(record.data.workflows.entries[1].source, {
+ libraryWorkflowId: saved.workflow_id,
+ ownerUserId: 'fixture-user',
+ revision: 1,
+ });
assert.equal(record.workspace.data.workflows.activeWorkflowId, record.data.workflows.entries[1].document.id);
// Restart: both workflows and the active selection come back from the server.
diff --git a/invokeai/frontend/webv2/src/features/workflow/core/types.ts b/invokeai/frontend/webv2/src/features/workflow/core/types.ts
index af24752cf69..f9e734f8d80 100644
--- a/invokeai/frontend/webv2/src/features/workflow/core/types.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/core/types.ts
@@ -272,6 +272,8 @@ export interface ProjectWorkflowSource {
libraryWorkflowId: string;
/** The template content revision loaded or last written; null when unknown (documents migrated from older schemas). */
revision: number | null;
+ /** Stable owner metadata lets each account check write access against its current session. */
+ ownerUserId?: string;
}
/** The newest successful run's final output, owned by the project workflow that submitted it. */
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts
index 598cfd8037e..637aa454713 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/api.test.ts
@@ -4,7 +4,13 @@ const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), apiFetchJson: vi.fn() }));
vi.mock('@platform/transport/http', () => ({ apiFetch: mocks.apiFetch, apiFetchJson: mocks.apiFetchJson }));
-import { getAllWorkflowTags, getLibraryWorkflowRecord, getWorkflowTagCounts, listLibraryWorkflows } from './api';
+import {
+ getAllWorkflowTags,
+ getLibraryWorkflowRecord,
+ getWorkflowTagCounts,
+ listLibraryWorkflows,
+ setLibraryWorkflowPublic,
+} from './api';
describe('workflow library api', () => {
beforeEach(() => {
@@ -56,6 +62,18 @@ describe('workflow library api', () => {
expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/workflows/i/workflow-1', { signal: undefined });
});
+ it('updates workflow visibility with the workflow record endpoint', async () => {
+ const record = { workflow_id: 'workflow-1', name: 'Workflow 1', workflow: {}, is_public: true };
+ mocks.apiFetchJson.mockResolvedValue(record);
+
+ await expect(setLibraryWorkflowPublic('team/workflow id', true)).resolves.toEqual(record);
+ expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/workflows/i/team%2Fworkflow%20id/is_public', {
+ body: JSON.stringify({ is_public: true }),
+ method: 'PATCH',
+ signal: undefined,
+ });
+ });
+
it('gets tag counts for the given tags', async () => {
mocks.apiFetchJson.mockResolvedValue({ lora: 2, upscaling: 1 });
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/api.ts b/invokeai/frontend/webv2/src/features/workflow/data/api.ts
index 2783f78b94d..ad25831043e 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/api.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/api.ts
@@ -92,12 +92,33 @@ export const listLibraryWorkflows = ({
return apiFetchJson(`/api/v1/workflows/?${params.toString()}`, { signal });
};
-export interface WorkflowRecordDTO extends WorkflowLibraryListItem {
+export interface WorkflowRecordDTO {
workflow_id: string;
name: string;
+ created_at: string;
+ updated_at: string;
+ opened_at?: string | null;
+ last_run_at?: string | null;
+ user_id: string;
+ is_public: boolean;
+ revision: number;
+ thumbnail_url?: string | null;
+ call_saved_workflow_compatibility?: WorkflowCallCompatibility | null;
workflow: Record;
}
+export const getWorkflowRecordCategory = (record: WorkflowRecordDTO): WorkflowLibraryCategory | undefined => {
+ const meta = record.workflow.meta;
+
+ if (typeof meta !== 'object' || meta === null) {
+ return undefined;
+ }
+
+ const category = (meta as { category?: unknown }).category;
+
+ return category === 'user' || category === 'default' ? category : undefined;
+};
+
export const getLibraryWorkflowRecord = (workflowId: string, signal?: AbortSignal): Promise =>
apiFetchJson(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}`, { signal });
@@ -228,6 +249,22 @@ export const updateLibraryWorkflow = async (
}
};
+export const setLibraryWorkflowPublic = async (
+ workflowId: string,
+ isPublic: boolean,
+ signal?: AbortSignal
+): Promise => {
+ try {
+ return await apiFetchJson(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}/is_public`, {
+ body: JSON.stringify({ is_public: isPublic }),
+ method: 'PATCH',
+ signal,
+ });
+ } catch (error) {
+ return rethrowAsRefusal(error);
+ }
+};
+
export const deleteLibraryWorkflow = async (workflowId: string, signal?: AbortSignal): Promise => {
await apiFetch(`/api/v1/workflows/i/${encodeURIComponent(workflowId)}`, { method: 'DELETE', signal });
};
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts
index 16d2df76cd5..c2f0db3f4fc 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.test.ts
@@ -182,6 +182,63 @@ describe('workflow library page fetching', () => {
);
});
+ it('queries shared workflows and their tags as public user workflows', async () => {
+ api.getAllWorkflowTags.mockResolvedValue(['shared-tag']);
+ api.getWorkflowTagCounts.mockResolvedValue({ 'shared-tag': 2 });
+ api.listLibraryWorkflows.mockImplementation(({ isPublic, page }: { isPublic?: boolean; page: number }) =>
+ Promise.resolve(buildPage([buildItem(isPublic ? `shared-${page}` : 'yours')], { page, pages: 2, total: 2 }))
+ );
+ await browse.ensureWorkflowLibraryBrowseLoaded('updated');
+
+ expect(api.listLibraryWorkflows.mock.calls[0]?.[0]).toMatchObject({ category: 'user', page: 0, perPage: 20 });
+ expect(api.listLibraryWorkflows.mock.calls[0]?.[0]).not.toHaveProperty('isPublic');
+
+ api.listLibraryWorkflows.mockClear();
+ api.getAllWorkflowTags.mockClear();
+ api.getWorkflowTagCounts.mockClear();
+ browse.setWorkflowLibraryBrowseFilter({ category: 'user', isPublic: true, tag: null });
+
+ await vi.waitFor(() => {
+ expect(browse.getWorkflowLibraryBrowseSnapshot().status).toBe('loaded');
+ expect(api.getWorkflowTagCounts).toHaveBeenCalled();
+ });
+
+ expect(api.listLibraryWorkflows).toHaveBeenCalledWith(
+ expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 })
+ );
+ expect(api.listLibraryWorkflows.mock.calls.every(([params]) => !('callable' in params))).toBe(true);
+ expect(api.getAllWorkflowTags).toHaveBeenCalledWith(
+ expect.objectContaining({ categories: ['user'], isPublic: true })
+ );
+ expect(api.getWorkflowTagCounts).toHaveBeenCalledWith(
+ expect.objectContaining({ categories: ['user'], isPublic: true, tags: ['shared-tag'] })
+ );
+
+ browse.loadNextWorkflowLibraryPage();
+ await vi.waitFor(() => expect(browse.getWorkflowLibraryBrowseSnapshot().page).toBe(1));
+ expect(api.listLibraryWorkflows).toHaveBeenCalledWith(
+ expect.objectContaining({ category: 'user', isPublic: true, page: 1, perPage: 20 })
+ );
+
+ api.listLibraryWorkflows.mockClear();
+ api.getAllWorkflowTags.mockClear();
+ api.getWorkflowTagCounts.mockClear();
+ await browse.refreshWorkflowLibraryBrowse();
+
+ expect(api.listLibraryWorkflows).toHaveBeenCalledWith(
+ expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 })
+ );
+ expect(api.listLibraryWorkflows).toHaveBeenCalledWith(
+ expect.objectContaining({ category: 'user', isPublic: true, page: 1, perPage: 20 })
+ );
+ expect(api.getAllWorkflowTags).toHaveBeenCalledWith(
+ expect.objectContaining({ categories: ['user'], isPublic: true })
+ );
+ expect(api.getWorkflowTagCounts).toHaveBeenCalledWith(
+ expect.objectContaining({ categories: ['user'], isPublic: true, tags: ['shared-tag'] })
+ );
+ });
+
it('ignores a repeated filter patch that changes nothing', async () => {
await browse.ensureWorkflowLibraryBrowseLoaded('updated');
api.listLibraryWorkflows.mockClear();
@@ -590,6 +647,35 @@ describe('workflow library browse refresh', () => {
expect(api.listLibraryWorkflows).toHaveBeenCalledTimes(1);
});
+ it('removes a workflow from the active Shared view after visibility invalidation', async () => {
+ const sharedWorkflow = buildItem('shared', { is_public: true, user_id: 'user-a' });
+ api.listLibraryWorkflows.mockImplementation(({ isPublic }: { isPublic?: boolean }) =>
+ Promise.resolve(buildPage(isPublic ? [sharedWorkflow] : []))
+ );
+
+ await browse.ensureWorkflowLibraryBrowseLoaded('updated');
+ browse.setWorkflowLibraryBrowseFilter({ category: 'user', isPublic: true, tag: null });
+ await vi.waitFor(() => {
+ const snapshot = browse.getWorkflowLibraryBrowseSnapshot();
+ expect(snapshot.status).toBe('loaded');
+ expect(snapshot.entries.map((entry) => entry.item.workflow_id)).toEqual(['shared']);
+ });
+
+ api.listLibraryWorkflows.mockResolvedValue(buildPage([]));
+ invalidate();
+
+ await vi.waitFor(() => {
+ expect(browse.getWorkflowLibraryBrowseSnapshot()).toMatchObject({
+ entries: [],
+ filter: { category: 'user', isPublic: true },
+ status: 'loaded',
+ });
+ });
+ expect(api.listLibraryWorkflows).toHaveBeenCalledWith(
+ expect.objectContaining({ category: 'user', isPublic: true, page: 0, perPage: 20 })
+ );
+ });
+
it('does not refresh while the store is idle', async () => {
invalidate();
@@ -758,6 +844,24 @@ describe('workflow library browse account ownership', () => {
userTotal: null,
});
});
+
+ it('drops an in-flight shared page when the account switches', async () => {
+ await browse.ensureWorkflowLibraryBrowseLoaded('updated');
+
+ const stale = createDeferred();
+ api.listLibraryWorkflows.mockReturnValueOnce(stale.promise);
+ browse.setWorkflowLibraryBrowseFilter({ isPublic: true });
+
+ account.accountLifecycle.activate('user-b');
+ stale.resolve(buildPage([buildItem('other-account-shared')]));
+ await flushAsyncWork();
+
+ expect(browse.getWorkflowLibraryBrowseSnapshot()).toMatchObject({
+ entries: [],
+ filter: { category: 'user', search: '', tag: null },
+ status: 'idle',
+ });
+ });
});
describe('workflow library user total probe', () => {
@@ -818,6 +922,42 @@ describe('workflow library view cache', () => {
expect(libraryCache.getLibraryWorkflowCached).not.toHaveBeenCalled();
});
+ it('keeps shared pages and tag counts separate from Yours in the view cache', async () => {
+ api.listLibraryWorkflows.mockImplementation(({ isPublic }: { isPublic?: boolean }) =>
+ Promise.resolve(buildPage([buildItem(isPublic ? 'shared' : 'yours')]))
+ );
+ api.getAllWorkflowTags.mockImplementation(({ isPublic }: { isPublic?: boolean }) =>
+ Promise.resolve([isPublic ? 'shared-tag' : 'yours-tag'])
+ );
+ api.getWorkflowTagCounts.mockImplementation(({ tags, isPublic }: { tags: string[]; isPublic?: boolean }) =>
+ Promise.resolve({ [tags[0] ?? '']: isPublic ? 2 : 1 })
+ );
+
+ await browse.ensureWorkflowLibraryBrowseLoaded('updated');
+ await vi.waitFor(() =>
+ expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 1, tag: 'yours-tag' }])
+ );
+ const yours = browse.getWorkflowLibraryBrowseSnapshot().entries;
+
+ browse.setWorkflowLibraryBrowseFilter({ isPublic: true });
+ await vi.waitFor(() => {
+ expect(browse.getWorkflowLibraryBrowseSnapshot().entries[0]?.item.workflow_id).toBe('shared');
+ expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 2, tag: 'shared-tag' }]);
+ });
+ const shared = browse.getWorkflowLibraryBrowseSnapshot().entries;
+
+ api.listLibraryWorkflows.mockClear();
+ browse.setWorkflowLibraryBrowseFilter({ isPublic: undefined });
+ expect(browse.getWorkflowLibraryBrowseSnapshot().entries).toBe(yours);
+ expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 1, tag: 'yours-tag' }]);
+
+ browse.setWorkflowLibraryBrowseFilter({ isPublic: true });
+ expect(browse.getWorkflowLibraryBrowseSnapshot().entries).toBe(shared);
+ expect(browse.getWorkflowLibraryBrowseSnapshot().tagCounts).toEqual([{ count: 2, tag: 'shared-tag' }]);
+ await flushAsyncWork();
+ expect(api.listLibraryWorkflows).not.toHaveBeenCalled();
+ });
+
it('keeps a stale view on screen while it revalidates, then merges without re-reading unchanged rows', async () => {
const yours = await browseYoursThenBrowse();
const later = Date.now() + 60_000;
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts
index a58cf6264bc..a32c734ac4a 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryBrowseStore.ts
@@ -41,6 +41,8 @@ export type WorkflowLibrarySort = 'updated' | 'opened' | 'created' | 'name';
export interface WorkflowLibraryBrowseFilter {
category: WorkflowLibraryCategory;
+ /** Public user workflows form a distinct view; absent keeps user listings owner scoped. */
+ isPublic?: boolean;
tag: string | null;
search: string;
/** Applies to the user category only; see `getListOrder`. */
@@ -68,7 +70,7 @@ export interface WorkflowLibraryBrowseSnapshot {
page: number;
pages: number;
total: number;
- /** Chip counts for the current category. */
+ /** Chip counts for the current category and visibility filter. */
tagCounts: readonly WorkflowTagCount[];
/** Total user-category workflows, for the one-time Browse/Yours auto-switch. */
userTotal: number | null;
@@ -120,7 +122,7 @@ const store = createExternalStore(INITIAL_SNAPSHO
// #region View cache
/**
- * Views already browsed (Browse, Yours, a tag or search), kept so switching back shows them at once with their
+ * Views already browsed (Browse, Yours, Shared, a tag or search), kept so switching back shows them at once with their
* enrichment instead of refetching and re-reading every row. A view older than `VIEW_FRESH_MS` is revalidated in the
* background and merged in place; library mutations drop the other views.
*/
@@ -141,7 +143,14 @@ const viewCache = new Map();
let currentViewFetchedAt = 0;
const getFilterKey = (filter: WorkflowLibraryBrowseFilter): string =>
- JSON.stringify([filter.category, filter.search, filter.tag, getListOrder(filter).orderBy]);
+ JSON.stringify([
+ filter.category,
+ filter.isPublic,
+ filter.search,
+ filter.tag,
+ getListOrder(filter).orderBy,
+ getListOrder(filter).direction,
+ ]);
const rememberCurrentView = (): void => {
const { entries, filter, page, pages, status, tagCounts, total } = store.getSnapshot();
@@ -390,6 +399,7 @@ const fetchPage = (
listLibraryWorkflows({
...getListOrder(filter),
category: filter.category,
+ ...(filter.isPublic === undefined ? {} : { isPublic: filter.isPublic }),
page,
perPage: PER_PAGE,
query: filter.search || undefined,
@@ -440,14 +450,22 @@ const loadMorePages = async (filter: WorkflowLibraryBrowseFilter, page: number,
};
/** Tag chips are best-effort: a failed count leaves the previous chips in place. */
-const loadTagCounts = async (category: WorkflowLibraryCategory, owner: AccountScope): Promise => {
+const loadTagCounts = async (
+ category: WorkflowLibraryCategory,
+ isPublic: boolean | undefined,
+ owner: AccountScope
+): Promise => {
try {
- const tags = await getAllWorkflowTags({ categories: [category], signal: owner.signal });
+ const publicFilter = isPublic === undefined ? {} : { isPublic };
+ const tags = await getAllWorkflowTags({ categories: [category], ...publicFilter, signal: owner.signal });
const counts =
- tags.length > 0 ? await getWorkflowTagCounts({ categories: [category], signal: owner.signal, tags }) : {};
+ tags.length > 0
+ ? await getWorkflowTagCounts({ categories: [category], ...publicFilter, signal: owner.signal, tags })
+ : {};
- // A category switch mid-flight owns the chips now.
- if (!isAccountScopeCurrent(owner) || store.getSnapshot().filter.category !== category) {
+ // A category or visibility switch mid-flight owns the chips now.
+ const filter = store.getSnapshot().filter;
+ if (!isAccountScopeCurrent(owner) || filter.category !== category || filter.isPublic !== isPublic) {
return;
}
@@ -490,6 +508,7 @@ export const setWorkflowLibraryBrowseFilter = (patch: Partial =>
}
}
- await loadTagCounts(filter.category, owner);
+ await loadTagCounts(filter.category, filter.isPublic, owner);
});
export const getWorkflowLibraryBrowseSnapshot = (): WorkflowLibraryBrowseSnapshot => store.getSnapshot();
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts
index 23e3344a843..4b576b6deba 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/libraryCache.test.ts
@@ -46,9 +46,15 @@ const buildPage = (items: WorkflowLibraryListItem[]): WorkflowLibraryPage => ({
/** Revision `n` of a workflow holds `n` notes nodes, so the graph a caller receives names its revision. */
const buildRecord = (workflowId: string, revision: number): WorkflowRecordDTO => ({
- ...buildItem(workflowId, revision),
+ created_at: '2026-10-20T00:00:00Z',
+ is_public: false,
+ name: `Workflow ${workflowId}`,
+ revision,
+ updated_at: '2026-10-20T00:00:00Z',
+ user_id: 'user-a',
workflow: {
edges: [],
+ meta: { category: 'user' },
name: `Workflow ${workflowId}`,
nodes: Array.from({ length: revision }, (_unused, index) => ({
data: { label: '', notes: '' },
@@ -57,6 +63,7 @@ const buildRecord = (workflowId: string, revision: number): WorkflowRecordDTO =>
type: 'notes',
})),
},
+ workflow_id: workflowId,
});
const createDeferred = () => {
@@ -343,13 +350,17 @@ describe('workflow library account ownership', () => {
account.accountLifecycle.activate('user-b');
expect(workflowSignal.aborted).toBe(true);
- delayed.resolve({ ...buildRecord('shared-id', 1), workflow: { owner: 'a' } });
+ delayed.resolve({ ...buildRecord('shared-id', 1), workflow: { meta: { category: 'user' }, owner: 'a' } });
await expect(oldWorkflow).rejects.toThrow('no longer active');
- api.getLibraryWorkflowRecord.mockResolvedValueOnce({ ...buildRecord('shared-id', 1), workflow: { owner: 'b' } });
+ api.getLibraryWorkflowRecord.mockResolvedValueOnce({
+ ...buildRecord('shared-id', 1),
+ workflow: { meta: { category: 'user' }, owner: 'b' },
+ });
await expect(cache.getLibraryWorkflowCached('shared-id', { expectedRevision: 1 })).resolves.toEqual({
id: 'shared-id',
+ meta: { category: 'user' },
owner: 'b',
});
});
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts
index 0da4e990110..b99fd45aeb7 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.test.ts
@@ -9,17 +9,20 @@ import type { WorkflowRecordDTO } from './api';
import { WorkflowLibraryWriteRefusedError } from './api';
import { createWorkflowPublicationController, isWorkflowContentEquivalent } from './publication';
-const record = (overrides: Partial = {}): WorkflowRecordDTO => ({
- category: 'user',
- description: '',
- name: 'Template',
- revision: 1,
- workflow: { name: 'Template', nodes: [] },
- workflow_id: 'lib-1',
- ...overrides,
-});
-
-const workflow = { name: 'Template', nodes: [{ id: 'n1' }] };
+const record = (overrides: Partial = {}): WorkflowRecordDTO =>
+ ({
+ created_at: '2026-10-20T00:00:00Z',
+ is_public: false,
+ name: 'Template',
+ revision: 1,
+ updated_at: '2026-10-20T00:00:00Z',
+ user_id: 'user-a',
+ workflow: { meta: { category: 'user' }, name: 'Template', nodes: [] },
+ workflow_id: 'lib-1',
+ ...overrides,
+ }) satisfies WorkflowRecordDTO;
+
+const workflow = { meta: { category: 'user' }, name: 'Template', nodes: [{ id: 'n1' }] };
const deferred = () => {
let resolve!: (value: T) => void;
@@ -39,6 +42,7 @@ describe('workflow publication controller', () => {
getRecord: vi.fn(),
invalidateCache: vi.fn(),
reserveId: vi.fn(() => 'reserved-1'),
+ setPublic: vi.fn(),
updateRecord: vi.fn(),
};
@@ -49,11 +53,13 @@ describe('workflow publication controller', () => {
deps.getRecord.mockReset();
deps.invalidateCache.mockReset();
deps.reserveId.mockReset().mockReturnValue('reserved-1');
+ deps.setPublic.mockReset();
deps.updateRecord.mockReset();
});
- const request = (destination: Parameters[0]['destination']) => ({
+ const request = (destination: Parameters[0]['destination'], isPublic?: boolean) => ({
destination,
+ ...(isPublic === undefined ? {} : { isPublic }),
owner,
projectId: 'project-1',
workflow,
@@ -62,7 +68,9 @@ describe('workflow publication controller', () => {
const controller = createWorkflowPublicationController(deps);
it('creates under a reserved id, names the template, and invalidates the library', async () => {
- deps.createRecord.mockResolvedValue(record({ name: 'Named', revision: 1, workflow_id: 'reserved-1' }));
+ deps.createRecord.mockResolvedValue(
+ record({ name: 'Named', revision: 1, user_id: 'user-a', workflow_id: 'reserved-1' })
+ );
const result = await controller.publish(request({ kind: 'create', name: 'Named' }));
@@ -70,6 +78,7 @@ describe('workflow publication controller', () => {
kind: 'created',
libraryWorkflowId: 'reserved-1',
name: 'Named',
+ ownerUserId: 'user-a',
revision: 1,
status: 'published',
});
@@ -78,6 +87,83 @@ describe('workflow publication controller', () => {
{ reservedId: 'reserved-1', signal: owner.signal }
);
expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1');
+ expect(deps.setPublic).not.toHaveBeenCalled();
+ });
+
+ it('keeps the creation revision when the visibility response is newer', async () => {
+ deps.createRecord.mockResolvedValue(
+ record({ name: 'Named', revision: 1, user_id: 'user-a', workflow_id: 'reserved-1' })
+ );
+ deps.setPublic.mockResolvedValue(
+ record({ name: 'Named', is_public: true, revision: 2, workflow_id: 'reserved-1' })
+ );
+
+ await expect(controller.publish(request({ kind: 'create', name: 'Named' }, true))).resolves.toMatchObject({
+ kind: 'created',
+ libraryWorkflowId: 'reserved-1',
+ revision: 1,
+ status: 'published',
+ });
+ });
+
+ it('invalidates the private record after partial visibility and retries under one reserved id', async () => {
+ deps.createRecord
+ .mockRejectedValueOnce(new TypeError('create answer lost'))
+ .mockResolvedValueOnce(record({ name: 'Named', is_public: false, workflow_id: 'reserved-1' }));
+ deps.setPublic
+ .mockRejectedValueOnce(new TypeError('visibility answer lost'))
+ .mockResolvedValueOnce(record({ name: 'Named', is_public: true, workflow_id: 'reserved-1' }));
+
+ const failed = await controller.publish(request({ kind: 'create', name: 'Named' }, true));
+
+ expect(failed.status).toBe('failed');
+ if (failed.status !== 'failed') {
+ throw new Error('expected the create failure');
+ }
+
+ const partial = await failed.retry();
+
+ expect(partial).toMatchObject({ libraryWorkflowId: 'reserved-1', status: 'partial' });
+ expect(deps.createRecord).toHaveBeenCalledTimes(2);
+ expect(deps.setPublic).toHaveBeenCalledWith('reserved-1', true, owner.signal);
+ expect(deps.invalidateCache).toHaveBeenCalledTimes(1);
+ expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1');
+ if (partial.status !== 'partial') {
+ throw new Error('expected the visibility failure');
+ }
+
+ await expect(partial.retry()).resolves.toMatchObject({
+ kind: 'created',
+ libraryWorkflowId: 'reserved-1',
+ status: 'published',
+ });
+ expect(deps.createRecord.mock.calls.map((call) => call[1].reservedId)).toEqual(['reserved-1', 'reserved-1']);
+ expect(deps.setPublic.mock.calls).toEqual([
+ ['reserved-1', true, owner.signal],
+ ['reserved-1', true, owner.signal],
+ ]);
+ expect(deps.reserveId).toHaveBeenCalledTimes(1);
+ expect(deps.invalidateCache).toHaveBeenCalledTimes(2);
+ expect(deps.invalidateCache).toHaveBeenCalledWith('reserved-1');
+ });
+
+ it('keeps the creation revision when a visibility retry response is newer', async () => {
+ deps.createRecord.mockResolvedValue(record({ revision: 1, workflow_id: 'reserved-1' }));
+ deps.setPublic
+ .mockRejectedValueOnce(new TypeError('visibility answer lost'))
+ .mockResolvedValueOnce(record({ is_public: true, revision: 2, workflow_id: 'reserved-1' }));
+
+ const result = await controller.publish(request({ kind: 'create', name: 'Named' }, true));
+ if (result.status !== 'partial') {
+ throw new Error('expected the visibility failure');
+ }
+
+ await expect(result.retry()).resolves.toMatchObject({
+ kind: 'created',
+ libraryWorkflowId: 'reserved-1',
+ revision: 1,
+ status: 'published',
+ });
});
it('refuses to overlap a publication of the same workflow and reports when it is busy', async () => {
@@ -123,6 +209,7 @@ describe('workflow publication controller', () => {
kind: 'updated',
libraryWorkflowId: 'lib-1',
name: 'Library name',
+ ownerUserId: 'user-a',
revision: 3,
status: 'published',
});
@@ -132,6 +219,7 @@ describe('workflow publication controller', () => {
{ expectedRevision: 2, signal: owner.signal }
);
expect(deps.invalidateCache).toHaveBeenCalledWith('lib-1');
+ expect(deps.setPublic).not.toHaveBeenCalled();
});
it('reports a conflict from the record read alone, without sending', async () => {
@@ -202,7 +290,9 @@ describe('workflow publication controller', () => {
expect(deps.updateRecord).toHaveBeenCalledTimes(2);
// Someone else moved it: never overwrite.
- deps.getRecord.mockResolvedValueOnce(record({ revision: 3, workflow: { name: 'Theirs', nodes: [] } }));
+ deps.getRecord.mockResolvedValueOnce(
+ record({ revision: 3, workflow: { meta: { category: 'user' }, name: 'Theirs', nodes: [] } })
+ );
await expect(failed.retry()).resolves.toEqual({
currentRevision: 3,
libraryWorkflowId: 'lib-1',
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts
index ba00852a52a..38708ec3d16 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/publication.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/publication.ts
@@ -8,7 +8,9 @@ import type { WorkflowLibraryWriteRefusedError, WorkflowRecordDTO } from './api'
import {
createLibraryWorkflowRecord,
+ getWorkflowRecordCategory,
getLibraryWorkflowRecord,
+ setLibraryWorkflowPublic,
toWorkflowLibraryWriteRefusal,
updateLibraryWorkflow,
} from './api';
@@ -30,17 +32,33 @@ export interface WorkflowPublicationRequest {
workflowId: string;
/** The serialized document as it stood when the user confirmed; later edits are not part of this publication. */
workflow: Record;
+ /** When true, create the template privately first and then request public visibility. */
+ isPublic?: boolean;
destination: WorkflowPublicationDestination;
}
export type WorkflowPublicationResult =
- | { status: 'published'; kind: 'created' | 'updated'; libraryWorkflowId: string; revision: number; name: string }
+ | {
+ status: 'published';
+ kind: 'created' | 'updated';
+ libraryWorkflowId: string;
+ ownerUserId?: string;
+ revision: number;
+ name: string;
+ }
/** The template moved past the expected revision, or its revision was never known. */
| { status: 'conflict'; libraryWorkflowId: string; currentRevision: number | null }
/** The destination cannot be written: it is gone, someone else's, or bundled. Saving as new still works. */
| { status: 'unavailable'; libraryWorkflowId: string; reason: 'missing' | 'forbidden' | 'bundled' }
/** The server rejected the content itself. */
| { status: 'invalid'; message: string }
+ /** The template exists privately, but the requested visibility change did not settle. */
+ | {
+ status: 'partial';
+ libraryWorkflowId: string;
+ message: string;
+ retry: () => Promise;
+ }
/** Nothing definitive came back; `retry` resends the same captured request under the same reserved id. */
| { status: 'failed'; message: string; retry: () => Promise }
/** The account changed underneath the publication; nothing more will be reported. */
@@ -53,6 +71,7 @@ export interface WorkflowPublicationDeps {
getRecord: typeof getLibraryWorkflowRecord;
invalidateCache: typeof invalidateWorkflowLibraryCache;
reserveId: () => string;
+ setPublic: typeof setLibraryWorkflowPublic;
updateRecord: typeof updateLibraryWorkflow;
}
@@ -61,6 +80,7 @@ const PRODUCTION_DEPS: WorkflowPublicationDeps = {
getRecord: getLibraryWorkflowRecord,
invalidateCache: invalidateWorkflowLibraryCache,
reserveId: createUuid,
+ setPublic: setLibraryWorkflowPublic,
updateRecord: updateLibraryWorkflow,
};
@@ -107,12 +127,14 @@ export const isWorkflowContentEquivalent = (
const toPublished = (
kind: 'created' | 'updated',
- record: WorkflowRecordDTO
+ record: WorkflowRecordDTO,
+ revision = record.revision
): Extract => ({
kind,
libraryWorkflowId: record.workflow_id,
name: record.name,
- revision: record.revision,
+ ...(getWorkflowRecordCategory(record) === 'user' ? { ownerUserId: record.user_id } : {}),
+ revision,
status: 'published',
});
@@ -179,7 +201,7 @@ export const createWorkflowPublicationController = (
return { status: 'cancelled' };
}
- if (result.status === 'published') {
+ if (result.status === 'published' || result.status === 'partial') {
deps.invalidateCache(result.libraryWorkflowId);
}
@@ -204,6 +226,35 @@ export const createWorkflowPublicationController = (
};
const create = (request: WorkflowPublicationRequest, name: string, reservedId: string) => {
+ const retryVisibility = (
+ libraryWorkflowId: string,
+ creationRevision: number
+ ): Promise => {
+ const attempt = (): Promise =>
+ settle(request, async () => {
+ try {
+ return toPublished(
+ 'created',
+ await deps.setPublic(libraryWorkflowId, true, request.owner.signal),
+ creationRevision
+ );
+ } catch (error) {
+ if (!isAccountScopeCurrent(request.owner)) {
+ return { status: 'cancelled' };
+ }
+
+ return {
+ libraryWorkflowId,
+ message: getApiErrorMessage(error, 'The library did not answer.'),
+ retry: attempt,
+ status: 'partial',
+ };
+ }
+ });
+
+ return attempt();
+ };
+
const attempt = (): Promise =>
settle(request, async () => {
try {
@@ -214,6 +265,27 @@ export const createWorkflowPublicationController = (
{ reservedId, signal: request.owner.signal }
);
+ if (request.isPublic === true) {
+ try {
+ return toPublished(
+ 'created',
+ await deps.setPublic(record.workflow_id, true, request.owner.signal),
+ record.revision
+ );
+ } catch (error) {
+ if (!isAccountScopeCurrent(request.owner)) {
+ return { status: 'cancelled' };
+ }
+
+ return {
+ libraryWorkflowId: record.workflow_id,
+ message: getApiErrorMessage(error, 'The library did not answer.'),
+ retry: () => retryVisibility(record.workflow_id, record.revision),
+ status: 'partial',
+ };
+ }
+ }
+
return toPublished('created', record);
} catch (error) {
const refusal = toWorkflowLibraryWriteRefusal(error);
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts
index facf0161f98..5338eeeb138 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
+import type { WorkflowRecordDTO } from './api';
+
import {
buildSavedWorkflowOptions,
+ getSavedWorkflowListItemFromRecord,
getSavedWorkflowPickerOwnedQuery,
getSavedWorkflowPickerSharedQuery,
getSavedWorkflowSelectionState,
@@ -36,6 +39,32 @@ const workflows = [
];
describe('savedWorkflowFieldUtils', () => {
+ it('maps a detail record’s nested category and content into a list item', () => {
+ const detail = {
+ created_at: '2026-10-20T00:00:00Z',
+ is_public: false,
+ name: 'Bundled workflow',
+ revision: 3,
+ updated_at: '2026-10-20T00:00:00Z',
+ user_id: 'system',
+ workflow: {
+ description: 'A bundled workflow',
+ meta: { category: 'default' },
+ name: 'Bundled workflow',
+ tags: 'starter',
+ },
+ workflow_id: 'workflow-default',
+ } satisfies WorkflowRecordDTO;
+
+ expect(getSavedWorkflowListItemFromRecord(detail)).toMatchObject({
+ category: 'default',
+ description: 'A bundled workflow',
+ tags: 'starter',
+ user_id: 'system',
+ workflow_id: 'workflow-default',
+ });
+ });
+
it('builds named picker options and disables incompatible workflows', () => {
expect(buildSavedWorkflowOptions(workflows)).toEqual([
{
diff --git a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts
index 4411d8d23e1..31ad2db0636 100644
--- a/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts
+++ b/invokeai/frontend/webv2/src/features/workflow/data/savedWorkflowFieldUtils.ts
@@ -2,6 +2,8 @@ import type { ComboboxOption } from '@platform/ui';
import type { ListWorkflowsParams, WorkflowCallCompatibility, WorkflowLibraryListItem, WorkflowRecordDTO } from './api';
+import { getWorkflowRecordCategory } from './api';
+
export const MISSING_WORKFLOW_OPTION_VALUE = '__missing_workflow__';
export type SavedWorkflowSelectionState =
@@ -146,20 +148,14 @@ export const getSavedWorkflowDisplayState = (
/** Maps the detail endpoint's full record into the same shape as list results. */
export const getSavedWorkflowListItemFromRecord = (record: WorkflowRecordDTO): WorkflowLibraryListItem => ({
call_saved_workflow_compatibility: record.call_saved_workflow_compatibility,
- category:
- record.category === 'default' ||
- (typeof record.workflow.meta === 'object' &&
- record.workflow.meta !== null &&
- (record.workflow.meta as { category?: unknown }).category === 'default')
- ? 'default'
- : 'user',
+ category: getWorkflowRecordCategory(record) ?? 'user',
created_at: record.created_at,
- description: typeof record.workflow.description === 'string' ? record.workflow.description : record.description,
+ description: typeof record.workflow.description === 'string' ? record.workflow.description : '',
is_public: record.is_public,
name: record.name,
opened_at: record.opened_at,
revision: record.revision,
- tags: typeof record.workflow.tags === 'string' ? record.workflow.tags : record.tags,
+ tags: typeof record.workflow.tags === 'string' ? record.workflow.tags : null,
thumbnail_url: record.thumbnail_url,
updated_at: record.updated_at,
user_id: record.user_id,
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx
index 56319464a87..1b6bf0f5a90 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.browser.test.tsx
@@ -53,9 +53,13 @@ describe('PendingWorkflowLoader', () => {
beforeEach(() => {
addWorkflow = vi.fn();
api.getLibraryWorkflowRecord.mockReset().mockResolvedValue({
+ created_at: '2026-10-20T00:00:00Z',
+ is_public: false,
name: 'Portrait',
revision: 2,
- workflow: { edges: [], name: 'Portrait', nodes: [] },
+ updated_at: '2026-10-20T00:00:00Z',
+ user_id: 'user-a',
+ workflow: { edges: [], meta: { category: 'user' }, name: 'Portrait', nodes: [] },
workflow_id: 'lib-portrait',
});
workflowUiStore.patchSnapshot({ libraryCopyChoice: null, pendingWorkflowLoad: null });
@@ -93,7 +97,7 @@ describe('PendingWorkflowLoader', () => {
await vi.waitFor(() => expect(addWorkflow).toHaveBeenCalledTimes(1));
expect(addWorkflow.mock.calls[0]?.[1]).toMatchObject({
reusePlaceholder: true,
- source: { libraryWorkflowId: 'lib-portrait', revision: 2 },
+ source: { libraryWorkflowId: 'lib-portrait', ownerUserId: 'user-a', revision: 2 },
});
expect(workflowUiStore.getSnapshot().libraryCopyChoice).toBeNull();
});
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx
index 2035bfe1d38..01b46571f93 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/PendingLibraryWorkflowLoader.tsx
@@ -1,4 +1,8 @@
-import { getLibraryWorkflowRecord, touchLibraryWorkflowOpenedAt } from '@features/workflow/data/api';
+import {
+ getLibraryWorkflowRecord,
+ getWorkflowRecordCategory,
+ touchLibraryWorkflowOpenedAt,
+} from '@features/workflow/data/api';
import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates';
import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands';
import { useWorkflowNotifications, useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext';
@@ -52,6 +56,7 @@ export const PendingWorkflowLoader = () => {
let raw: unknown = null;
let label = '';
let libraryRevision: number | null = null;
+ let libraryOwnerUserId: string | undefined;
if (source.kind === 'library') {
const record = await getLibraryWorkflowRecord(source.workflowId, owner.signal);
@@ -60,6 +65,7 @@ export const PendingWorkflowLoader = () => {
raw = { ...record.workflow, id: record.workflow_id };
label = t('commandPalette.workflowLoad.loaded', { name });
libraryRevision = record.revision;
+ libraryOwnerUserId = getWorkflowRecordCategory(record) === 'user' ? record.user_id : undefined;
} else {
raw = source.raw;
label = source.label;
@@ -81,7 +87,13 @@ export const PendingWorkflowLoader = () => {
// An embedded document may carry the id of the record it was saved from; that id is provenance, never
// a write target.
...(source.kind === 'library'
- ? { source: { libraryWorkflowId: source.workflowId, revision: libraryRevision } }
+ ? {
+ source: {
+ libraryWorkflowId: source.workflowId,
+ ...(libraryOwnerUserId ? { ownerUserId: libraryOwnerUserId } : {}),
+ revision: libraryRevision,
+ },
+ }
: {}),
});
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx
index 3d1d1e39374..3980ce21df3 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowMenuItems.browser.test.tsx
@@ -29,6 +29,19 @@ vi.mock('@features/workflow/data/api', async (importOriginal) => ({
getLibraryWorkflowRecord: getLibraryWorkflowRecordMock,
}));
+const identity = vi.hoisted(() => ({
+ session: { multiuserEnabled: false, phase: 'ready', user: null } as {
+ multiuserEnabled: boolean;
+ phase: 'ready';
+ user: { user_id: string } | null;
+ },
+}));
+
+vi.mock('@features/identity', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuthSession: () => identity.session,
+}));
+
vi.mock('./workflowTransfer', () => ({
copyWorkflowJson: copyWorkflowJsonMock,
downloadWorkflowJson: downloadWorkflowJsonMock,
@@ -59,6 +72,8 @@ const TRANSLATIONS: Record = {
'widgets.workflow.updateTemplateWithEllipsis': 'Update library template…',
'workflowLibrary.sourceBundled': 'From a bundled template (read-only)',
'workflowLibrary.sourceNone': 'Not linked to a library template',
+ 'workflowLibrary.sourceRevision': 'Linked to library template at revision {{revision}}',
+ 'workflowLibrary.sourceUnknownRevision': 'Linked to a library template (revision unknown)',
'workflowLibrary.multipleWorkflowReturnNodesForTransfer':
'The workflow must contain exactly one workflow return node before it can be exported or copied.',
};
@@ -68,7 +83,9 @@ vi.mock('react-i18next', () => ({
t: (key: string, options?: Record) =>
key === 'widgets.workflow.linkedTemplate'
? `Linked to library template "${String(options?.name)}"`
- : (TRANSLATIONS[key] ?? key),
+ : key === 'workflowLibrary.sourceRevision'
+ ? `Linked to library template at revision ${String(options?.revision)}`
+ : (TRANSLATIONS[key] ?? key),
}),
}));
@@ -149,6 +166,7 @@ describe('WorkflowMenuItems', () => {
root = createRoot(host);
copyWorkflowJsonMock.mockReset();
downloadWorkflowJsonMock.mockReset();
+ identity.session = { multiuserEnabled: false, phase: 'ready', user: null };
clearWorkflowPublicationIntent();
notifications = { error: vi.fn(), info: vi.fn(), success: vi.fn() };
commands = createCommands();
@@ -258,6 +276,39 @@ describe('WorkflowMenuItems', () => {
kind: 'update-source',
workflowId: 'workflow-1',
});
+
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ await renderMenu(createProjectGraph('workflow-1'), {
+ libraryWorkflowId: 'lib-public-other-account',
+ ownerUserId: 'another-user',
+ revision: 4,
+ });
+ expect(menuItem('Update library template…')).toBeUndefined();
+ const saveAsNew = menuItem('Save to library…');
+ expect(saveAsNew).not.toBeUndefined();
+
+ await act(() => saveAsNew?.click());
+
+ expect(workflowUiStore.getSnapshot().publicationIntent).toEqual({
+ kind: 'save-as-new',
+ workflowId: 'workflow-1',
+ });
+ });
+
+ it('matches persisted source ownership against the current account and fails closed without an owner', async () => {
+ const source = { libraryWorkflowId: 'lib-1', ownerUserId: 'user-a', revision: 4 };
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ await renderMenu(createProjectGraph('workflow-1'), source);
+ expect(menuItem('Update library template…')).not.toBeUndefined();
+
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-b' } };
+ await renderMenu(createProjectGraph('workflow-1'), source);
+ expect(menuItem('Update library template…')).toBeUndefined();
+ expect(menuItem('Save to library…')).not.toBeUndefined();
+
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ await renderMenu(createProjectGraph('workflow-1'), { libraryWorkflowId: 'lib-1', revision: 4 });
+ expect(menuItem('Update library template…')).toBeUndefined();
});
});
@@ -270,6 +321,7 @@ describe('WorkflowHeaderActions', () => {
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
+ identity.session = { multiuserEnabled: false, phase: 'ready', user: null };
clearWorkflowPublicationIntent();
persistence = createMutablePort(SAVED);
});
@@ -368,6 +420,7 @@ describe('WorkflowWidgetLabel', () => {
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
+ identity.session = { multiuserEnabled: false, phase: 'ready', user: null };
getLibraryWorkflowRecordMock.mockReset().mockResolvedValue({ name: 'Portrait template' });
});
@@ -409,6 +462,14 @@ describe('WorkflowWidgetLabel', () => {
expect(linkDescription()).toBe('Linked to a library template');
});
+ it('describes another user’s template as read-only rather than bundled', async () => {
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-b' } };
+ await renderLabel({ libraryWorkflowId: 'library-1', ownerUserId: 'user-a', revision: 4 });
+
+ expect(linkDescription()).toBe('Linked to library template at revision 4');
+ expect(host.querySelector('[data-template-link]')?.getAttribute('data-template-link')).toBe('read-only');
+ });
+
it('reads the linked template’s name only once the name’s tooltip is asked for', async () => {
await renderLabel({ libraryWorkflowId: 'library-1', revision: 2 });
expect(getLibraryWorkflowRecordMock).not.toHaveBeenCalled();
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx
index d4d5a39af07..814b48b2df1 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx
@@ -1,6 +1,7 @@
import type { InvocationTemplate, ProjectWorkflowSource, XYPosition } from '@features/workflow/contracts';
import { Box, HStack, Icon, Menu, Stack, Text, VisuallyHidden } from '@chakra-ui/react';
+import { useAuthSession } from '@features/identity';
import { savedWorkflowDetailQueryOptions } from '@features/workflow/data/savedWorkflowQueries';
import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates';
import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands';
@@ -51,7 +52,7 @@ import { CallSavedWorkflowSyncRuntime } from './editor/CallSavedWorkflowSyncRunt
import { getWorkflowFlowInstance } from './editor/flowInstanceStore';
import { releaseWorkflowViewportsExcept } from './editor/workflowViewportStore';
import { LibraryCopyChoiceHost } from './library/LibraryCopyChoiceDialog';
-import { isUpdatableSource } from './library/projectWorkflowEntries';
+import { isBundledLibraryWorkflowId, isUpdatableSource } from './library/projectWorkflowEntries';
import { WorkflowLibraryDialog } from './library/WorkflowLibraryDialog';
import { WorkflowPublicationHost } from './library/WorkflowPublicationHost';
import { PendingWorkflowLoader } from './PendingLibraryWorkflowLoader';
@@ -91,14 +92,19 @@ const PERSISTENCE_VIEW: Record<
};
/**
- * What the workflow's library link says: the template it updates, a bundled template, or none. The template's name
- * is not part of the project; it is read only once asked for, through the query the update confirmation uses.
+ * What the workflow's library link says: the template it updates, a bundled template, another linked template, or
+ * none. The template's name is not part of the project; it is read only once asked for, through the query the update
+ * confirmation uses.
*/
-const useTemplateLinkDescription = (source: ProjectWorkflowSource | undefined, wantsName: boolean): string => {
+const useTemplateLinkDescription = (
+ source: ProjectWorkflowSource | undefined,
+ wantsName: boolean,
+ session: ReturnType
+): string => {
const { t } = useTranslation();
- const isLinked = isUpdatableSource(source);
+ const isLinked = isUpdatableSource(source, session);
const templateName = useQuery({
- ...savedWorkflowDetailQueryOptions(isLinked ? source.libraryWorkflowId : ''),
+ ...savedWorkflowDetailQueryOptions(isLinked && source ? source.libraryWorkflowId : ''),
enabled: isLinked && wantsName,
select: (record) => record.name,
}).data;
@@ -108,7 +114,11 @@ const useTemplateLinkDescription = (source: ProjectWorkflowSource | undefined, w
? t('widgets.workflow.linkedTemplate', { name: templateName })
: t('widgets.workflow.linkedTemplateUnnamed')
: source
- ? t('workflowLibrary.sourceBundled')
+ ? isBundledLibraryWorkflowId(source.libraryWorkflowId)
+ ? t('workflowLibrary.sourceBundled')
+ : source.revision === null
+ ? t('workflowLibrary.sourceUnknownRevision')
+ : t('workflowLibrary.sourceRevision', { revision: source.revision })
: t('workflowLibrary.sourceNone');
};
@@ -123,8 +133,9 @@ export const WorkflowWidgetLabel = ({ region }: WorkflowWidgetLabelProps) => {
const projectId = useWorkflowProjectSelector((project) => project.id);
const activeWorkflowId = useWorkflowProjectSelector((project) => project.activeWorkflowId);
const source = useWorkflowProjectSelector((project) => project.activeWorkflow.source);
+ const session = useAuthSession();
const [wantsTemplateName, setWantsTemplateName] = useState(false);
- const linkDescription = useTemplateLinkDescription(source, wantsTemplateName);
+ const linkDescription = useTemplateLinkDescription(source, wantsTemplateName, session);
const linkDescriptionId = useId();
const openProjectWorkflows = useCallback(
() => openWorkflowLibraryAtProjectWorkflow(projectId, activeWorkflowId),
@@ -152,9 +163,16 @@ export const WorkflowWidgetLabel = ({ region }: WorkflowWidgetLabelProps) => {
// Center chrome already names the widget; the workflow name opens the project's workflows with this one selected.
const displayName = workflowName || t('widgets.workflow.untitled');
- const isLinked = isUpdatableSource(source);
- // A source that cannot be updated is a bundled template's.
+ const isLinked = isUpdatableSource(source, session);
+ // A source this account cannot update is locked, whether bundled or owned by another account.
const linkIcon = isLinked ? Link2Icon : source ? LockIcon : Link2OffIcon;
+ const linkKind = isLinked
+ ? 'linked'
+ : source
+ ? isBundledLibraryWorkflowId(source.libraryWorkflowId)
+ ? 'bundled'
+ : 'read-only'
+ : 'none';
return (
// Sized by its content, so a narrow center shrinks it and the view menu beside it in proportion; a zero basis
@@ -178,13 +196,7 @@ export const WorkflowWidgetLabel = ({ region }: WorkflowWidgetLabelProps) => {
>
-
+
{linkDescription}
@@ -198,13 +210,14 @@ const LABEL_CLIP_CSS = { overflow: 'clip', overflowClipMargin: '4px' } as const;
/** Entries contributed to the shared widget actions menu. */
export const WorkflowMenuItems = (_props: WorkflowWidgetViewProps) => {
const { t } = useTranslation();
+ const session = useAuthSession();
const { getProjectGraph } = useWorkflowUi();
const { widgets } = useWorkflowHostCommands();
const { createWorkflow } = useProjectGraphCommands();
const notify = useWorkflowNotifications();
const activeWorkflowId = useWorkflowProjectSelector((project) => project.activeWorkflowId);
const source = useWorkflowProjectSelector((project) => project.activeWorkflow.source);
- const canUpdateSource = isUpdatableSource(source);
+ const canUpdateSource = isUpdatableSource(source, session);
const openDetailsPanel = useCallback(() => {
widgets.open({ region: 'left', widgetId: 'workflow' });
@@ -291,8 +304,11 @@ const selectPersistenceView = (persistence: WorkflowProjectPersistence) => ({
export const WorkflowHeaderActions = ({ region }: WorkflowWidgetViewProps) => {
const { t } = useTranslation();
+ const session = useAuthSession();
const activeWorkflowId = useWorkflowProjectSelector((project) => project.activeWorkflowId);
- const canUpdateSource = useWorkflowProjectSelector((project) => isUpdatableSource(project.activeWorkflow.source));
+ const canUpdateSource = useWorkflowProjectSelector((project) =>
+ isUpdatableSource(project.activeWorkflow.source, session)
+ );
const { hasLocalRecovery, status } = useWorkflowPersistenceSelector(selectPersistenceView);
const openAddNode = useCallback(() => setAddNodeOpen(true), []);
const openWorkflowLibrary = useCallback(() => setWorkflowLibraryOpen(true), []);
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx
index 53f379c7a77..62533d2a127 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx
@@ -7,6 +7,10 @@ import type {
import type { WorkflowGraphPreviewPort, WorkflowUiAdapter } from '@features/workflow/ui/WorkflowUiContext';
import { ChakraProvider } from '@chakra-ui/react';
+import {
+ getUnresolvedWorkflowPublication,
+ getWorkflowPublicationController,
+} from '@features/workflow/ui/library/useWorkflowPublication';
import { WorkflowGraphPreviewProvider, WorkflowUiProvider } from '@features/workflow/ui/WorkflowUiContext';
import { createProjectGraph } from '@features/workflow/utility';
import { system } from '@theme/system';
@@ -102,14 +106,29 @@ vi.mock('@features/workflow/react', async (importOriginal) => ({
useInvocationTemplatesSnapshot: () => templatesSnapshotRef.current,
}));
-const { createLibraryWorkflowRecordMock } = vi.hoisted(() => ({
+const identity = vi.hoisted(() => ({
+ session: { multiuserEnabled: false, phase: 'ready', user: null } as {
+ multiuserEnabled: boolean;
+ phase: 'ready';
+ user: { user_id: string } | null;
+ },
+}));
+
+vi.mock('@features/identity', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuthSession: () => identity.session,
+}));
+
+const { createLibraryWorkflowRecordMock, setLibraryWorkflowPublicMock } = vi.hoisted(() => ({
createLibraryWorkflowRecordMock: vi.fn(),
+ setLibraryWorkflowPublicMock: vi.fn(),
}));
// The publication controller reaches the transport through the API module itself, not the queries barrel.
vi.mock('@features/workflow/data/api', async (importOriginal) => ({
...(await importOriginal>()),
createLibraryWorkflowRecord: createLibraryWorkflowRecordMock,
+ setLibraryWorkflowPublic: setLibraryWorkflowPublicMock,
}));
// Supply rendered English strings without starting the HTTP-backed i18n client.
@@ -157,7 +176,13 @@ const TRANSLATIONS: Record = {
'graphPreview.showNode': 'show node',
'graphPreview.thisGraph': 'This graph',
'graphPreview.title': 'Graph preview',
+ 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.',
+ 'workflowLibrary.publicVisibilityPartial':
+ 'The template was saved, but its public visibility could not be confirmed ({{message}}). Retry to make it public.',
+ 'workflowLibrary.publicWorkflow': 'Public workflow',
+ 'workflowLibrary.retry': 'Retry',
'workflowLibrary.saveFailed': 'Failed to save workflow',
+ 'workflowLibrary.saveToLibraryConfirm': 'Save',
'workflowLibrary.saved': 'Workflow saved',
'workflowLibrary.savedCreatedBody': 'Saved "{{name}}" to the library.',
};
@@ -405,6 +430,8 @@ describe('GraphPreviewDialog', () => {
downloads.downloadBlob.mockReset();
downloads.downloadText.mockReset();
createLibraryWorkflowRecordMock.mockReset();
+ setLibraryWorkflowPublicMock.mockReset();
+ identity.session = { multiuserEnabled: false, phase: 'ready', user: null };
fitViewMock.mockClear();
templatesSnapshotRef.current = TEMPLATES_SNAPSHOT;
});
@@ -476,6 +503,17 @@ describe('GraphPreviewDialog', () => {
});
});
+ const deferred = () => {
+ let reject!: (reason?: unknown) => void;
+ let resolve!: (value: T) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+
+ return { promise, reject, resolve };
+ };
+
it('renders summary rows, node count, and the live subtitle', async () => {
await renderDialog(FIXTURE_SOURCE);
@@ -795,12 +833,107 @@ describe('GraphPreviewDialog', () => {
expect(serialized).toMatchObject({ name: 'Generate' });
// A client-reserved id makes a resend after a lost answer idempotent.
expect(options).toMatchObject({ reservedId: expect.any(String) });
+ expect(options).not.toHaveProperty('isPublic');
expect(workflowUiAdapter.notifications.success).toHaveBeenCalledWith(
'Workflow saved',
'Saved "Generate" to the library.'
);
// A preview is not a project workflow, so nothing in the project is pointed at the new template.
expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled();
+ expect(setLibraryWorkflowPublicMock).not.toHaveBeenCalled();
+ });
+
+ it('Open as → Save to workflow library offers the public visibility choice in multiuser mode', async () => {
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ createLibraryWorkflowRecordMock.mockImplementation(
+ (workflow: Record, options: { reservedId?: string }) =>
+ Promise.resolve({
+ name: String(workflow.name),
+ revision: 1,
+ workflow,
+ workflow_id: options.reservedId,
+ is_public: false,
+ })
+ );
+ setLibraryWorkflowPublicMock.mockImplementation((workflowId: string) =>
+ Promise.resolve({ name: 'Generate', revision: 1, workflow: {}, workflow_id: workflowId, is_public: true })
+ );
+
+ await renderDialog(FIXTURE_SOURCE);
+
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+
+ const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]');
+ expect(checkbox).not.toBeNull();
+ expect(checkbox?.checked).toBe(false);
+ await act(() => checkbox?.click());
+ await clickButtonWithText('Save');
+ await flushAsync();
+
+ expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(1);
+ const [, options] = createLibraryWorkflowRecordMock.mock.calls[0] ?? [];
+ expect(options).toMatchObject({ reservedId: expect.any(String) });
+ expect(options).not.toHaveProperty('isPublic');
+ expect(setLibraryWorkflowPublicMock).toHaveBeenCalledWith(
+ (options as { reservedId: string }).reservedId,
+ true,
+ expect.anything()
+ );
+ expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled();
+ });
+
+ it('retries a failed visibility change with the same preview template and public choice', async () => {
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ createLibraryWorkflowRecordMock.mockImplementation(
+ (workflow: Record, options: { reservedId?: string }) =>
+ Promise.resolve({
+ name: String(workflow.name),
+ revision: 1,
+ workflow,
+ workflow_id: options.reservedId,
+ is_public: false,
+ })
+ );
+ setLibraryWorkflowPublicMock
+ .mockRejectedValueOnce(new Error('visibility unavailable'))
+ .mockImplementationOnce((workflowId: string) =>
+ Promise.resolve({ name: 'Generate', revision: 1, workflow: {}, workflow_id: workflowId, is_public: true })
+ );
+
+ await renderDialog(FIXTURE_SOURCE);
+
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+ const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]');
+ expect(checkbox).not.toBeNull();
+ await act(() => checkbox?.click());
+ await clickButtonWithText('Save');
+ await flushAsync();
+
+ const createCall = createLibraryWorkflowRecordMock.mock.calls[0];
+ expect(createCall).toBeDefined();
+ if (!createCall) {
+ throw new Error('Expected the preview workflow to be created');
+ }
+ const reservedId = (createCall[1] as { reservedId: string }).reservedId;
+ expect(setLibraryWorkflowPublicMock).toHaveBeenCalledTimes(1);
+ expect(setLibraryWorkflowPublicMock).toHaveBeenCalledWith(reservedId, true, expect.anything());
+ expect(createCall[1]).not.toHaveProperty('isPublic');
+ expect([...document.querySelectorAll('button')].some((button) => button.textContent?.includes('Retry'))).toBe(true);
+ expect(document.body.textContent).toContain(
+ 'The template was saved, but its public visibility could not be confirmed (visibility unavailable). Retry to make it public.'
+ );
+
+ await clickButtonWithText('Retry');
+ await flushAsync();
+
+ expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(1);
+ expect(setLibraryWorkflowPublicMock.mock.calls.map((call) => call.slice(0, 2))).toEqual([
+ [reservedId, true],
+ [reservedId, true],
+ ]);
+ expect(workflowUiAdapter.commands.setWorkflowSource).not.toHaveBeenCalled();
});
it('Open as → Save to workflow library retries a lost answer once under the same reserved id, never claiming success', async () => {
@@ -843,6 +976,134 @@ describe('GraphPreviewDialog', () => {
expect(item?.getAttribute('aria-disabled')).toBe('true');
});
+ it('disables Fork into new project until invocation templates load, then restores it', async () => {
+ templatesSnapshotRef.current = { error: null, status: 'loading', templates: {} };
+
+ await renderDialog(FIXTURE_SOURCE);
+ await openAsMenu();
+
+ let item = findMenuItemWithText('Fork into new project');
+ expect(item?.getAttribute('aria-disabled')).toBe('true');
+
+ templatesSnapshotRef.current = TEMPLATES_SNAPSHOT;
+ await renderDialog(FIXTURE_SOURCE);
+
+ item = findMenuItemWithText('Fork into new project');
+ expect(item?.getAttribute('aria-disabled')).not.toBe('true');
+ await clickMenuItemWithText('Fork into new project');
+
+ expect(graphPreviewPort.openDocumentInNewProject).toHaveBeenCalledTimes(1);
+ });
+
+ it('discards a preview visibility failure that settles after the preview unmounts', async () => {
+ identity.session = { multiuserEnabled: true, phase: 'ready', user: { user_id: 'user-a' } };
+ const visibilityRequest = deferred>();
+ const publicationController = getWorkflowPublicationController();
+ const publishSpy = vi.spyOn(publicationController, 'publish');
+ createLibraryWorkflowRecordMock.mockImplementation(
+ (_workflow: Record, options: { reservedId?: string }) =>
+ Promise.resolve({
+ name: 'Generate',
+ revision: 1,
+ workflow: {},
+ workflow_id: options.reservedId,
+ is_public: false,
+ })
+ );
+ setLibraryWorkflowPublicMock.mockReturnValue(visibilityRequest.promise);
+
+ await renderDialog(FIXTURE_SOURCE);
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+ const checkbox = document.querySelector('input[type="checkbox"][name="isPublic"]');
+ expect(checkbox).not.toBeNull();
+ await act(() => checkbox?.click());
+ await clickButtonWithText('Save');
+ await flushAsync();
+
+ expect(setLibraryWorkflowPublicMock).toHaveBeenCalledTimes(1);
+ const request = publishSpy.mock.calls[0]?.[0];
+ expect(request).toBeDefined();
+ if (!request) {
+ throw new Error('Expected the preview publication request');
+ }
+
+ await act(() => root.render(null));
+ visibilityRequest.reject(new Error('visibility unavailable'));
+ await flushAsync();
+
+ expect(getUnresolvedWorkflowPublication('project-1', request.workflowId)).toBeNull();
+ publishSpy.mockRestore();
+ });
+
+ it('discards a single-user preview retry failure that settles after the preview unmounts', async () => {
+ const retry = deferred>();
+ const publicationController = getWorkflowPublicationController();
+ const publishSpy = vi.spyOn(publicationController, 'publish');
+ createLibraryWorkflowRecordMock
+ .mockRejectedValueOnce(new Error('first attempt lost'))
+ .mockReturnValueOnce(retry.promise);
+
+ await renderDialog(FIXTURE_SOURCE);
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+ await flushAsync();
+
+ expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(2);
+ const request = publishSpy.mock.calls[0]?.[0];
+ publishSpy.mockRestore();
+ expect(request).toBeDefined();
+ if (!request) {
+ throw new Error('Expected the preview publication request');
+ }
+
+ await act(() => root.render(null));
+ retry.reject(new Error('retry unavailable'));
+ await flushAsync();
+
+ expect(getUnresolvedWorkflowPublication('project-1', request.workflowId)).toBeNull();
+ });
+
+ it('cleans earlier unresolved single-user saves after a later preview save succeeds', async () => {
+ const publicationController = getWorkflowPublicationController();
+ const publishSpy = vi.spyOn(publicationController, 'publish');
+ createLibraryWorkflowRecordMock
+ .mockRejectedValueOnce(new Error('first attempt lost'))
+ .mockRejectedValueOnce(new Error('retry unavailable'))
+ .mockImplementation((workflow: Record, options: { reservedId?: string }) =>
+ Promise.resolve({
+ name: String(workflow.name),
+ revision: 1,
+ workflow,
+ workflow_id: options.reservedId,
+ is_public: true,
+ })
+ );
+
+ await renderDialog(FIXTURE_SOURCE);
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+ await flushAsync();
+
+ const firstRequest = publishSpy.mock.calls[0]?.[0];
+ publishSpy.mockRestore();
+ expect(firstRequest).toBeDefined();
+ if (!firstRequest) {
+ throw new Error('Expected the first preview publication request');
+ }
+
+ await openAsMenu();
+ await clickMenuItemWithText('Save to workflow library');
+ await flushAsync();
+
+ expect(createLibraryWorkflowRecordMock).toHaveBeenCalledTimes(3);
+ expect(workflowUiAdapter.notifications.success).toHaveBeenCalledTimes(1);
+
+ await act(() => root.render(null));
+
+ expect(getUnresolvedWorkflowPublication('project-1', firstRequest.workflowId)).toBeNull();
+ });
+
it('truncates a long resolved-input string and exposes the full value via title', async () => {
await renderDialog(LONG_VALUE_SOURCE);
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx
index ec88a083be0..17cf10c6bf1 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewOpenAsMenu.tsx
@@ -1,24 +1,40 @@
+import type { ProjectGraphState } from '@features/workflow/core/types';
import type { WorkflowInvocationSourceId, WorkflowPreviewGraph } from '@features/workflow/ui/contracts';
-import type { ReactNode } from 'react';
-import { Menu, Portal } from '@chakra-ui/react';
+import { Checkbox, chakra, Input, Menu, Portal, Stack, Text } from '@chakra-ui/react';
+import { useAuthSession } from '@features/identity';
import { previewGraphToDocument } from '@features/workflow/core/graphToDocument';
import { useInvocationTemplatesSnapshot } from '@features/workflow/react';
-import { useWorkflowPublication } from '@features/workflow/ui/library/useWorkflowPublication';
+import {
+ discardUnresolvedWorkflowPublication,
+ useWorkflowPublication,
+ type WorkflowPublicationFailure,
+} from '@features/workflow/ui/library/useWorkflowPublication';
import { MenuActionItem } from '@features/workflow/ui/MenuActionItem';
import {
useWorkflowGraphPreview,
useWorkflowHostCommands,
useWorkflowNotifications,
+ useWorkflowUi,
} from '@features/workflow/ui/WorkflowUiContext';
import { downloadText } from '@platform/browser/downloadBlob';
+import { useMountEffect } from '@platform/react/useMountEffect';
+import { Button, CloseButton } from '@platform/ui/Button';
+import { Dialog } from '@platform/ui/Dialog';
+import { Field } from '@platform/ui/Field';
import { MenuContent } from '@platform/ui/Menu';
import { BookmarkIcon, DownloadIcon, GitForkIcon, PencilRulerIcon } from 'lucide-react';
-import { useCallback } from 'react';
+import { useCallback, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
const MENU_POSITIONING = { placement: 'top-end' } as const;
+interface PreviewSaveRequest {
+ document: ProjectGraphState;
+ failure?: WorkflowPublicationFailure;
+ name: string;
+}
+
interface GraphPreviewOpenAsMenuProps {
children: ReactNode;
graph: WorkflowPreviewGraph;
@@ -40,10 +56,17 @@ export const GraphPreviewOpenAsMenu = ({
onClose,
}: GraphPreviewOpenAsMenuProps) => {
const { t } = useTranslation();
+ const session = useAuthSession();
const { workflows } = useWorkflowHostCommands();
+ const { project } = useWorkflowUi();
const graphPreview = useWorkflowGraphPreview();
const notifications = useWorkflowNotifications();
- const { saveDocumentAsNew } = useWorkflowPublication();
+ const { retry: retryPublication, saveDocumentAsNew } = useWorkflowPublication();
+ const [saveRequest, setSaveRequest] = useState(null);
+ const [isSavePending, setIsSavePending] = useState(false);
+ const activePublicationRef = useRef<{ projectId: string; workflowId: string } | null>(null);
+ const singleUserPublicationTargetsRef = useRef(new Set<{ projectId: string; workflowId: string }>());
+ const isMountedRef = useRef(false);
// A hook (not `getInvocationTemplatesSnapshot()`) so the "Edit in workflow
// editor" item's disabled state updates live if the menu is opened while
// templates are still loading.
@@ -51,6 +74,24 @@ export const GraphPreviewOpenAsMenu = ({
const canEditInEditor = sourceId !== 'workflow';
const isTemplatesLoaded = templatesSnapshot.status === 'loaded';
+ useMountEffect(() => {
+ isMountedRef.current = true;
+
+ return () => {
+ isMountedRef.current = false;
+ const activePublication = activePublicationRef.current;
+
+ if (activePublication) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ }
+
+ singleUserPublicationTargetsRef.current.forEach(({ projectId, workflowId }) => {
+ discardUnresolvedWorkflowPublication(projectId, workflowId);
+ });
+ singleUserPublicationTargetsRef.current.clear();
+ };
+ });
+
const handleEditInEditor = useCallback(() => {
const { document, skippedNodeTypes } = previewGraphToDocument(graph, templatesSnapshot.templates);
@@ -87,19 +128,125 @@ export const GraphPreviewOpenAsMenu = ({
document.name = graph.label ?? sourceLabel;
- // The publication reports its own outcome; a lost answer is retried by the same captured request.
+ if (session.multiuserEnabled) {
+ setSaveRequest({ document, name: document.name });
+ return;
+ }
+
+ // Single-user saves keep the backend's public-by-default behavior and retry a lost answer once.
+ const activePublication = { projectId: project.getSnapshot().id, workflowId: document.id };
+ singleUserPublicationTargetsRef.current.add(activePublication);
const result = await saveDocumentAsNew(document, document.name);
+ if (!isMountedRef.current) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ singleUserPublicationTargetsRef.current.delete(activePublication);
+ return;
+ }
+
if (result.status === 'failed') {
const retried = await result.retry();
+ if (!isMountedRef.current) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ singleUserPublicationTargetsRef.current.delete(activePublication);
+ return;
+ }
+
if (retried.status === 'failed') {
notifications.error(t('graphPreview.saveToLibraryFailed'), retried.message);
+ } else {
+ singleUserPublicationTargetsRef.current.delete(activePublication);
}
+ } else {
+ singleUserPublicationTargetsRef.current.delete(activePublication);
}
- }, [graph, templatesSnapshot, sourceLabel, saveDocumentAsNew, notifications, t]);
+ }, [graph, templatesSnapshot, sourceLabel, saveDocumentAsNew, notifications, project, session.multiuserEnabled, t]);
const handleSaveToLibrary = useCallback(() => void saveToLibrary(), [saveToLibrary]);
+ const closeSaveDialog = useCallback(() => {
+ const activePublication = activePublicationRef.current;
+
+ if (saveRequest?.failure && activePublication) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ }
+
+ activePublicationRef.current = null;
+ setSaveRequest(null);
+ }, [saveRequest]);
+
+ const handleSaveSubmit = useCallback(
+ async (event: FormEvent) => {
+ event.preventDefault();
+ if (!saveRequest) {
+ return;
+ }
+
+ const formData = new FormData(event.currentTarget);
+ const name = formData.get('templateName')?.toString().trim() || t('workflowLibrary.untitled');
+ const isPublic = formData.has('isPublic');
+ const activePublication = { projectId: project.getSnapshot().id, workflowId: saveRequest.document.id };
+
+ activePublicationRef.current = activePublication;
+ setIsSavePending(true);
+ try {
+ const result = await saveDocumentAsNew(saveRequest.document, name, isPublic);
+
+ if (!isMountedRef.current) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ return;
+ }
+
+ if (result.status === 'failed' || result.status === 'partial') {
+ setSaveRequest({ ...saveRequest, failure: result, name });
+ } else {
+ activePublicationRef.current = null;
+ setSaveRequest(null);
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setIsSavePending(false);
+ }
+ }
+ },
+ [project, saveDocumentAsNew, saveRequest, t]
+ );
+
+ const handleSaveRetry = useCallback(async () => {
+ if (!saveRequest?.failure) {
+ return;
+ }
+
+ const activePublication = activePublicationRef.current;
+ setIsSavePending(true);
+ try {
+ const result = await retryPublication(saveRequest.failure);
+
+ if (!isMountedRef.current) {
+ if (activePublication) {
+ discardUnresolvedWorkflowPublication(activePublication.projectId, activePublication.workflowId);
+ }
+ return;
+ }
+
+ if (result.status === 'failed' || result.status === 'partial') {
+ setSaveRequest({ ...saveRequest, failure: result });
+ } else {
+ activePublicationRef.current = null;
+ setSaveRequest(null);
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setIsSavePending(false);
+ }
+ }
+ }, [retryPublication, saveRequest]);
+
+ const handleSaveDialogOpenChange = useCallback(
+ (event: { open: boolean }) => (event.open ? undefined : closeSaveDialog()),
+ [closeSaveDialog]
+ );
+
const handleForkIntoProject = useCallback(() => {
const { document, skippedNodeTypes } = previewGraphToDocument(graph, templatesSnapshot.templates);
@@ -128,47 +275,122 @@ export const GraphPreviewOpenAsMenu = ({
}, [graph]);
return (
-
- {children}
-
-
-
- {canEditInEditor ? (
+ <>
+
+ {children}
+
+
+
+ {canEditInEditor ? (
+
+ ) : null}
- ) : null}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('workflowLibrary.saveToLibraryTitle')}
+
+
+ {saveRequest?.failure ? (
+
+ {saveRequest.failure.status === 'partial'
+ ? t('workflowLibrary.publicVisibilityPartial', { message: saveRequest.failure.message })
+ : t('workflowLibrary.retryBody', {
+ message: saveRequest.failure.message,
+ name: saveRequest.name,
+ })}
+
+ ) : (
+
+
+
+
+
+ {t('workflowLibrary.saveToLibraryExplanation')}
+
+
+
+
+
+
+ {t('workflowLibrary.publicWorkflow')}
+
+ {t('workflowLibrary.publicVisibilityHelp')}
+
+
+
+
+
+ )}
+
+
+
+ {saveRequest?.failure ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ >
);
};
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx
index a7f335a0966..2c4d47dc494 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx
@@ -1,6 +1,7 @@
import type { ProjectWorkflowEntry } from '@features/workflow/core/types';
import { Badge, Box, Flex, HStack, Icon, Menu, Portal, Stack, Text } from '@chakra-ui/react';
+import { useAuthSession } from '@features/identity';
import { useInvocationTemplatesSnapshot } from '@features/workflow/react';
import { MenuActionItem } from '@features/workflow/ui/MenuActionItem';
import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands';
@@ -235,6 +236,7 @@ const ProjectWorkflowDetailPanel = ({
onRename,
}: ProjectWorkflowDetailPanelProps) => {
const { t } = useTranslation();
+ const session = useAuthSession();
const deps = useModelRequirementDeps();
const templatesSnapshot = useInvocationTemplatesSnapshot();
const openAddModels = useOpenAddModels();
@@ -322,7 +324,7 @@ const ProjectWorkflowDetailPanel = ({
const showThumbnail = Boolean(thumbnailUrl) && thumbnailUrl !== failedThumbnailUrl;
const lastRun = entry.lastRun ? formatRelativeTime(entry.lastRun.completedAt, new Date()) : '';
const source = entry.source;
- const canUpdateSource = isUpdatableSource(source);
+ const canUpdateSource = isUpdatableSource(source, session);
const actionItems = (
<>
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx
index 99307fc6ed1..d12ff8b9361 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx
@@ -74,6 +74,8 @@ export interface WorkflowLibraryCardProps {
/** Which of the tile's controls has the actions menu open, if any; that control names the menu for the dialog. */
menuOpenedBy?: 'card' | 'button' | null;
isSelected: boolean;
+ /** Project workflows reuse this card, but have no library visibility state. */
+ showVisibilityStatus?: boolean;
/** Models this workflow needs that are not installed; 0 hides the badge. */
missingCount: number;
onContextMenu: (workflowId: string, anchor: WorkflowCardMenuAnchor) => void;
@@ -90,6 +92,7 @@ export const WorkflowLibraryCard = ({
onContextMenu,
onOpen,
onSelect,
+ showVisibilityStatus = false,
}: WorkflowLibraryCardProps) => {
const { t } = useTranslation();
const [hasThumbnailFailed, setHasThumbnailFailed] = useState(false);
@@ -168,6 +171,15 @@ export const WorkflowLibraryCard = ({
+ {showVisibilityStatus && item.category === 'user' ? (
+
+ {t(item.is_public === true ? 'workflowLibrary.public' : 'workflowLibrary.private')}
+
+ ) : null}
{isActive ? (
{t('workflowLibrary.activeWorkflow')}
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx
index b126222e8c7..7f49d3f41c1 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.browser.test.tsx
@@ -10,11 +10,13 @@ import { ChakraProvider } from '@chakra-ui/react';
import { GalleryHostProvider } from '@features/gallery/picker';
import { WorkflowGraphPreviewProvider, WorkflowUiProvider } from '@features/workflow/ui/WorkflowUiContext';
import { createProjectGraph, serializeWorkflowJson } from '@features/workflow/utility';
+import { accountLifecycle } from '@platform/state/accountLifecycle';
import { closingFrames, recordDialogExit } from '@platform/ui/dialogExit.testing';
import { system } from '@theme/system';
import { act, StrictMode, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
import { loadGraphPreview } from './libraryPreviewSource';
import { WorkflowLibraryDetailPanel } from './WorkflowLibraryDetailPanel';
@@ -77,6 +79,9 @@ const queries = vi.hoisted(() => ({
),
invalidateWorkflowLibraryCache: vi.fn(),
setLibraryWorkflowThumbnail: vi.fn((_workflowId: string, _image: Blob, _signal?: AbortSignal) => Promise.resolve()),
+ setLibraryWorkflowPublic: vi.fn((_workflowId: string, isPublic: boolean, _signal?: AbortSignal) =>
+ Promise.resolve({ is_public: isPublic })
+ ),
updateLibraryWorkflow: vi.fn(
(_workflowId: string, _workflow: Record, _options?: { expectedRevision?: number }) =>
Promise.resolve({} as Record)
@@ -183,6 +188,13 @@ const TRANSLATIONS: Record = {
'workflowLibrary.requires': 'Requires',
'workflowLibrary.sampleOutput': 'Sample output',
'workflowLibrary.untitled': 'Untitled Workflow',
+ 'workflowLibrary.private': 'Private',
+ 'workflowLibrary.public': 'Public',
+ 'workflowLibrary.publicVisibilityHelp': 'Visible to users of this InvokeAI instance.',
+ 'workflowLibrary.publicWorkflow': 'Public workflow',
+ 'workflowLibrary.visibilityUpdateFailed': 'Failed to update workflow visibility',
+ 'workflowLibrary.visibilityUnknown': 'Visibility unknown',
+ 'workflowLibrary.retry': 'Retry',
};
const interpolate = (template: string, options?: Record): string =>
@@ -309,6 +321,11 @@ const IMAGE_TO_VIDEO = entry(
['Wan 2.2']
);
+const privateWorkflowEntry = (base: WorkflowLibraryEntry, workflowId: string): WorkflowLibraryEntry => ({
+ ...base,
+ item: { ...base.item, is_public: false, user_id: 'user-a', workflow_id: workflowId },
+});
+
const DEFAULT_CATEGORY = entry(
{ category: 'default', name: 'Bundled default', workflow_id: 'wf-default' },
readyEnrichment(SDXL_REQUIREMENTS)
@@ -322,9 +339,14 @@ const RAW_WORKFLOW: Record = {
/** The library record behind IMAGE_TO_VIDEO, at the revision a fork must carry along. */
const RAW_RECORD = {
- ...IMAGE_TO_VIDEO.item,
+ created_at: '2026-10-20T00:00:00Z',
+ is_public: false,
+ name: 'Image to video',
revision: 7,
- workflow: RAW_WORKFLOW,
+ updated_at: '2026-10-20T00:00:00Z',
+ user_id: 'owner-a',
+ workflow: { ...RAW_WORKFLOW, meta: { category: 'user', version: '3.0.0' } },
+ workflow_id: 'wf-image-to-video',
};
/** Project copies of TEXT_TO_IMAGE: once one exists, Open asks instead of adding another. */
@@ -364,7 +386,11 @@ describe('WorkflowLibraryDetailPanel', () => {
setTimeout(resolve, 0);
});
- const panelTree = (selected: WorkflowLibraryEntry | null, projectWorkflows: readonly ProjectWorkflowEntry[]) => (
+ const panelTree = (
+ selected: WorkflowLibraryEntry | null,
+ projectWorkflows: readonly ProjectWorkflowEntry[],
+ canChangeVisibility = false
+ ) => (
@@ -374,6 +400,7 @@ describe('WorkflowLibraryDetailPanel', () => {
contextMenuPoint={null}
contextMenuTriggerId={null}
entry={selected}
+ canChangeVisibility={canChangeVisibility}
projectWorkflows={projectWorkflows}
onClose={onClose}
onContextMenuClose={NOOP}
@@ -391,15 +418,23 @@ describe('WorkflowLibraryDetailPanel', () => {
const renderPanel = async (
selected: WorkflowLibraryEntry | null,
- projectWorkflows: readonly ProjectWorkflowEntry[] = NO_COPIES
+ projectWorkflows: readonly ProjectWorkflowEntry[] = NO_COPIES,
+ canChangeVisibility = false
) => {
await act(async () => {
- root.render(panelTree(selected, projectWorkflows));
+ root.render(panelTree(selected, projectWorkflows, canChangeVisibility));
await settleFrame();
});
};
const panel = () => document.querySelector('[data-workflow-detail]');
+ const toggleVisibility = async () => {
+ await act(async () => {
+ panel()?.querySelector('input[type="checkbox"]')?.focus();
+ await userEvent.keyboard(' ');
+ await settleFrame();
+ });
+ };
const buttonWithText = (text: string) =>
[...document.querySelectorAll('button')].find((candidate) => (candidate.textContent ?? '').trim() === text);
const requirementRows = () => [...document.querySelectorAll('[data-requirement-status]')];
@@ -453,6 +488,7 @@ describe('WorkflowLibraryDetailPanel', () => {
onDuplicated = vi.fn((_workflowId: string) => {});
onOpen = vi.fn();
onPreview = vi.fn();
+ accountLifecycle.invalidate();
models.activeInstallSources.current = new Set();
models.installedModels.current = [INSTALLED_SDXL_MAIN, INSTALLED_WAN_VAE];
@@ -467,11 +503,15 @@ describe('WorkflowLibraryDetailPanel', () => {
queries.deleteLibraryWorkflow.mockResolvedValue(undefined);
queries.getLibraryWorkflowCached.mockClear();
queries.getLibraryWorkflowCached.mockResolvedValue(RAW_WORKFLOW);
+ queries.getLibraryWorkflowRecord.mockReset().mockResolvedValue({} as Record);
queries.getLibraryWorkflowRecordCached.mockClear();
queries.getLibraryWorkflowRecordCached.mockResolvedValue(RAW_RECORD);
queries.invalidateWorkflowLibraryCache.mockClear();
queries.setLibraryWorkflowThumbnail.mockReset().mockResolvedValue(undefined);
queries.deleteLibraryWorkflowThumbnail.mockReset().mockResolvedValue(undefined);
+ queries.setLibraryWorkflowPublic
+ .mockReset()
+ .mockImplementation((_workflowId, isPublic) => Promise.resolve({ is_public: isPublic }));
downloadText.mockClear();
OPEN_DOCUMENT_IN_NEW_PROJECT.mockClear();
@@ -500,6 +540,350 @@ describe('WorkflowLibraryDetailPanel', () => {
expect(models.ensureStartersLoaded).toHaveBeenCalled();
});
+ it('changes visibility by keyboard, stays server-driven while pending, then reflects the response and invalidates', async () => {
+ let resolveChange: (record: { is_public: boolean }) => void = NOOP;
+ queries.setLibraryWorkflowPublic.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveChange = resolve;
+ })
+ );
+ const privateEntry = {
+ ...TEXT_TO_IMAGE,
+ item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' },
+ };
+
+ await renderPanel(privateEntry, NO_COPIES, true);
+
+ const control = panel()?.querySelector('input[type="checkbox"]');
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false);
+ expect(control?.labels?.[0]?.textContent).toBe('Public workflow');
+ expect(panel()?.textContent).toContain('Private');
+ expect(panel()?.textContent).toContain('Visible to users of this InvokeAI instance.');
+
+ await act(async () => {
+ control?.focus();
+ await userEvent.keyboard(' ');
+ await settleFrame();
+ });
+
+ expect(queries.setLibraryWorkflowPublic).toHaveBeenCalledWith('wf-text-to-image', true, expect.anything());
+ expect(control?.disabled).toBe(true);
+ expect(control?.checked).toBe(false);
+ expect(panel()?.textContent).toContain('Private');
+ expect(queries.invalidateWorkflowLibraryCache).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveChange({ is_public: true });
+ await settleFrame();
+ });
+
+ const updatedControl = panel()?.querySelector('input[type="checkbox"]');
+ expect(updatedControl?.disabled).toBe(false);
+ expect(updatedControl?.checked).toBe(true);
+ expect(document.activeElement).toBe(updatedControl);
+ expect(panel()?.textContent).toContain('Public');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+ expect(queries.updateLibraryWorkflow).not.toHaveBeenCalled();
+ });
+
+ it('keeps overlapping visibility requests and successful values scoped per workflow', async () => {
+ let resolveA: (record: { is_public: boolean }) => void = NOOP;
+ let resolveB: (record: { is_public: boolean }) => void = NOOP;
+ queries.setLibraryWorkflowPublic.mockImplementation(
+ (workflowId) =>
+ new Promise((resolve) => {
+ if (workflowId === 'wf-a') {
+ resolveA = resolve;
+ } else {
+ resolveB = resolve;
+ }
+ })
+ );
+ const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a');
+ const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b');
+
+ await renderPanel(privateA, NO_COPIES, true);
+ await toggleVisibility();
+
+ await renderPanel(privateB, NO_COPIES, true);
+ await toggleVisibility();
+
+ await act(async () => {
+ resolveA({ is_public: true });
+ await settleFrame();
+ });
+ expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(true);
+
+ await act(async () => {
+ resolveB({ is_public: true });
+ await settleFrame();
+ });
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+
+ await renderPanel(privateA, NO_COPIES, true);
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+ await renderPanel(privateB, NO_COPIES, true);
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+ });
+
+ it('keeps a successful visibility response when a later request for another workflow completes', async () => {
+ let resolveA: (record: { is_public: boolean }) => void = NOOP;
+ let resolveB: (record: { is_public: boolean }) => void = NOOP;
+ queries.setLibraryWorkflowPublic.mockImplementation(
+ (workflowId) =>
+ new Promise((resolve) => {
+ if (workflowId === 'wf-a') {
+ resolveA = resolve;
+ } else {
+ resolveB = resolve;
+ }
+ })
+ );
+ const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a');
+ const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b');
+
+ await renderPanel(privateA, NO_COPIES, true);
+ await toggleVisibility();
+ await renderPanel(privateB, NO_COPIES, true);
+ await toggleVisibility();
+
+ await act(async () => {
+ resolveB({ is_public: true });
+ await settleFrame();
+ });
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+
+ await act(async () => {
+ resolveA({ is_public: true });
+ await settleFrame();
+ });
+ await renderPanel(privateB, NO_COPIES, true);
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+ });
+
+ it("preserves another workflow's visibility error when an older request succeeds", async () => {
+ let resolveA: (record: { is_public: boolean }) => void = NOOP;
+ let rejectB: (error: Error) => void = NOOP;
+ queries.setLibraryWorkflowPublic.mockImplementation(
+ (workflowId) =>
+ new Promise((resolve, reject) => {
+ if (workflowId === 'wf-a') {
+ resolveA = resolve;
+ } else {
+ rejectB = reject;
+ }
+ })
+ );
+ queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed'));
+ const privateA = privateWorkflowEntry(TEXT_TO_IMAGE, 'wf-a');
+ const privateB = privateWorkflowEntry(IMAGE_TO_VIDEO, 'wf-b');
+
+ await renderPanel(privateA, NO_COPIES, true);
+ await toggleVisibility();
+ await renderPanel(privateB, NO_COPIES, true);
+ await toggleVisibility();
+
+ expect(queries.setLibraryWorkflowPublic.mock.calls.map(([workflowId]) => workflowId)).toEqual(['wf-a', 'wf-b']);
+ await act(async () => {
+ rejectB(new Error('Permission denied'));
+ await settleFrame();
+ });
+ expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied');
+
+ await act(async () => {
+ resolveA({ is_public: true });
+ await settleFrame();
+ });
+ expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied');
+ });
+
+ it('shows visibility as unknown when the write and its readback both fail', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied'));
+ queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed'));
+ const privateEntry = {
+ ...TEXT_TO_IMAGE,
+ item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' },
+ };
+
+ await renderPanel(privateEntry, NO_COPIES, true);
+ const control = panel()?.querySelector('input[type="checkbox"]');
+
+ await act(async () => {
+ control?.focus();
+ await userEvent.keyboard(' ');
+ await settleFrame();
+ });
+
+ expect(control?.checked).toBe(false);
+ expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull();
+ expect(panel()?.textContent).toContain('Visibility unknown');
+ expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+ });
+
+ it('reconciles a rejected visibility write from the server and allows another toggle', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out'));
+ queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: true } as Record);
+ const privateEntry = { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } };
+
+ await renderPanel(privateEntry, NO_COPIES, true);
+ await toggleVisibility();
+
+ const publicControl = panel()?.querySelector('input[type="checkbox"]');
+ expect(publicControl?.checked).toBe(true);
+ expect(publicControl?.disabled).toBe(false);
+ expect(panel()?.textContent).toContain('Public');
+ expect(queries.getLibraryWorkflowRecord).toHaveBeenCalledWith('wf-text-to-image', expect.anything());
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledWith('wf-text-to-image');
+
+ await toggleVisibility();
+ expect(queries.setLibraryWorkflowPublic).toHaveBeenLastCalledWith('wf-text-to-image', false, expect.anything());
+ });
+
+ it('keeps an unchanged authoritative value and explains a rejected visibility write', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Permission denied'));
+ queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: false } as Record);
+ await renderPanel(
+ { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } },
+ NO_COPIES,
+ true
+ );
+ await toggleVisibility();
+
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false);
+ expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(false);
+ expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Permission denied');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+ });
+
+ it('shows unknown disabled visibility and retries reconciliation when both write and readback fail', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out'));
+ queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed'));
+ await renderPanel(
+ { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } },
+ NO_COPIES,
+ true
+ );
+ await toggleVisibility();
+
+ expect(panel()?.textContent).toContain('Visibility unknown');
+ expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull();
+ expect(panel()?.querySelector('[role="alert"]')?.textContent).toContain('Request timed out');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+
+ queries.getLibraryWorkflowRecord.mockResolvedValueOnce({ is_public: true } as Record);
+ await act(async () => {
+ await userEvent.click(buttonWithText('Retry')!);
+ await settleFrame();
+ });
+
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+ expect(panel()?.querySelector('input[type="checkbox"]')?.disabled).toBe(false);
+ expect(panel()?.textContent).toContain('Public');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps replacement entries unknown and hides the stale switch until retry succeeds', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out'));
+ queries.getLibraryWorkflowRecord.mockRejectedValueOnce(new Error('Read failed'));
+ const privateEntry = { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } };
+ await renderPanel(privateEntry, NO_COPIES, true);
+ await toggleVisibility();
+
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+ await renderPanel({ ...privateEntry, item: { ...privateEntry.item, is_public: true } }, NO_COPIES, true);
+
+ expect(panel()?.querySelector('[data-workflow-visibility="unknown"]')?.textContent).toBe('Visibility unknown');
+ expect(panel()?.querySelector('input[type="checkbox"]')).toBeNull();
+ expect(buttonWithText('Retry')).toBeDefined();
+ });
+
+ it('returns focus to the visibility switch after a keyboard retry succeeds', async () => {
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out'));
+ queries.getLibraryWorkflowRecord
+ .mockRejectedValueOnce(new Error('Read failed'))
+ .mockResolvedValueOnce({ is_public: true } as Record);
+ await renderPanel(
+ { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } },
+ NO_COPIES,
+ true
+ );
+ const initialControl = panel()?.querySelector('input[type="checkbox"]');
+ initialControl?.focus();
+ await act(async () => {
+ await userEvent.keyboard(' ');
+ await settleFrame();
+ });
+
+ const retry = buttonWithText('Retry');
+ retry?.focus();
+ await act(async () => {
+ await userEvent.keyboard('{Enter}');
+ await settleFrame();
+ });
+
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(true);
+ expect(document.activeElement).toBe(panel()?.querySelector('input[type="checkbox"]'));
+ });
+
+ it('ignores a visibility readback that resolves after the account lifecycle rotates', async () => {
+ let resolveReadback: (record: { is_public: boolean }) => void = NOOP;
+ queries.setLibraryWorkflowPublic.mockRejectedValueOnce(new Error('Request timed out'));
+ queries.getLibraryWorkflowRecord.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveReadback = resolve;
+ })
+ );
+ try {
+ await renderPanel(
+ { ...TEXT_TO_IMAGE, item: { ...TEXT_TO_IMAGE.item, is_public: false, user_id: 'user-a' } },
+ NO_COPIES,
+ true
+ );
+ await toggleVisibility();
+ accountLifecycle.invalidate();
+
+ await act(async () => {
+ resolveReadback({ is_public: true });
+ await settleFrame();
+ });
+
+ expect(panel()?.querySelector('input[type="checkbox"]')?.checked).toBe(false);
+ expect(panel()?.querySelector('[data-workflow-visibility="private"]')?.textContent).toBe('Private');
+ } finally {
+ accountLifecycle.invalidate();
+ }
+ });
+
+ it('returns a public workflow to Private and invalidates its library caches', async () => {
+ queries.setLibraryWorkflowPublic.mockResolvedValueOnce({ is_public: false });
+ const publicEntry = {
+ ...TEXT_TO_IMAGE,
+ item: { ...TEXT_TO_IMAGE.item, is_public: true, user_id: 'user-a' },
+ };
+
+ await renderPanel(publicEntry, NO_COPIES, true);
+ const control = panel()?.querySelector('input[type="checkbox"]');
+ expect(control?.checked).toBe(true);
+
+ await act(async () => {
+ control?.focus();
+ await userEvent.keyboard(' ');
+ await settleFrame();
+ });
+ await vi.waitFor(() =>
+ expect(queries.setLibraryWorkflowPublic).toHaveBeenCalledWith('wf-text-to-image', false, expect.anything())
+ );
+
+ const updatedControl = panel()?.querySelector('input[type="checkbox"]');
+ expect(updatedControl?.checked).toBe(false);
+ expect(document.activeElement).toBe(updatedControl);
+ expect(panel()?.textContent).toContain('Private');
+ expect(queries.invalidateWorkflowLibraryCache).toHaveBeenCalledExactlyOnceWith('wf-text-to-image');
+ });
+
it('offers Open as the primary action when every requirement is installed', async () => {
await renderPanel(TEXT_TO_IMAGE);
@@ -732,12 +1116,27 @@ describe('WorkflowLibraryDetailPanel', () => {
expect(document_.name).toBe('Image to video');
expect(label).toBe('Image to video');
// The copy can update its template later only if it knows which revision it started from.
- expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', revision: 7 });
+ expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', ownerUserId: 'owner-a', revision: 7 });
// The fork lands the user in a new project, so the library gets out of the way.
expect(onClose).toHaveBeenCalledTimes(1);
expect(queries.createLibraryWorkflow).not.toHaveBeenCalled();
});
+ it('carries stable ownership when forking a Shared public workflow', async () => {
+ queries.getLibraryWorkflowRecordCached.mockResolvedValueOnce({
+ ...RAW_RECORD,
+ is_public: true,
+ user_id: 'owner-a',
+ });
+ await renderPanel(IMAGE_TO_VIDEO);
+
+ await clickMenuItem('fork-into-project');
+
+ const [, , source] = OPEN_DOCUMENT_IN_NEW_PROJECT.mock.calls[0] as [unknown, string, unknown];
+
+ expect(source).toEqual({ libraryWorkflowId: 'wf-image-to-video', ownerUserId: 'owner-a', revision: 7 });
+ });
+
it('downloads the raw workflow JSON under a slugged file name', async () => {
await renderPanel(IMAGE_TO_VIDEO);
@@ -860,7 +1259,6 @@ describe('WorkflowLibraryDetailPanel', () => {
})
)
);
-
expect(onDeleted).toHaveBeenCalledTimes(1);
expect(panel()).toBeNull();
expect(frames).not.toHaveLength(0);
diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx
index 92476157a73..f43d1bc1ce2 100644
--- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx
+++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx
@@ -3,8 +3,9 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types';
import type { WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore';
import type { WorkflowLibraryListItem } from '@features/workflow/queries';
-import { Badge, Box, HStack, Menu, Portal, Stack, Text } from '@chakra-ui/react';
+import { Badge, Box, HStack, Menu, Portal, Stack, Switch, Text } from '@chakra-ui/react';
import { getStarterModelInstallSources, useInstallActions } from '@features/models';
+import { getWorkflowRecordCategory } from '@features/workflow/data/api';
import {
createLibraryWorkflow,
deleteLibraryWorkflow,
@@ -12,6 +13,7 @@ import {
getLibraryWorkflowRecord,
getLibraryWorkflowRecordCached,
invalidateWorkflowLibraryCache,
+ setLibraryWorkflowPublic,
updateLibraryWorkflow,
} from '@features/workflow/queries';
import { MenuActionItem } from '@features/workflow/ui/MenuActionItem';
@@ -39,7 +41,7 @@ import {
useTooltipTriggerIds,
} from '@platform/ui';
import { CopyIcon, DownloadIcon, EllipsisIcon, GitForkIcon, PencilIcon, Trash2Icon, WorkflowIcon } from 'lucide-react';
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { planLibraryWorkflowOpen } from './useOpenLibraryWorkflow';
@@ -61,12 +63,26 @@ import {
const DETAIL_RAIL_WIDTH = '18rem';
const INSTALL_HOVER = { opacity: 0.85 } as const;
+type VisibilityState =
+ | { requestId: object; previousItem: WorkflowLibraryListItem; isPublic: boolean; status: 'pending' | 'success' }
+ | {
+ requestId: object;
+ previousItem: WorkflowLibraryListItem;
+ isPublic: boolean;
+ message: string;
+ status: 'error' | 'unknown' | 'reconciling';
+ };
+
export interface WorkflowLibraryDetailPanelProps {
/** Where a card's right-click asked for the actions menu; null while it is closed. */
contextMenuPoint: WorkflowCardMenuAnchor | null;
/** What opened the menu last, kept through its close so focus returns there. */
contextMenuTriggerId: string | null;
+ /** Allows the authenticated owner to change visibility, only on Yours. */
+ canChangeVisibility?: boolean;
entry: WorkflowLibraryEntry | null;
+ /** Hides writes to a public template owned by another account. */
+ isReadOnly?: boolean;
/** The shell closes the library when a fork takes the user to a new project. */
onClose: () => void;
onContextMenuClose: () => void;
@@ -83,9 +99,11 @@ export interface WorkflowLibraryDetailPanelProps {
const toFileSlug = (name: string): string => name.trim().replaceAll(/\s+/g, '-').toLowerCase() || 'workflow';
export const WorkflowLibraryDetailPanel = ({
+ canChangeVisibility = false,
contextMenuPoint,
contextMenuTriggerId,
entry,
+ isReadOnly = false,
onClose,
onContextMenuClose,
onDeleted,
@@ -105,6 +123,9 @@ export const WorkflowLibraryDetailPanel = ({
// Guard duplicate creation until the copy exists, including copies landing outside the visible category.
const [isDuplicatePending, setIsDuplicatePending] = useState(false);
const isDuplicatePendingRef = useRef(false);
+ const [visibilityStates, setVisibilityStates] = useState