openvibers/openvibe.chat Commits

Workspaces for everyone: any community room, a Workspace kind, /work (#168)

OP OpenVibers committed 19 days ago · 0b5b121b41 · 4 files, +97 −11

* Any community room can be a workspace: its owner ticks 'Work together here' (work queue, alerts, shared screen) and Vibe joins to run the work

* Start a room: a Workspace kind (team + Vibe, work queue, alerts, shared screen), preselected by /rooms?new=workspace&name= for other sites' 'Work on it together' links

* /work: the shared navbar's Work together lands on the new-workspace form (signed out: sign in, then back to it)

Changes

ddiff --git a/README.md b/README.md
iindex 5ead91c..5fcd9d4 100644
---- a/README.md
++++ b/README.md
@@@ -389,7 +389,12 @@ A workspace room has a **work queue** beside its conversation ([server/rooms/wor
[server/web/workspace-pages.js](server/web/workspace-pages.js), migration `0027_workspaces.sql`): the team talks, asks
`@vibe`, and everyone sees what is queued, being worked on and done. The **staff room** (`/r/staff`, seeded by the
migration with Vibe in it) is OpenVibe staff's own: every member of staff (chat staff, `staff.moderation.chat`) becomes a
--member on their first visit, and nobody else reads it, whatever role they once had.
++member on their first visit, and nobody else reads it, whatever role they once had. **Any community room** becomes a
++workspace when its owner ticks "Work together here" in the room's settings (or `PATCH /api/chat/rooms/:slug`
++`{ "workspace": true }`), and "Start a room" offers a **Workspace** kind (preselected by `/rooms?new=workspace&name=…`, the
++link other OpenVibe sites use for "Work on it together"; `/work`, the shared navbar's "Work together", goes there); from those pages Vibe joins it, when there is room for one more AI member, to run and
++announce the work. Switching it off hides the queue and drops the shared screen; the staff room stays a workspace, and
++a call room, a server's channel or a chat with Vibe never becomes one.
- **Adding work:** anyone who may post adds a task (title, kind, details) from the panel or the API, or asks Vibe, which
queues it with its `queue_task` tool (one task per job) and answers what is going on with `queue_status`.
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 232b190..67586ad 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -392,7 +392,7 @@ function screenUrlOf(v) {
return s;
}
--async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url } = {}, { fromServer = false, minName = 3 } = {}) {
++async function update(room, actor, { name, topic, visibility, slow_seconds, join_role, screen_url, workspace } = {}, { fromServer = false, minName = 3 } = {}) {
if (!(await access(room, actor)).manage) fail(403, 'rooms.not_owner', 'Only the owner changes the room');
// A server's channel follows the server's visibility, and is changed through the server (servers.js).
if (room.server_id && !fromServer && visibility != null && visibility !== room.visibility) fail(409, 'rooms.server_channel', 'A channel is as public as its server: change the server instead');
@@@ -406,12 +406,21 @@ async function update(room, actor, { name, topic, visibility, slow_seconds, join
if (kindOf(room) !== 'call' || !JOIN_ROLES_CALL.includes(join_role)) fail(422, 'rooms.join_role', 'People join a call room as speakers, participants or viewers');
next.join_role = join_role;
}
-- if (screen_url !== undefined) {
-- if (!isWorkspace(room)) fail(409, 'rooms.not_workspace', 'Only a workspace room shows a shared screen');
-- next.screen_url = screenUrlOf(screen_url);
++ // A community room's owner makes it a workspace (a work queue beside the conversation, alerts, a shared screen) or
++ // a plain room again; the staff room stays one, and a call, a channel or a chat with Vibe never becomes one.
++ if (workspace !== undefined && workspace !== null) {
++ const on = workspace === true || workspace === 1 || workspace === '1' || workspace === 'on';
++ if (on !== isWorkspace(room)) {
++ if (room.slug === 'staff') fail(409, 'rooms.staff_room', 'The staff room stays a workspace');
++ if (on && (kindOf(room) !== 'community' || room.server_id || Number(room.assistant) === 1)) fail(409, 'rooms.workspace_kind', 'Only a community room becomes a workspace');
++ next.workspace = on ? 1 : 0;
++ if (!on) next.screen_url = null;
++ }
}
++ if (screen_url !== undefined && isWorkspace(next)) next.screen_url = screenUrlOf(screen_url);
++ else if (screen_url !== undefined && screen_url !== '' && screen_url !== null) fail(409, 'rooms.not_workspace', 'Only a workspace room shows a shared screen');
await db.tx(async () => {
-- await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, room.id]);
++ await db.run('UPDATE rooms SET name = ?, topic = ?, visibility = ?, slow_seconds = ?, join_role = ?, screen_url = ?, workspace = ? WHERE id = ?', [next.name, next.topic, next.visibility, next.slow_seconds, next.join_role == null ? null : next.join_role, next.screen_url == null ? null : next.screen_url, Number(next.workspace) === 1 ? 1 : 0, room.id]);
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
await announceDeleted(room, (await db.all('SELECT id FROM room_messages WHERE room_id = ? AND is_deleted = 0 ORDER BY id', [room.id])).map((r) => r.id));
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 07b6c00..46b6fee 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -600,19 +600,23 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const { public: pub, mine } = await rooms.list(me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const staff = !!me && permissions.can(me, 'staff.moderation.chat');
-- const create = me ? `<form class="oc-form oc-create" method="post" action="/rooms/new">
++ // ?new=workspace (other OpenVibe sites link "Work on it together" here) picks the workspace kind and fills the name.
++ const wantWork = req.query.new === 'workspace';
++ const preName = wantWork && typeof req.query.name === 'string' ? req.query.name.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40) : '';
++ const create = me ? `<form class="oc-form oc-create" id="oc-new-room" method="post" action="/rooms/new">
<h2>Start a room</h2>
--<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls"></label>
++<label>Name <input name="name" required minlength="3" maxlength="40" placeholder="Night Owls" value="${esc(preName)}"${wantWork ? ' autofocus' : ''}></label>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
<fieldset class="oc-fieldset"><legend>Kind</legend>
--<label class="oc-check"><input type="radio" name="kind" value="community" checked> Chat room: people write to each other</label>
++<label class="oc-check"><input type="radio" name="kind" value="community"${wantWork ? '' : ' checked'}> Chat room: people write to each other</label>
++<label class="oc-check"><input type="radio" name="kind" value="workspace"${wantWork ? ' checked' : ''}> Workspace: a team and Vibe work together, with a work queue, alerts from other systems and a shared screen</label>
<label class="oc-check"><input type="radio" name="kind" value="call"> Call room: a voice call with a text chat beside it</label>
${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system"> Announcements: only OpenVibe staff post (staff)</label>' : ''}
</fieldset>
<label>In a call room, people who join <select name="join_role"><option value="participant">listen and write (participants)</option><option value="speaker">can talk (speakers)</option><option value="viewer">listen and read (viewers)</option></select></label>
<label class="oc-check"><input type="checkbox" name="private" value="1"> Private: only people you invite can read and post</label>
<p><button type="submit">Create room</button></p>
--</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2Frooms">Sign in with OpenVibe</a> to start or join a room.</p>`;
++</form>` : `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${wantWork ? '%2Frooms%3Fnew%3Dworkspace' : '%2Frooms'}">Sign in with OpenVibe</a> to start or join a room.</p>`;
await page(req, res, 200, {
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
@@@ -629,11 +633,20 @@ ${create}
});
});
++ // "Work together" in every OpenVibe site's account menu (openvibe-shared navbar): start a workspace room.
++ router.get('/work', (req, res) => res.redirect(302, '/rooms?new=workspace#oc-new-room'));
++
router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
++ const work = b.kind === 'workspace';
const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
const room = await rooms.create(actor.user, { name: b.name, topic: b.topic, visibility: b.private === '1' ? 'private' : 'public', kind, join_role: kind === 'call' ? b.join_role || null : null });
++ if (work) {
++ const made = await rooms.bySlug(room.slug);
++ await rooms.update(made, actor.user, { workspace: true });
++ if (aiMembers.enabled()) await aiMembers.add(made, actor.user, await rooms.access(made, actor.user), { name: 'Vibe', persona: VIBE_IN_ROOMS }).catch(() => null);
++ }
res.redirect(303, `/r/${room.slug}`);
}));
@@@ -845,6 +858,7 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(room.slug)}
<label>Who can read <select name="visibility"><option value="public"${room.visibility === 'public' ? ' selected' : ''}>Anyone (public)</option><option value="private"${room.visibility === 'private' ? ' selected' : ''}>Members only (private)</option></select></label>
${room.kind === 'call' ? `<label>People who join <select name="join_role">${rooms.JOIN_ROLES_CALL.map((r) => `<option value="${r}"${r === addRole ? ' selected' : ''}>${{ speaker: 'can talk (speakers)', participant: 'listen and write (participants)', viewer: 'listen and read (viewers)' }[r]}</option>`).join('')}</select></label>` : ''}
<label>Slow mode (seconds between messages, 0 = off) <input name="slow_seconds" type="number" min="0" max="600" value="${Number(room.slow_seconds) || 0}"></label>
++${room.slug !== 'staff' && (room.kind || 'community') === 'community' && Number(room.assistant) !== 1 ? `<input type="hidden" name="has_workspace" value="1"><label class="oc-check"><input type="checkbox" name="workspace" value="1"${rooms.isWorkspace(room) ? ' checked' : ''}> Work together here: a work queue beside the conversation (Vibe runs research at once; code and computer tasks go to a member or to the asker's own computer), alerts from other systems, and a shared screen</label>` : ''}
${rooms.isWorkspace(room) ? `<label>Shared screen <input name="screen_url" type="url" value="${esc(room.screen_url || '')}" placeholder="https://openvibe.bot/screen/…"> <span class="oc-muted">A screen share link from OpenVibe.Bot (a computer's owner makes it on its screen page). Members see the screen beside the conversation, and with a "use" link they can click and type on it. Empty: no screen.</span></label>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
${hookSection}
@@@ -869,8 +883,18 @@ ${attachList}`,
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
-- await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined,
++ const workspace = b.has_workspace ? [].concat(b.workspace || []).includes('1') : undefined;
++ const updated = await rooms.update(room, actor.user, { name: b.name, topic: b.topic, visibility: b.visibility, slow_seconds: b.slow_seconds === undefined ? undefined : Number(b.slow_seconds), join_role: room.kind === 'call' ? b.join_role : undefined,
++ ...(workspace !== undefined ? { workspace } : {}),
...(rooms.isWorkspace(room) && b.screen_url !== undefined ? { screen_url: b.screen_url } : {}) });
++ // A room that just became a workspace gets Vibe, who runs and announces its work, when it has room for one more.
++ if (workspace && !rooms.isWorkspace(room) && updated.workspace && aiMembers.enabled()) {
++ const ais = await aiMembers.list(room);
++ if (!ais.some((a) => a.handle === 'vibe') && ais.length < aiMembers.MAX_PER_ROOM) {
++ const can = await rooms.access(room, actor.user);
++ await aiMembers.add(room, actor.user, can, { name: 'Vibe', persona: VIBE_IN_ROOMS }).catch(() => null);
++ }
++ }
await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex 610885a..d259662 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -198,4 +198,52 @@ t('a workspace room shows its shared screen to members only; only a Bot screen l
assert.ok(!(await api('GET', '/rooms/staff', staff)).body.screen, 'cleared');
});
++t('any community room\'s owner makes it a workspace (and Vibe joins); the staff room stays one; a call never becomes one', async () => {
++ const r = await api('POST', '/rooms', user, { name: 'Pat Builds', visibility: 'private' });
++ assert.strictEqual(r.status, 201, r.text);
++ const slug = r.body.room.slug;
++ const cookie = `ov_token=${user.token}`;
++ let page = await h.http('GET', `/r/${slug}/settings`, { headers: { cookie } });
++ assert.match(page.text, /name="workspace" value="1">/, 'the owner sees the switch, off');
++ const form = (body) => h.http('POST', `/r/${slug}/settings`, { headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, raw: body });
++ let res = await form('name=Pat+Builds&visibility=private&slow_seconds=0&has_workspace=1&workspace=1');
++ assert.ok(res.status < 400 && /Saved\./.test(res.text), 'saved (the redirect is followed)');
++ const room = await rooms.bySlug(slug);
++ assert.ok(rooms.isWorkspace(room), 'now a workspace');
++ const ai = await h.db.getDb().prepare('SELECT handle FROM room_ai_members WHERE room_id = ?').all(room.id);
++ assert.deepStrictEqual(ai.map((a) => a.handle), ['vibe'], 'Vibe joined to run and announce the work');
++ page = await h.http('GET', `/r/${slug}`, { headers: { cookie } });
++ assert.match(page.text, /<aside class="oc-queue" id="oc-queue"/);
++ assert.strictEqual((await api('POST', `/rooms/${slug}/tasks`, user, { title: 'Plan the launch week', kind: 'other' })).status, 201, 'the owner adds work');
++ await api('PATCH', `/rooms/${slug}`, user, { screen_url: 'https://openvibe.bot/screen/abcdefghijklmnopqrstuvwxyz0123456789ABCDEF' });
++ res = await form('name=Pat+Builds&visibility=private&slow_seconds=0&has_workspace=1');
++ assert.ok(res.status < 400 && /Saved\./.test(res.text));
++ const after = await rooms.bySlug(slug);
++ assert.ok(!rooms.isWorkspace(after) && !after.screen_url, 'off again, and the screen goes with it');
++ assert.strictEqual((await api('PATCH', '/rooms/staff', staff, { workspace: false })).status, 409, 'the staff room stays a workspace');
++ const call = await api('POST', '/rooms', user, { name: 'Pat Calls', kind: 'call', visibility: 'private' });
++ assert.strictEqual(call.status, 201, call.text);
++ assert.strictEqual((await api('PATCH', `/rooms/${call.body.room.slug}`, user, { workspace: true })).status, 409, 'a call room never becomes one');
++ const kim = h.addUser('kim', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(kim.id));
++ assert.ok([403, 404].includes((await api('PATCH', `/rooms/${slug}`, kim, { workspace: true })).status), 'someone else cannot');
++});
++
++t('starting a room: the Workspace kind (preselected from ?new=workspace) makes a workspace with Vibe in it', async () => {
++ const cookie = `ov_token=${user.token}`;
++ const go = await fetch(`${h.base}/work`, { redirect: 'manual' });
++ assert.deepStrictEqual([go.status, go.headers.get('location')], [302, '/rooms?new=workspace#oc-new-room'], 'the navbar\'s Work together');
++ assert.match((await h.http('GET', '/rooms?new=workspace')).text, /next=%2Frooms%3Fnew%3Dworkspace/, 'signed out: sign in, then back to it');
++ const list = await h.http('GET', '/rooms?new=workspace&name=Launch%20week%00', { headers: { cookie } });
++ assert.match(list.text, /name="kind" value="workspace" checked/);
++ assert.match(list.text, /value="Launch week"/, 'the name is filled in, without control characters');
++ const res = await h.http('POST', '/rooms/new', { headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, raw: 'name=Launch+week&kind=workspace&private=1' });
++ assert.ok(res.status < 400, res.text.slice(0, 200));
++ const made = await h.db.getDb().prepare("SELECT slug FROM rooms WHERE name = 'Launch week' ORDER BY id DESC LIMIT 1").get();
++ const room = await rooms.bySlug(made.slug);
++ assert.ok(room && rooms.isWorkspace(room) && room.visibility === 'private' && room.kind === 'community');
++ const ai = await h.db.getDb().prepare('SELECT handle FROM room_ai_members WHERE room_id = ?').all(room.id);
++ assert.deepStrictEqual(ai.map((a) => a.handle), ['vibe']);
++});
++
t.run(async () => { if (h && h.close) await h.close(); });