openvibers/openvibe.chat Commits
Workspaces for everyone: any community room, a Workspace kind, /work (#168)
Changes
@@@ -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 themigration 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, whichqueues it with its `queue_task` tool (one task per job) and answers what is going on with `queue_status`.@@@ -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, joinif (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));@@@ -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`);}));@@@ -198,4 +198,52 @@ t('a workspace room shows its shared screen to members only; only a Bot screen lassert.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(); });
@@ -389,7 +389,12 @@ A workspace room has a **work queue** beside its conversation ([server/rooms/wor
@@ -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
[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
`@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
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
- **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`.
queues it with its `queue_task` tool (one task per job) and answers what is going on with `queue_status`.
@@ -392,7 +392,7 @@ function screenUrlOf(v) {
@@ -392,7 +392,7 @@ function screenUrlOf(v) {
return s;
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');
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).
// 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');
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
@@ -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');
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;
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.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.
// Public → private: everything the room published stops being replayable on the realtime plane.
if (room.visibility === 'public' && next.visibility === 'private') {
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));
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));
@@ -600,19 +600,23 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
@@ -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 { public: pub, mine } = await rooms.list(me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const staff = !!me && permissions.can(me, 'staff.moderation.chat');
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>
<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>
<label>Topic <input name="topic" maxlength="200" placeholder="What it is about (optional)"></label>
<fieldset class="oc-fieldset"><legend>Kind</legend>
<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>
<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>' : ''}
${staff ? '<label class="oc-check"><input type="radio" name="kind" value="system"> Announcements: only OpenVibe staff post (staff)</label>' : ''}
</fieldset>
</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>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>
<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>
<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, {
await page(req, res, 200, {
actor, title: 'Rooms', path: '/rooms', active: 'rooms', robots: 'index, follow',
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.',
description: 'Chat and call rooms on OpenVibe: start one about anything, invite people, or join a public room.',
@@ -629,11 +633,20 @@ ${create}
@@ -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) => {
router.post('/rooms/new', formLimit, sameSite, roomsOrError(async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
const b = req.body || {};
const b = req.body || {};
+ const work = b.kind === 'workspace';
const kind = rooms.KINDS.includes(b.kind) ? b.kind : 'community';
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 });
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}`);
res.redirect(303, `/r/${room.slug}`);
}));
}));
@@ -845,6 +858,7 @@ ${can.manage ? `<form class="oc-form" method="post" action="/r/${esc(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>
<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>` : ''}
${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>
<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>` : ''}
${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>` : ''}
<p><button type="submit">Save</button></p></form>` : ''}
${hookSection}
${hookSection}
@@ -869,8 +883,18 @@ ${attachList}`,
@@ -869,8 +883,18 @@ ${attachList}`,
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
if (!room || !(await rooms.access(room, actor.user)).read) return res.redirect(303, '/rooms');
req.roomBack = `/r/${room.slug}/settings`;
req.roomBack = `/r/${room.slug}/settings`;
const b = req.body || {};
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 } : {}) });
...(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);
await applyRoomChange(room);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
res.redirect(303, `/r/${room.slug}/settings?saved=1`);
}));
}));
@@ -198,4 +198,52 @@ t('a workspace room shows its shared screen to members only; only a Bot screen l
@@ -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');
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(); });
t.run(async () => { if (h && h.close) await h.close(); });