Show who is in the node graph or the App view
Clients report their view; the Live tab marks each avatar and People shows a Graph/App chip, with ComfyUI's own Graph and App icons (also used for apps in Files and the Live list).
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
- App mode: live workflows work in ComfyUI's App view. Each person keeps their own graph or App view through live edits, and App inputs sync. Cursors are off in the App view. Workflows saved as Apps (`.app.json`) show with an app icon in Files and in the Live list, and Save keeps them Apps
|
||||
- In the App view, where ComfyUI shows only its own sidebar tabs, RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it
|
||||
- The Live tab and People show whether each person is in the node graph or the App view, with ComfyUI's own Graph and App icons
|
||||
- ComfyUI's own Workflows and Apps sidebar tabs are hidden, since Files replaces them, and their shortcuts open Files. Admin setting `hide_comfy_file_tabs`, on by default
|
||||
|
||||
## 1.1.0 (2026-09-26)
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ Only the tab on screen is live. Switch to another tab and you leave the room; sw
|
||||
- Two people editing the same node at once: the last change wins.
|
||||
- Two people adding or connecting nodes at the same moment both keep their work. If both picked the same node or link id, the server renumbers the later one and quietly resyncs that person. Links and groups merge one by one, so a connection made by one person never erases another's.
|
||||
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it.
|
||||
**App mode.** Each person chooses for themselves whether a workflow shows as the node graph or as an App. Switching doesn't change anyone else's view, and live edits keep it. The Live tab and People show which view each person is in, using ComfyUI's Graph and App icons. Inputs changed in the App view sync like any other widget value. The app's layout, meaning its chosen inputs and outputs, belongs to the workflow and syncs. Cursors aren't shown in the App view. ComfyUI's App view shows only its own sidebar tabs, so RigShare adds **RigShare** and **Files** buttons to that sidebar, with the unread chat badge. They open the panels in a drawer beside it.
|
||||
|
||||
Viewers (no *Edit* permission) see a read-only canvas. Anything they change locally is put back.
|
||||
|
||||
|
||||
@@ -76,12 +76,13 @@ class Client:
|
||||
self.joined = time.time()
|
||||
self.idle = False
|
||||
self.room = None
|
||||
self.view = "graph" # "graph" (node editor) or "app" (ComfyUI's App view)
|
||||
|
||||
def public(self):
|
||||
return {"id": self.id, "kind": self.kind, "key": self.key if self.kind == "user" else None,
|
||||
"guest_id": self.key if self.kind == "guest" else None,
|
||||
"name": self.name, "color": self.color, "perms": self.perms,
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room}
|
||||
"joined": int(self.joined), "idle": self.idle, "room": self.room, "view": self.view}
|
||||
|
||||
|
||||
class Hub:
|
||||
@@ -505,6 +506,12 @@ class Hub:
|
||||
await self.broadcast_presence()
|
||||
await self.system_chat(f"{old} is now known as {name}")
|
||||
|
||||
elif kind == "view":
|
||||
view = data.get("view")
|
||||
if view in ("graph", "app") and view != client.view:
|
||||
client.view = view
|
||||
await self.broadcast_presence()
|
||||
|
||||
elif kind == "idle":
|
||||
client.idle = bool(data.get("idle"))
|
||||
await self.broadcast_presence()
|
||||
|
||||
@@ -351,3 +351,20 @@ def test_apps_and_comfy_file_tabs(run):
|
||||
assert (await sock.until("server"))["server"]["hide_comfy_file_tabs"] is False
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_view_mode_in_presence(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
await rig.add_user("alice", edit=True)
|
||||
alice, boss = await rig.ws("alice"), await rig.ws("boss")
|
||||
await boss.drain()
|
||||
await alice.send({"type": "view", "view": "app"})
|
||||
presence = await boss.until("presence")
|
||||
assert {u["key"]: u["view"] for u in presence["users"]} == {"alice": "app", "boss": "graph"}
|
||||
await alice.send({"type": "view", "view": "nonsense"})
|
||||
await asyncio.sleep(0.2)
|
||||
assert next(c for c in rig.hub.clients.values() if c.key == "alice").view == "app", "unknown views are ignored"
|
||||
await alice.close(); await boss.close()
|
||||
run(scenario())
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// One component serves the Files tab ("browse"), the "Open from RigShare"
|
||||
// dialog ("open"), the Save dialog ("save") and the "Move to…" picker ("pick").
|
||||
|
||||
import { h, timeAgo, storage } from "./ui.js";
|
||||
import { h, timeAgo, storage, APP_ICON } from "./ui.js";
|
||||
|
||||
const SHARED = "@shared";
|
||||
const ALL = "";
|
||||
@@ -192,7 +192,7 @@ export class FilesBrowser {
|
||||
const meta = isFolder
|
||||
? [e.owner !== undefined && owner ? `owner ${owner}` : null, e.role === "view" ? "view only" : null].filter(Boolean).join(" · ")
|
||||
: [e.app ? "App" : null, timeAgo(e.modified * 1000), e.role === "view" ? "view only" : null].filter(Boolean).join(" · ");
|
||||
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : e.app ? "pi-th-large" : "pi-file";
|
||||
const icon = isFolder ? (e.restricted ? "pi-lock" : "pi-folder") : e.app ? "app" : "pi-file";
|
||||
// Files outside private folders are live while open: show who is in them.
|
||||
const live = isFolder ? null : this.client.rooms?.find((r) => r.key === `file:workflows/${e.path}`);
|
||||
const disabled = this.mode === "pick" && !isFolder;
|
||||
@@ -207,7 +207,7 @@ export class FilesBrowser {
|
||||
if (this.mode !== "pick") this.onOpenFile?.(`workflows/${e.path}`);
|
||||
},
|
||||
},
|
||||
h("i", { class: `pi ${icon} rs-fb-icon ${isFolder ? "folder" : ""}` }),
|
||||
h("i", { class: `${icon === "app" ? `${APP_ICON} rs-comfy-icon` : `pi ${icon}`} rs-fb-icon ${isFolder ? "folder" : ""}` }),
|
||||
h("span", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis", title: e.name }, e.name,
|
||||
live ? h("span", { class: "rs-live rs-inline-icon", title: `Live · ${live.members.length} open` }, h("span", { class: "rs-live-dot" })) : null),
|
||||
|
||||
+16
-5
@@ -1,7 +1,7 @@
|
||||
// Sidebar panel: tabs for workflows, people, chat, server and admin, plus an
|
||||
// account view (profile, password, API keys) behind the avatar button.
|
||||
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage } from "./ui.js";
|
||||
import { h, initials, timeAgo, clock, roleChips, meter, storage, viewIcon, APP_ICON } from "./ui.js";
|
||||
import { ServerWidget } from "./server-widget.js";
|
||||
import { pickFolder } from "./files.js";
|
||||
import { isPrivatePath } from "./sync.js";
|
||||
@@ -247,7 +247,7 @@ export class RigSharePanel {
|
||||
s.live
|
||||
? h("span", { class: "rs-live" }, h("span", { class: "rs-live-dot" }), `Live · rev ${s.version}${last}`)
|
||||
: h("span", { class: "rs-live rs-muted" }, h("i", { class: "pi pi-spin pi-spinner" }), "Joining…"),
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))));
|
||||
h("span", { class: "rs-avatars" }, ...here.map((u) => this.viewAvatar(u)))));
|
||||
body.push(this.accessLine());
|
||||
if (this.accessOpen && s.room?.manage && s.room.folder) body.push(this.accessEditor(s.room.folder));
|
||||
if (!s.canEdit) body.push(h("p", { class: "rs-note" }, h("i", { class: "pi pi-lock" }), c.perms.edit ? "View only in this workflow." : "View only — ask an admin for edit access."));
|
||||
@@ -383,6 +383,15 @@ export class RigSharePanel {
|
||||
await this.app.loadGraphData(doc, true, true, `${snap.room_name || "Snapshot"} (${snap.label || snap.id}).json`);
|
||||
}
|
||||
|
||||
/** Small avatar marked with how that person views the workflow: node graph or App. */
|
||||
viewAvatar(u) {
|
||||
const app = u.view === "app";
|
||||
return h("span", {
|
||||
class: `rs-avatar rs-avatar-xs rs-view-avatar ${app ? "rs-view-app" : ""}`, style: `background:${u.color}`,
|
||||
title: `${u.name} · ${app ? "App view" : "editing the graph"}`,
|
||||
}, initials(u.name), h("span", { class: "rs-view-mark" }, viewIcon(u.view)));
|
||||
}
|
||||
|
||||
roomsSection() {
|
||||
const c = this.client;
|
||||
const rooms = c.rooms || [];
|
||||
@@ -392,11 +401,11 @@ export class RigSharePanel {
|
||||
const members = r.members.map((id) => byId.get(id)).filter(Boolean);
|
||||
const folder = r.key.replace(/^file:workflows\//, "").split("/").slice(0, -1).join("/");
|
||||
return h("div", { class: `rs-row-item rs-room ${here ? "here" : ""}`, title: r.key.replace(/^file:workflows\//, "") },
|
||||
h("i", { class: `pi ${r.app ? "pi-th-large" : "pi-file"} rs-row-icon`, title: r.app ? "App" : null }),
|
||||
h("i", { class: `${r.app ? `${APP_ICON} rs-comfy-icon` : "pi pi-file"} rs-row-icon`, title: r.app ? "App" : null }),
|
||||
h("div", { class: "rs-grow rs-min0" },
|
||||
h("div", { class: "rs-name rs-ellipsis" }, r.name, r.restricted ? h("i", { class: "pi pi-lock rs-inline-icon rs-muted", title: "Restricted" }) : null),
|
||||
h("div", { class: "rs-muted rs-small rs-ellipsis" }, folder ? folder.replace(/^shared(\/|$)/, "Shared$1") : "Workflows", ` · ${r.nodes} nodes`),
|
||||
h("div", { class: "rs-avatars" }, ...members.map((u) => h("span", { class: "rs-avatar rs-avatar-xs", style: `background:${u.color}`, title: u.name }, initials(u.name))))),
|
||||
h("div", { class: "rs-avatars" }, ...members.map((u) => this.viewAvatar(u)))),
|
||||
here ? h("span", { class: "rs-chip rs-chip-edit" }, "open") : h("button", {
|
||||
class: "rs-btn rs-btn-icon", title: "Open",
|
||||
onclick: () => this.run(() => this.sync.openRoom(r)),
|
||||
@@ -432,7 +441,9 @@ export class RigSharePanel {
|
||||
h("div", { class: "rs-name rs-ellipsis" }, u.name, self ? h("span", { class: "rs-muted" }, " (you)") : null,
|
||||
u.idle ? h("i", { class: "pi pi-moon rs-muted rs-inline-icon", title: "Away" }) : null),
|
||||
h("div", { class: "rs-chips" }, ...(toggles.length ? toggles : roleChips(u.perms))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"))),
|
||||
h("div", { class: "rs-where" }, h("i", { class: `pi ${where ? "pi-file" : "pi-lock"}` }), h("span", { class: "rs-ellipsis" }, where ?? "private tab"),
|
||||
where ? h("span", { class: `rs-chip rs-view-chip ${u.view === "app" ? "rs-view-app" : ""}` },
|
||||
viewIcon(u.view), u.view === "app" ? "App" : "Graph") : null)),
|
||||
self ? null : h("button", {
|
||||
class: `rs-btn rs-btn-icon ${following ? "rs-btn-primary" : ""}`,
|
||||
title: following ? "Stop following" : `Follow ${u.name} (jump to their tab and view)`,
|
||||
|
||||
@@ -94,7 +94,7 @@ code { font-family: ui-monospace, monospace; font-size: 11px; background: var(--
|
||||
.rs-avatar-sm { width: 28px; height: 28px; font-size: 10.5px; }
|
||||
.rs-avatar-lg { width: 44px; height: 44px; font-size: 15px; }
|
||||
.rs-avatar-xs { width: 20px; height: 20px; font-size: 8.5px; }
|
||||
.rs-avatars { display: inline-flex; gap: 3px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-avatars { display: inline-flex; gap: 7px; flex-wrap: wrap; margin-top: 3px; }
|
||||
.rs-name { font-weight: 600; }
|
||||
.rs-me { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.rs-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
|
||||
@@ -261,6 +261,14 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; }
|
||||
background: #ef4444; color: #fff; font-size: 10px; line-height: 16px; font-weight: 700; text-align: center; }
|
||||
.rs-dock-close { position: absolute; top: 8px; right: 8px; z-index: 2; border: 0; background: none; color: var(--rs-muted); cursor: pointer; padding: 4px; }
|
||||
.rs-dock-close:hover { color: var(--rs-fg); }
|
||||
.rs-view-avatar { position: relative; overflow: visible; }
|
||||
.rs-comfy-icon { display: inline-block; width: 1em; height: 1em; flex: none; }
|
||||
.rs-view-mark { position: absolute; right: -6px; bottom: -5px; font-size: 8px; width: 13px; height: 13px; display: grid; place-items: center;
|
||||
border-radius: 50%; background: var(--rs-bg); color: var(--rs-muted); border: 1px solid var(--rs-border); }
|
||||
.rs-view-app .rs-view-mark { color: var(--rs-accent-fg); background: var(--rs-accent); border-color: var(--rs-accent); }
|
||||
.rs-view-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: 6px; font-size: 10.5px; padding: 0 6px; }
|
||||
.rs-view-chip .rs-comfy-icon { font-size: 11px; }
|
||||
.rs-view-chip.rs-view-app { color: var(--rs-accent); border-color: var(--rs-accent); }
|
||||
.rs-cursor-layer { position: fixed; inset: 0; pointer-events: none; z-index: 900; overflow: hidden; }
|
||||
.rs-cursor { position: absolute; left: 0; top: 0; will-change: transform; transition: transform 70ms linear; }
|
||||
.rs-cursor svg { display: block; filter: drop-shadow(0 1px 1px rgb(0 0 0 / .4)); }
|
||||
|
||||
@@ -120,7 +120,7 @@ export class RoomSync extends EventTarget {
|
||||
this.inFlight = 0; // our patches not yet acknowledged
|
||||
this.crossed = false; // a remote patch arrived while ours were in flight
|
||||
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.tick(); });
|
||||
client.on("welcome", () => { this.targetKey = undefined; this.sentView = undefined; this.tick(); });
|
||||
client.on("status", (s) => { if (s !== "online") { this.ready = false; this.emitState(); } });
|
||||
client.on("room", (msg) => this.onRoom(msg));
|
||||
client.on("room_info", (msg) => {
|
||||
@@ -261,6 +261,9 @@ export class RoomSync extends EventTarget {
|
||||
|
||||
tick() {
|
||||
if (!this.client.online || !this.store) return;
|
||||
// Tell the others whether this tab shows the node graph or the App view.
|
||||
const view = this.inAppMode ? "app" : "graph";
|
||||
if (view !== this.sentView && this.client.send({ type: "view", view })) this.sentView = view;
|
||||
const { wf, room } = this.activeRoom();
|
||||
const key = room?.key ?? null;
|
||||
if (key !== this.targetKey || (key && wf !== this.targetWf)) {
|
||||
|
||||
@@ -62,3 +62,11 @@ export const storage = {
|
||||
} catch { /* private mode */ }
|
||||
},
|
||||
};
|
||||
|
||||
// ComfyUI's own icons for its Graph / App switch, so RigShare matches what people see there.
|
||||
export const GRAPH_ICON = "icon-[comfy--workflow]";
|
||||
export const APP_ICON = "icon-[lucide--panels-top-left]";
|
||||
|
||||
export function viewIcon(view, cls = "") {
|
||||
return h("i", { class: `${view === "app" ? APP_ICON : GRAPH_ICON} rs-comfy-icon ${cls}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user