Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 3a4f8a6727 feat(presence): show connection counts from per-session presence
Two terminals of one user collapsed into one viewer. With collaboration
1.2.0 (qbit-collaboration #54) the snapshot says how many connections
each viewer has, and perConnection=true lists each connection.

- core: PresencePerson.connections, PresenceState.connections, an
  opt-in perConnection that fills person.leases (mode, field, start,
  this client's own lease marked self via the server's opaque id)
- TUI: the record strip, panel ("Admin x2 (you)", one line per
  terminal) and Viewing now entry show counts; the record presence
  joins with perConnection; the screen saver's Online tile counts
  connections ("2 online - you x2")
- qqq collab presence: connections column, --per-connection rows
- regenerated the client from the collaboration 1.2.0 document,
  read from the local feat/per-session-presence worktree until it
  lands on develop (extension-sources.json says so)

Servers before 1.2.0 send no connections: every person counts once and
nothing changes on screen; tests cover both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 16:45:24 -06:00
21 changed files with 506 additions and 59 deletions

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/op
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `feat/per-session-presence` 0ae1e62 (local worktree `.worktrees/qbit-collab-sessions`, not yet on `develop`) | 1.1.0 (presence schemas, error contract); 1.2.0 adds per-connection presence (`connections`, `leaseCount`, `perConnection`), used when present |
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
+23 -9
View File
@@ -10,7 +10,7 @@
*/
import type { Command } from 'commander'
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import { COLLABORATION, connectionsSuffix, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
import type { Context } from '../context.js'
import { UsageError } from '../context.js'
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
@@ -27,14 +27,15 @@ function hhmmss(date: Date): string {
return date.toTimeString().slice(0, 8)
}
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
/** `2 on person 1: Alice Morgan ×2 (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
export function presenceLine(state: PresenceState): string {
if (!state.available) return 'presence: the collaboration extension is not installed'
if (state.error) return `presence: ${state.error}`
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
if (!state.count) return `nobody on ${where}`
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
return `${state.count} on ${where}: ${people.join(', ')}`
const people = state.people.map((p) => `${p.name}${connectionsSuffix(p)} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
const connections = state.connections ?? state.count
return `${state.count} on ${where}${connections > state.count ? ` (${connections} connections)` : ''}: ${people.join(', ')}`
}
function favoriteRows(items: FavoriteEntry[]): Json[] {
@@ -71,7 +72,8 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
.option('--watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
.option('--observe', 'follow changes without joining')
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
.option('--per-connection', 'one row per connection (tab, terminal) with its mode, field and start (collaboration 1.2.0)')
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean; perConnection?: boolean }) => {
const c = ctx()
const collaboration = await collab(c)
const scope = { table, pk }
@@ -81,18 +83,30 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
}
if (!options.watch && !options.observe) {
const state = await collaboration.presenceSnapshot(scope)
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
const state = await collaboration.presenceSnapshot(scope, { perConnection: options.perConnection })
const connections = state.connections ?? state.count
const where = `on ${pk !== undefined ? `${table} ${pk}` : table}`
const notes = [where, ...(connections > state.count ? [`${connections} connections`] : []), '--watch to join and follow']
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', connections: p.connections ?? 1 }))
if (options.perConnection) {
// older servers send no per-connection detail: one row per person, connection unknown
const leaseRows = state.people.flatMap((p) => p.leases?.length
? p.leases.map((l) => ({ name: p.name, user: p.userId, mode: l.mode, field: l.fieldName ?? '', since: l.since ? hhmmss(l.since) : '', connection: l.connectionId }))
: [{ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', since: '', connection: '' }])
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows, leases: leaseRows }, rows: leaseRows, columns: ['name', 'user', 'mode', 'field', 'since', 'connection'], footer: countFooter(state.count, 'person', notes, 'people') })
return
}
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field', 'connections'], footer: countFooter(state.count, 'person', notes, 'people') })
return
}
let last = ''
const handle = await collaboration.joinPresence(scope, {
announce: Boolean(options.watch),
perConnection: options.perConnection,
mode: options.editing ? 'EDITING' : 'VIEWING',
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
onChange: (state) => {
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName, p.connections, p.leases?.map((l) => [l.connectionId, l.mode, l.fieldName])]), state.error, state.live])
if (key !== last) {
last = key
emit(state)
+1
View File
@@ -41,4 +41,5 @@ test('presence lines say who is there and since when', () => {
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 1, connections: 2, people: [{ userId: 'admin', name: 'Admin', mode: 'VIEWING', since, connections: 2 }], live: true }), '1 on person 1 (2 connections): Admin ×2 (viewing since 17:50:01)')
})
+1 -1
View File
@@ -4,7 +4,7 @@ esb qqq esb 1.1.0 ok 1.1.0 1.1.0 5
health qqq health not installed 1.1.0 1.1.0 4
alerts qqq alerts not installed 1.2.0 6
assistant qqq assistant not installed 1.6.0 16
collaboration qqq collab not installed 1.1.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
collaboration qqq collab not installed 1.2.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
data-analysis qqq data-analysis not installed 1.5.0 21
db-performance qqq db-performance not installed 1.0.0 4
edi qqq edi not installed 1.1.0 4
@@ -0,0 +1,68 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* `qqq collab presence` against a stub: the connections column for two terminals of one user
* (collaboration 1.2.0), `--per-connection` rows, and a 1.1.0 server that sends no connections.
*/
import assert from 'node:assert/strict'
import { after, before, describe, test } from 'node:test'
import { runCli, sandbox } from './harness.js'
import { startStub, type Stub } from './stub.js'
describe('qqq collab presence against a stub', () => {
let stub: Stub
let box: ReturnType<typeof sandbox>
let version = '1.2.0'
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
const admin = { userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }
before(async () => {
box = sandbox()
stub = await startStub([
[/^GET \/version$/, () => ({ apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'collaboration', version }] })],
[/^GET \/qbit\/collaboration\/presence$/, (r) => {
if (version === '1.1.0') return { table: 'person', viewers: [admin, { userId: 'bob', displayName: 'Bob', mode: 'EDITING' }] }
const leases = [
{ ...admin, connections: 1, connectionId: 'c0000000000000001', sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ ...admin, mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'c0000000000000002', sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
return { table: 'person', viewers: [{ ...admin, mode: 'EDITING', connections: 2 }], leaseCount: 2, ...(r.query.get('perConnection') === 'true' ? { leases } : {}) }
}],
])
})
after(async () => {
await stub.close()
box.dispose()
})
test('two terminals of one user: one person with 2 connections', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /^name,user,mode,field,connections\r?\nAdmin,admin,EDITING,,2/m)
const json = JSON.parse((await qqq(['collab', 'presence', 'person', '-O', 'json'])).stdout)
assert.deepEqual([json.count, json.connections, json.viewers[0].connections], [1, 2, 2])
assert.equal(stub.requests.filter((q) => q.path === '/qbit/collaboration/presence').at(-1)!.query.get('perConnection'), null)
})
test('--per-connection lists each terminal with its mode, field and start', async () => {
version = '1.2.0'
const r = await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])
assert.equal(r.code, 0, r.stderr)
const json = JSON.parse(r.stdout)
assert.deepEqual(json.leases.map((l: { mode: string; field: string; connection: string }) => [l.mode, l.field, l.connection]), [['VIEWING', '', 'c0000000000000001'], ['EDITING', 'name', 'c0000000000000002']])
assert.equal(stub.requests.at(-1)!.query.get('perConnection'), 'true')
})
test('a collaboration 1.1.0 server without connections: each person counts once', async () => {
version = '1.1.0'
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
assert.equal(r.code, 0, r.stderr)
assert.match(r.stdout, /Admin,admin,VIEWING,,1\r?\nBob,bob,EDITING,,1/)
const per = JSON.parse((await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])).stdout)
assert.deepEqual([per.count, per.connections, per.leases.length], [2, 2, 2])
})
})
+2 -2
View File
@@ -34,7 +34,7 @@ export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"collaboration","version":"1.2.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
@@ -111,7 +111,7 @@ export const OPERATIONS: OperationInfo[] = [
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk","perConnection"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
+7
View File
@@ -43,6 +43,7 @@ const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
announce: true, // false: watch without appearing
})
presence.count; presence.people; presence.state.live; presence.state.error
presence.state.connections // two terminals of one user count twice (collaboration 1.2.0)
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
```
@@ -55,6 +56,12 @@ await presence.stop() // DELETE /presence, stop the heartbeat, close the s
- `since` is when this client first saw the person; the qbit does not send join times.
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
EDITING wins).
- Collaboration 1.2.0: each person has `connections` (their live tabs and terminals) and
`state.connections` totals them. `perConnection: true` (option of `joinPresence` and
`presenceSnapshot`) also fills `person.leases`: each connection's mode, field, start and opaque
ids, this client's own marked `self` (the server's id is `c` + 16 hex digits of SHA-256 of
user id, newline, connection id; see `opaqueConnectionId`). Older servers send neither: each
person is then one connection and `leases` stays unset.
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
`openStream` that fans out its own connection's events instead of opening another.
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
+94 -16
View File
@@ -17,6 +17,7 @@
* there) and the other calls throw ExtensionNotInstalledError.
*/
import { createHash } from 'node:crypto'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import type { ExtensionRegistry } from './registry.js'
@@ -104,6 +105,20 @@ export interface PresenceScope {
pk?: string
}
/** One live connection (a tab or terminal) of a person, from collaboration 1.2.0's per-connection presence. */
export interface PresenceConnection {
/** Opaque per-connection id from the server (never the client's own connection id). */
connectionId: string
mode: 'VIEWING' | 'EDITING' | string
fieldName?: string | null
/** When the lease started, as the server reports it. */
since?: Date
/** Opaque id of the signed-in session behind it (never the session itself). */
sessionId?: string
/** True for this client's own connection. */
self?: boolean
}
/** One person on the scope, merged across their tabs. */
export interface PresencePerson {
userId: string
@@ -112,6 +127,10 @@ export interface PresencePerson {
fieldName?: string | null
/** When this client first saw them there (the qbit does not send join times). */
since: Date
/** Live connections (tabs, terminals) merged into this person; 1 from servers before collaboration 1.2.0 (always set by this module). */
connections?: number
/** Each connection, when the server sent per-connection presence (perConnection, or any 1.2.0 event). */
leases?: PresenceConnection[]
}
export interface PresenceState {
@@ -120,6 +139,8 @@ export interface PresenceState {
scope: PresenceScope
count: number
people: PresencePerson[]
/** Live connections behind `people` (two terminals of one user count twice); equals `count` on older servers (always set by this module). */
connections?: number
/** True while the realtime stream is connected and subscribed (else presence is polled). */
live: boolean
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
@@ -140,6 +161,8 @@ export interface PresenceOptions {
announce?: boolean
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
reconnectMs?: number
/** Ask for each connection's mode, field and start (collaboration 1.2.0; ignored by older servers). */
perConnection?: boolean
}
export interface PresenceHandle {
@@ -159,12 +182,64 @@ interface Viewer {
displayName?: string
mode?: string
fieldName?: string | null
connections?: number
connectionId?: string
sessionId?: string
since?: string
}
interface Snapshot {
table?: string
pk?: string | null
viewers?: Viewer[]
leaseCount?: number
leases?: Viewer[]
}
/**
* The opaque id the server gives a connection (collaboration 1.2.0): `c` and the first 16 hex
* digits of SHA-256 of the user id, a newline and the client's connection id.
*/
export function opaqueConnectionId(userId: string, connectionId: string): string {
return `c${createHash('sha256').update(`${userId}\n${connectionId}`).digest('hex').slice(0, 16)}`
}
/**
* People and connection totals from a snapshot or event. Servers before collaboration 1.2.0 send
* neither `connections` nor `leases`: each person then counts as one connection.
*/
export function presencePeople(snapshot: Snapshot, firstSeen: (userId: string) => Date, ownConnectionId?: string): { people: PresencePerson[]; connections: number } {
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const leases = new Map<string, PresenceConnection[]>()
for (const l of snapshot.leases ?? []) {
if (l.userId === undefined || !l.connectionId) continue
const userId = String(l.userId)
const since = l.since ? new Date(l.since) : undefined
const entry: PresenceConnection = {
connectionId: l.connectionId, mode: l.mode ?? 'VIEWING', fieldName: l.fieldName ?? null,
...(since && !Number.isNaN(since.getTime()) ? { since } : {}), ...(l.sessionId ? { sessionId: l.sessionId } : {}),
...(ownConnectionId && l.connectionId === opaqueConnectionId(userId, ownConnectionId) ? { self: true } : {}),
}
leases.set(userId, [...(leases.get(userId) ?? []), entry])
}
const people = viewers.map((v) => {
const userId = String(v.userId)
const own = leases.get(userId)
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : own?.length || 1
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen(userId), connections, ...(own ? { leases: own } : {}) }
})
const sum = people.reduce((n, p) => n + p.connections, 0)
return { people, connections: typeof snapshot.leaseCount === 'number' ? Math.max(snapshot.leaseCount, people.length) : sum }
}
/** ` ×2` after a person with more than one connection, else nothing. */
export function connectionsSuffix(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
/** GET /presence query for a scope; `perConnection` only when asked (older servers ignore it). */
function presenceQuery(scope: PresenceScope, perConnection?: boolean): Record<string, string | undefined> {
return { table: scope.table, pk: scope.pk, ...(perConnection ? { perConnection: 'true' } : {}) }
}
/** The channel presence events for a scope arrive on. */
@@ -281,13 +356,15 @@ export class Collaboration {
}
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
async presenceSnapshot(scope: PresenceScope, options: { perConnection?: boolean } = {}): Promise<PresenceState> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
if (!state.available) return state
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot
const now = new Date()
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
state.count = state.people.length
const { people, connections } = presencePeople(snapshot, () => now)
state.people = people
state.count = people.length
state.connections = connections
return state
}
@@ -297,7 +374,7 @@ export class Collaboration {
* at once with a no-op handle when the extension is absent.
*/
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
if (!state.available) {
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
@@ -313,22 +390,22 @@ export class Collaboration {
const apply = (snapshot: Snapshot) => {
const now = new Date()
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
const ids = new Set(viewers.map((v) => String(v.userId)))
const ids = new Set((snapshot.viewers ?? []).filter((v) => v.userId !== undefined).map((v) => String(v.userId)))
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
state.people = viewers.map((v) => {
const userId = String(v.userId)
const { people, connections } = presencePeople(snapshot, (userId) => {
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
})
state.count = state.people.length
return firstSeen.get(userId)!
}, announce ? connectionId : undefined)
state.people = people
state.count = people.length
state.connections = connections
state.error = undefined
notify()
}
const refresh = async () => {
try {
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
apply((await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot)
} catch (error) {
state.error = messageOf(error)
notify()
@@ -341,8 +418,9 @@ export class Collaboration {
try {
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
const data = result.data as Snapshot | undefined
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
if (data && typeof data.table === 'string') {
// The qbit answers the heartbeat with the merged snapshot; older builds answer {success}.
// Per-connection detail only comes with GET, so re-read rather than drop it.
if (data && typeof data.table === 'string' && !options.perConnection) {
apply(data)
return
}
+63 -1
View File
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
import type { CallInput, CallResult } from '../api/call.js'
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { Collaboration, connectionsSuffix, opaqueConnectionId, presenceChannel, presencePeople, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
@@ -164,3 +164,65 @@ test('tags: vocabulary search, create, record tags and apply call the qbit opera
assert.deepEqual(calls[1]!.input.json, { table: 'person', label: 'New one', color: 'teal' })
assert.deepEqual(calls[3]!.input, { path: { table: 'person', pk: '3' }, json: { add: [9] } })
})
test('two connections of one user: one person with connections 2, and per connection each mode, field and start', async () => {
const leases = [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-mine'), sessionId: 's1', since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-other'), sessionId: 's2', since: '2026-11-01T12:00:05Z' },
]
const { calls, transport } = fake((id, input) => {
if (id === 'deleteQbitCollaborationPresence') return { success: true }
const per = (input.query as Record<string, string | undefined> | undefined)?.perConnection === 'true'
return { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 2 }], leaseCount: 2, ...(per ? { leases } : {}) }
})
const collab = new Collaboration(withCollab, transport)
const merged = await collab.presenceSnapshot({ table: 'person', pk: '1' })
assert.deepEqual([merged.count, merged.connections, merged.people[0]!.connections, merged.people[0]!.leases], [1, 2, 2, undefined])
assert.equal(connectionsSuffix(merged.people[0]!), ' ×2')
assert.equal((calls.at(-1)!.input.query as Record<string, unknown>).perConnection, undefined, 'merged by default')
const handle = await collab.joinPresence({ table: 'person', pk: '1' }, { perConnection: true, connectionId: 'cli-mine', heartbeatMs: 10000 })
try {
const admin = handle.people[0]!
assert.equal(admin.connections, 2)
assert.deepEqual(admin.leases!.map((l) => [l.mode, l.fieldName, l.since?.toISOString(), l.self ?? false]), [
['VIEWING', null, '2026-11-01T12:00:00.000Z', true],
['EDITING', 'name', '2026-11-01T12:00:05.000Z', false],
])
assert.deepEqual(calls.filter((c) => c.id === 'getQbitCollaborationPresence').at(-1)!.input.query, { table: 'person', pk: '1', perConnection: 'true' })
} finally {
await handle.stop()
}
})
test('servers before collaboration 1.2.0 send no connections: each person is one connection', async () => {
const { transport } = fake(() => ({ table: 'person', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'bob', displayName: 'Bob' }] }))
const state = await new Collaboration(withCollab, transport).presenceSnapshot({ table: 'person' }, { perConnection: true })
assert.deepEqual([state.count, state.connections], [2, 2])
assert.deepEqual(state.people.map((p) => [p.connections, p.leases, connectionsSuffix(p)]), [[1, undefined, ''], [1, undefined, '']])
const fromEvent = presencePeople({ table: 'person', viewers: [{ userId: 'x', displayName: 'X', connections: 3 }], leaseCount: 3 }, () => new Date(0))
assert.deepEqual([fromEvent.connections, fromEvent.people[0]!.connections], [3, 3])
})
test('presence.changed with leases updates connections live', async () => {
const events: RealtimeMessage[] = [
{ event: 'hello', data: { connectionId: 'sse-1' } },
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }], leaseCount: 2,
leases: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c1', since: '2026-11-01T12:00:00Z' }, { userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c2', since: '2026-11-01T12:00:01Z' }] } },
]
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
let next = 0
while (!signal.aborted) {
await tick(2)
if (next < events.length) yield events[next++]!
}
}
const { transport } = fake((id, input) => (id === 'updateRealtimeSubscriptions' ? { active: (input.json as { add: string[] }).add } : { table: 'person', pk: '1', viewers: [] }), stream)
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
try {
await until('the event with two connections', () => handle.state.connections === 2)
assert.equal(handle.people[0]!.leases?.length, 2)
} finally {
await handle.stop()
}
})
+2 -2
View File
@@ -210,10 +210,10 @@ export class RecordCollaboration {
}
/** Who is on the record (the actions entry): everyone the server reports, this user marked "(you)". */
export function viewingText(everyone: Array<{ name: string; self?: boolean }> | undefined): string {
export function viewingText(everyone: Array<{ name: string; self?: boolean; connections?: number }> | undefined): string {
if (!everyone) return 'Viewing now: …'
if (!everyone.length) return 'Viewing now: no one else'
const names = everyone.map((p) => (p.self ? `${p.name} (you)` : p.name))
const names = everyone.map((p) => `${p.name}${p.connections && p.connections > 1 ? ` ×${p.connections}` : ''}${p.self ? ' (you)' : ''}`)
const others = everyone.filter((p) => !p.self).length
return `Viewing now: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${others ? ` · ${others} other${others === 1 ? '' : 's'}` : ''}`
}
+25 -2
View File
@@ -20,6 +20,24 @@ export interface PresencePerson {
/** VIEWING or EDITING (the server's mode), and the field being edited when it says. */
mode?: string
fieldName?: string | null
/** Live connections (tabs, terminals) of this person; absent or 1 before collaboration 1.2.0. */
connections?: number
/** Each connection's mode, field and start when the server sends them (collaboration 1.2.0). */
terminals?: PresenceTerminal[]
}
/** One connection of a person: what that tab or terminal does. */
export interface PresenceTerminal {
mode?: string
fieldName?: string | null
since?: string
/** This terminal. */
self?: boolean
}
/** ` ×2` after a person with more than one connection. */
export function timesText(p: { connections?: number }): string {
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
}
export interface Presence {
@@ -64,7 +82,7 @@ export function usePresence(scope: string | undefined): Presence {
/** Title-bar text for presence, empty when nobody else is there. */
export function presenceText(p: Presence): string {
if (!p.count) return ''
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
const names = p.people.slice(0, 3).map((x) => `${x.name}${timesText(x)}`).join(', ')
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
}
@@ -94,8 +112,13 @@ export function collaborationPresence(registry: ExtensionRegistry, client: TuiCl
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
current = entry
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
perConnection: true,
onChange: (state) => {
const everyone = state.people.map((p) => ({ name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName }))
const everyone = state.people.map((p) => ({
name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName,
...(p.connections && p.connections > 1 ? { connections: p.connections } : {}),
...(p.leases?.length ? { terminals: p.leases.map((l) => ({ mode: l.mode, fieldName: l.fieldName, since: l.since?.toISOString(), ...(l.self ? { self: true } : {}) })) } : {}),
}))
const people = everyone.filter((p) => !p.self).map(({ self: _self, ...person }) => person)
entry.value = { count: people.length, people, everyone }
notify()
+25 -10
View File
@@ -51,6 +51,8 @@ interface Viewer {
userId: string
displayName: string
mode?: string
/** Live connections (tabs, terminals) of this user on the table; absent before collaboration 1.2.0. */
connections?: number
}
/** Sessions that count as online: active in the last 10 minutes. */
@@ -64,11 +66,13 @@ function userLabel(userId: string): string {
* Who is online: signed-in sessions from the session store's table when the server has one and it
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
* while the viewer is signed in.
* (the saver keeps its lease, so the signed-in viewer is always one of them). Collaboration 1.2.0
* counts connections, so two terminals of one user are "2 online · you ×2"; older servers count each
* person once. Never "nobody around" while the viewer is signed in.
*/
export function presenceSource(ctx: SaverContext): TileSource {
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
// keyed by user and table: a user's terminals on different tables add up
const seen = new Map<string, { userId: string; name: string; table: string; mode?: string; connections?: number; self: boolean; at: number }>()
const skip = new Set<string>()
let cursor = 0
let sessionsGone = false
@@ -130,10 +134,9 @@ export function presenceSource(ctx: SaverContext): TileSource {
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
for (const v of snap.viewers ?? []) {
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
// this terminal is you (it keeps its lease while the saver shows): not a second person
if (you) continue
names.set(v.userId, v.displayName || v.userId)
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : undefined
if (!you) names.set(v.userId, v.displayName || v.userId)
seen.set(`${v.userId}\u0000${table}`, { userId: v.userId, name: v.displayName || v.userId, table, mode: v.mode, connections, self: you, at: t })
}
} catch (error) {
const status = (error as { status?: number }).status
@@ -146,9 +149,21 @@ export function presenceSource(ctx: SaverContext): TileSource {
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
const t = now()
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
const total = others.length + (signedIn ? 1 : 0)
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
// one entry per person, their connections summed over the tables they are on; a server
// before collaboration 1.2.0 sends no connections, and then each person counts once
const people = new Map<string, { name: string; table: string; mode?: string; connections: number; counted: boolean; self: boolean }>()
for (const v of seen.values()) {
const p = people.get(v.userId)
if (!p) people.set(v.userId, { name: v.name, table: v.table, mode: v.mode, connections: v.connections ?? 1, counted: v.connections !== undefined, self: v.self })
else if (p.counted && v.connections !== undefined) p.connections += v.connections
else p.connections = 1, p.counted = false
}
const others = [...people.values()].filter((p) => !p.self).sort((a, b) => a.name.localeCompare(b.name))
// this terminal is you (it keeps its lease while the saver shows); your other terminals count too
const yours = signedIn ? Math.max(1, [...people.values()].filter((p) => p.self).reduce((n, p) => n + p.connections, 0)) : 0
const times = (n: number) => (n > 1 ? ` ×${n}` : '')
const total = others.reduce((n, p) => n + p.connections, 0) + yours
const parts = [...(signedIn ? [`you${times(yours)}`] : []), ...others.map((p) => `${p.name}${times(p.connections)}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
},
+16 -5
View File
@@ -12,7 +12,7 @@
import type { TagEntry } from '@qrun-io/qqq-core'
import type { CollabSummary } from '../collab'
import type { PresencePerson } from '../presence'
import { timesText, type PresencePerson } from '../presence'
import { ago } from '../servers'
import { KEY_SEP, cellWidth, pad, trunc } from '../util/text'
@@ -77,7 +77,7 @@ export function tagsText(tags: TagEntry[] | undefined): string {
}
/** What a person is doing on the record: "editing email", "editing", "viewing". */
export function personState(p: PresencePerson): string {
export function personState(p: Pick<PresencePerson, 'mode' | 'fieldName'>): string {
return /edit/i.test(p.mode ?? '') ? `editing${p.fieldName ? ` ${p.fieldName}` : ''}` : 'viewing'
}
@@ -92,11 +92,11 @@ function viewingParts(state: CollabState): StripPart[] {
const others = othersOf(state)
// others (with what they do) take the place of "(you)": the strip is about who else is here
if (others.length) {
const named = others.slice(0, 2).map((p) => `${p.name} (${personState(p)})`).join(', ')
const named = others.slice(0, 2).map((p) => `${p.name}${timesText(p)} (${personState(p)})`).join(', ')
return [{ text: 'viewing: ' }, { text: named + (others.length > 2 ? ` +${others.length - 2}` : ''), em: true }]
}
const self = everyone.find((p) => p.self)
return [{ text: 'viewing: ' }, { text: self ? self.name : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
return [{ text: 'viewing: ' }, { text: self ? `${self.name}${timesText(self)}` : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
}
const count = (n: number | undefined, one: string, many = `${one}s`): StripPart[] => [{ text: n === undefined ? '…' : String(n), em: true }, { text: ` ${n === 1 ? one : many}` }]
@@ -193,10 +193,21 @@ export function panelRows(state: CollabState, width: number, height: number, now
else if (!everyone.length) add(' no one else', 'dim', 'viewing')
// others first, with what they are doing; this user last
const people: Array<PresencePerson & { self?: boolean }> = [...othersOf(state), ...(everyone ?? []).filter((x) => x.self)]
for (const p of people) add(` ${p.name}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
for (const p of people) {
add(` ${p.name}${timesText(p)}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
// with more than one terminal, what each does (this one first)
const terminals = p.terminals && p.terminals.length > 1 ? [...p.terminals].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self))) : []
terminals.forEach((x, i) => add(` ${x.self ? 'this terminal' : `#${i + 1}`} · ${personState(x)}${x.since ? ` · ${hhmm(x.since)}` : ''}`, 'dim', 'viewing'))
}
return rows.slice(0, Math.max(0, height))
}
/** A lease start as local "17:50". */
function hhmm(iso: string): string {
const at = new Date(iso)
return Number.isNaN(at.getTime()) ? iso : at.toTimeString().slice(0, 5)
}
/** A due date as "3 Nov" (and the year when it is not this one). */
function dueText(iso: string): string {
const at = new Date(iso)
+5 -3
View File
@@ -12,7 +12,7 @@ import { actionsOverlay } from '../overlays/actions'
import { TagOverlay, collaborationOf } from '../overlays/tags'
import { RecordCollaboration, uploadBlocked, viewingText, type CollabSummary } from '../collab'
import { CollabScreen, sharePreview, toggleFollow, uploadAttachment, type CollabView } from './collab'
import { presenceSource } from '../presence'
import { presenceSource, timesText } from '../presence'
import { canPrintTable, printScreen } from '../printing'
import { AuditScreen } from './audits'
import { DeveloperScreen } from './developer'
@@ -303,7 +303,9 @@ export class RecordScreen implements Screen {
/** Who else is on the record, in a dialog. */
showViewers(): void {
const viewers = this.viewers()
this.store.message(viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`, 'info', 'Presence')
const self = presenceSource().get(this.presenceScope()).everyone?.find((p) => p.self)
const yours = self?.connections && self.connections > 1 ? `\n\nYou have it open in ${self.connections} terminals.` : ''
this.store.message((viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`) + yours, 'info', 'Presence')
}
build(): void {
@@ -644,7 +646,7 @@ export class RecordScreen implements Screen {
/** The names of the others viewing this record (presence; this client is not counted). */
viewers(): string[] {
return presenceSource().get(this.presenceScope()).people.map((p) => p.name)
return presenceSource().get(this.presenceScope()).people.map((p) => `${p.name}${timesText(p)}`)
}
/** Opens comments, attachments, the timeline or tasks. */
+1
View File
@@ -243,6 +243,7 @@ test('collaboration: who is viewing (this user marked) and comment lines', () =>
expect(viewingText([{ name: 'Admin', self: true }])).toBe('Viewing now: Admin (you)')
expect(viewingText([{ name: 'Tester', self: true }, { name: 'Pat Smith' }])).toBe('Viewing now: Tester (you), Pat Smith · 1 other')
expect(viewingText(undefined)).toBe('Viewing now: …')
expect(viewingText([{ name: 'Admin', self: true, connections: 2 }, { name: 'Pat Smith', connections: 1 }])).toBe('Viewing now: Admin ×2 (you), Pat Smith · 1 other')
expect(commentLines({ authorDisplayName: 'Pat', body: 'hi @[Lee](user:7)', createdAt: undefined, replies: [{ authorDisplayName: 'Lee', body: 'ok' }] }, () => 'now')).toEqual(['Pat · now: hi @Lee', ' ↳ Lee · now: ok'])
})
@@ -0,0 +1,60 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { expect, test } from 'bun:test'
import { ExtensionRegistry, opaqueConnectionId } from '@qrun-io/qqq-core'
import { collaborationPresence, presenceText } from '../src/presence'
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.2.0' }] })
/** A client stub answering presence calls; records the GET queries. */
function client(answer: (query: Record<string, string | undefined>, own: string | undefined) => unknown) {
const queries: Array<Record<string, string | undefined>> = []
let own: string | undefined
return {
queries,
call: async (id: string, input: { query?: Record<string, string | undefined>; json?: { connectionId?: string } }) => {
if (input.json?.connectionId) own = input.json.connectionId
if (id === 'getQbitCollaborationPresence') queries.push(input.query ?? {})
const data = id === 'getQbitCollaborationPresence' ? answer(input.query ?? {}, own) : id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '7', viewers: [] }
return { status: 200, headers: new Headers(), data, contentType: 'application/json' }
},
}
}
async function until(condition: () => boolean): Promise<void> {
for (let i = 0; i < 500 && !condition(); i++) await new Promise((r) => setTimeout(r, 2))
}
test('the record presence asks per connection: "Admin ×2", this terminal marked among the terminals', async () => {
const c = client((query, own) => ({
table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }, { userId: 'pat', displayName: 'Pat', mode: 'VIEWING', connections: 2 }], leaseCount: 4,
...(query.perConnection === 'true' ? { leases: [
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', own ?? ''), since: '2026-11-01T12:00:00Z' },
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'cffff', since: '2026-11-01T12:00:01Z' },
] } : {}),
}))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(c.queries[0]!.perConnection).toBe('true')
const admin = p.everyone!.find((x) => x.self)!
expect(admin.connections).toBe(2)
expect(admin.terminals!.map((t) => [t.mode, t.self ?? false])).toEqual([['VIEWING', true], ['EDITING', false]])
expect(presenceText(p)).toBe('1 other viewing: Pat ×2')
source.enter!(undefined)
})
test('a server without connections (collaboration 1.1.0) still shows everyone, once each', async () => {
const c = client(() => ({ table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'pat', displayName: 'Pat', mode: 'EDITING' }] }))
const source = collaborationPresence(registry, c as never, 'Admin')!
source.enter!('record:person:7')
await until(() => Boolean(source.get('record:person:7').everyone?.length))
const p = source.get('record:person:7')
expect(p.everyone!.map((x) => [x.name, x.connections, x.terminals])).toEqual([['Admin', undefined, undefined], ['Pat', undefined, undefined]])
expect(presenceText(p)).toBe('1 other viewing: Pat')
source.enter!(undefined)
})
+26
View File
@@ -93,3 +93,29 @@ test('panel rows: tags, latest two comments, tasks with the next due, files, fol
expect(rows.filter((r) => r.key === 'C').length).toBe(5)
expect(panelRows(busy, PANEL_WIDTH - 1, 5)).toHaveLength(5)
})
test('two terminals of one user: "×2" on the strip and in the panel, each terminal with its mode and start', () => {
const since = '2026-11-01T12:00:05Z'
const local = new Date(since).toTimeString().slice(0, 5)
const twice: CollabState = {
...alone,
everyone: [
{ name: 'Admin', self: true, mode: 'EDITING', fieldName: 'name', connections: 2, terminals: [{ mode: 'EDITING', fieldName: 'name', since }, { mode: 'VIEWING', self: true }] },
{ name: 'Pat Smith', mode: 'VIEWING', connections: 3 },
],
}
expect(text(twice, 200)).toContain('viewing: Pat Smith ×3 (viewing)')
expect(text({ ...alone, everyone: [twice.everyone![0]!] }, 200).trimEnd()).toEndWith('viewing: Admin ×2 (you)')
const lines = panelRows(twice, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(lines.slice(lines.indexOf(' Viewing now'))).toEqual([
' Viewing now',
' Pat Smith ×3 · viewing',
' Admin ×2 (you) · editing name',
' this terminal · viewing',
` #2 · editing name · ${local}`,
])
// a server before collaboration 1.2.0 sends no connections: no "×", no terminal lines
const old = panelRows(busy, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
expect(old.join('\n')).not.toContain('×')
expect(old.join('\n')).not.toContain('this terminal')
})
+16
View File
@@ -134,6 +134,22 @@ describe('sources', () => {
expect((await s.load()).value).toBe('1')
})
test('presence counts connections: two terminals of one user are 2 online; older servers count each person once', async () => {
const viewers: Record<string, Array<{ userId: string; displayName: string; mode: string; connections?: number }>> = {
person: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }, { userId: 'u:bob', displayName: 'Bob', mode: 'VIEWING', connections: 2 }],
order: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }],
}
const s = presenceSource(ctx({ data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
const v = await s.load()
expect(v.value).toBe('4')
expect(v.detail).toBe('4 online · you ×2, Bob ×2 · PERSON')
// only this terminal's own lease, no "connections" (collaboration 1.1.0): you, once
const old = presenceSource(ctx({ data: () => ({ viewers: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING' }] }) }))
const o = await old.load()
expect(o.value).toBe('1')
expect(o.detail).toBe('1 online · you')
})
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
const refuse = () => { throw new UiApiError(404, {}) }
// signed out: nothing to show once every table refuses
@@ -31,10 +31,10 @@
},
{
"name": "collaboration",
"version": "1.1.0",
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "develop",
"commit": "03407ae"
"version": "1.2.0",
"document": ".worktrees/qbit-collab-sessions/src/main/resources/openapi/qqq-v1/collaboration.yaml",
"branch": "feat/per-session-presence",
"commit": "0ae1e62"
},
{
"name": "data-analysis",
+47 -2
View File
@@ -73,7 +73,7 @@
},
{
"name": "collaboration",
"version": "1.1.0",
"version": "1.2.0",
"description": "Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence",
"pathPrefix": "/qbit/collaboration",
"operations": [
@@ -13972,6 +13972,15 @@
"schema": {
"type": "string"
}
},
{
"description": "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.",
"in": "query",
"name": "perConnection",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -41284,13 +41293,28 @@
"items": {
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
},
"type": "array",
"description": "Live viewers; always present, empty once the last viewer leaves.",
"nullable": false
},
"leaseCount": {
"description": "Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.",
"nullable": true,
"type": "integer"
},
"leases": {
"description": "One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.",
"items": {
"$ref": "#/components/schemas/CollaborationPresenceViewerV1"
},
"nullable": true,
"type": "array"
}
},
"type": "object"
},
"CollaborationPresenceViewerV1": {
"description": "One user merged across live tabs; EDITING takes precedence.",
"description": "One user merged across live tabs (EDITING takes precedence), or one live connection in leases.",
"properties": {
"displayName": {
"description": "Session display name.",
@@ -41315,6 +41339,27 @@
"description": "Stable user identity.",
"nullable": false,
"type": "string"
},
"connectionId": {
"description": "Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.",
"nullable": true,
"type": "string"
},
"connections": {
"description": "Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.",
"nullable": true,
"type": "integer"
},
"sessionId": {
"description": "Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.",
"nullable": true,
"type": "string"
},
"since": {
"description": "When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.",
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"type": "object"
+19 -1
View File
@@ -8936,9 +8936,14 @@ export interface components {
pk?: string | null;
/** @description Parent table name. */
table?: string;
/** @description Live viewers; always present, empty once the last viewer leaves. */
viewers?: components["schemas"]["CollaborationPresenceViewerV1"][];
/** @description Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0. */
leaseCount?: number | null;
/** @description One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0. */
leases?: components["schemas"]["CollaborationPresenceViewerV1"][] | null;
};
/** @description One user merged across live tabs; EDITING takes precedence. */
/** @description One user merged across live tabs (EDITING takes precedence), or one live connection in leases. */
CollaborationPresenceViewerV1: {
/** @description Session display name. */
displayName?: string;
@@ -8951,6 +8956,17 @@ export interface components {
mode?: "VIEWING" | "EDITING";
/** @description Stable user identity. */
userId?: string;
/** @description Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0. */
connectionId?: string | null;
/** @description Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0. */
connections?: number | null;
/** @description Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0. */
sessionId?: string | null;
/**
* Format: date-time
* @description When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.
*/
since?: string | null;
};
/** @description Most recent visible targets, at most fifty. */
CollaborationRecentListV1: {
@@ -19478,6 +19494,8 @@ export interface operations {
table: string;
/** @description Parent record primary key. */
pk?: string;
/** @description Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0. */
perConnection?: boolean;
};
header?: never;
path?: never;