@volter/teams 0.1.35 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/browser-channel.d.ts +59 -0
- package/package.json +11 -2
- package/server/viewer.mjs +2 -0
- package/session-follow.d.ts +50 -0
- package/session-follow.mjs +152 -0
- package/viewer/index.html +1 -1
- package/viewer/session-view-entry.mjs +1 -76
- package/viewer/session-view.css +6 -0
- package/viewer/session-view.mjs +29 -29
- package/viewer/viewer.mjs +41 -53
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** A tiny event emitter: the channel, its streams and a pane's terminal stream all speak it. */
|
|
2
|
+
export interface ChannelEvents {
|
|
3
|
+
on(event: string, listener: (...args: any[]) => void): this;
|
|
4
|
+
once(event: string, listener: (...args: any[]) => void): this;
|
|
5
|
+
off(event: string, listener: (...args: any[]) => void): this;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** One named stream of a channel (a door on the machine: `mail`, `terminal`, `node`, …). */
|
|
9
|
+
export interface BrowserStream extends ChannelEvents {
|
|
10
|
+
readonly id: number;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
closed: boolean;
|
|
13
|
+
/** Events: `data` (a frame's text), `close` (the reason, or null). */
|
|
14
|
+
write(data: string): void;
|
|
15
|
+
close(reason?: string | null): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A browser's channel to one machine, through its Teams server. Events: `hello` (the machine's greeting), `close`. */
|
|
19
|
+
export declare class BrowserChannel implements ChannelEvents {
|
|
20
|
+
constructor(socket: WebSocket);
|
|
21
|
+
hello: unknown;
|
|
22
|
+
on(event: string, listener: (...args: any[]) => void): this;
|
|
23
|
+
once(event: string, listener: (...args: any[]) => void): this;
|
|
24
|
+
off(event: string, listener: (...args: any[]) => void): this;
|
|
25
|
+
/** Open a named stream; resolves once the machine accepts it, rejects with its refusal. */
|
|
26
|
+
open(name: string, params?: Record<string, unknown>): Promise<BrowserStream>;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChannelOptions {
|
|
31
|
+
/** Defaults to the browser's WebSocket. */
|
|
32
|
+
WebSocketImpl?: typeof WebSocket;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Machine `machineId` of team `teamId` on the Teams server at `origin`, reached with the caller's own `token`. */
|
|
36
|
+
export function openBrowserMachine(target: { origin: string; teamId: string; machineId: string; token: string }, options?: ChannelOptions): Promise<BrowserChannel>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The machine running session `sessionId` of team `teamId`, reached with a workplace's session token (scope `control`)
|
|
40
|
+
* for a session its owner bound to the token's Room and Conversation: the server finds the machine.
|
|
41
|
+
*/
|
|
42
|
+
export function openBrowserSession(target: { origin: string; teamId: string; sessionId: string; token: string }, options?: ChannelOptions): Promise<BrowserChannel>;
|
|
43
|
+
|
|
44
|
+
/** The machine's answer to a typed turn. `waiting`: filed, typed when the session's composer is free. */
|
|
45
|
+
export type BrowserTurnAnswer =
|
|
46
|
+
| { code: 0; turn_id?: string; door?: 'runtime' | 'pane' | 'waiting'; text?: string }
|
|
47
|
+
| { code: number; text?: string };
|
|
48
|
+
|
|
49
|
+
/** One line typed into the channel's session as its user's own turn; rejects when the machine refuses the stream. */
|
|
50
|
+
export function sendBrowserTurn(channel: BrowserChannel, text: string): Promise<BrowserTurnAnswer>;
|
|
51
|
+
|
|
52
|
+
/** A pane's terminal stream. Events: `data` (text), `hello`, `notice`, `error-message`, `close`. */
|
|
53
|
+
export interface BrowserPane extends ChannelEvents {
|
|
54
|
+
write(text: string): void;
|
|
55
|
+
resize(cols: number, rows: number): void;
|
|
56
|
+
close(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function attachBrowserPane(channel: BrowserChannel, options: { pane: string; mode?: 'observe' | 'control'; cols?: number; rows?: number }): Promise<BrowserPane>;
|
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.mjs",
|
|
9
9
|
"./channel": "./channel.mjs",
|
|
10
|
-
"./browser-channel":
|
|
10
|
+
"./browser-channel": {
|
|
11
|
+
"types": "./browser-channel.d.ts",
|
|
12
|
+
"default": "./browser-channel.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./session-follow": {
|
|
15
|
+
"types": "./session-follow.d.ts",
|
|
16
|
+
"default": "./session-follow.mjs"
|
|
17
|
+
},
|
|
11
18
|
"./admission": "./admission.mjs",
|
|
12
19
|
"./identity": "./identity.mjs",
|
|
13
20
|
"./products": "./products.mjs",
|
|
@@ -31,6 +38,8 @@
|
|
|
31
38
|
"files": [
|
|
32
39
|
"LICENSE-MIT",
|
|
33
40
|
"*.mjs",
|
|
41
|
+
"browser-channel.d.ts",
|
|
42
|
+
"session-follow.d.ts",
|
|
34
43
|
"server",
|
|
35
44
|
"viewer",
|
|
36
45
|
"README.md"
|
package/server/viewer.mjs
CHANGED
|
@@ -10,6 +10,8 @@ const FILES = {
|
|
|
10
10
|
// The same module under its own name, as a workplace console imports it (openBrowserSession, sendBrowserTurn).
|
|
11
11
|
'/view/browser-channel.mjs': ['browser-channel.mjs', 'text/javascript; charset=utf-8'],
|
|
12
12
|
'/view/stream-rpc.mjs': ['stream-rpc.mjs', 'text/javascript; charset=utf-8'],
|
|
13
|
+
// A team session followed live (the viewer's, and a workplace's own drawing of it: @volter/teams/session-follow).
|
|
14
|
+
'/view/session-follow.mjs': ['session-follow.mjs', 'text/javascript; charset=utf-8'],
|
|
13
15
|
// A team session's surface: supercode-ui's transcript and composer, built by scripts/build-session-view.mjs.
|
|
14
16
|
'/view/session-view.mjs': ['viewer/session-view.mjs', 'text/javascript; charset=utf-8'],
|
|
15
17
|
'/view/session-view.css': ['viewer/session-view.css', 'text/css; charset=utf-8'],
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** One read of a session: its descriptor, its messages (oldest first), the names of its members and machines. */
|
|
2
|
+
export interface TeamSessionSnapshot {
|
|
3
|
+
descriptor: { id: string; harness: string; title?: string | null; user?: { principal_id?: string | null; reported_user?: string | null }; machine_ids?: string[]; [field: string]: unknown };
|
|
4
|
+
messages: Array<{ id: string; source_id?: string; native_key?: string; recorded_at?: number | null; value: { role: string; content?: unknown; metadata?: Record<string, unknown>; [field: string]: unknown } }>;
|
|
5
|
+
names?: { members?: Array<{ principal?: { id: string; display_name?: string } }>; machines?: Array<{ id: string; name?: string }> };
|
|
6
|
+
working?: unknown;
|
|
7
|
+
event_cursor?: string | number;
|
|
8
|
+
revision?: number;
|
|
9
|
+
[field: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FollowTeamSessionOptions {
|
|
13
|
+
origin: string;
|
|
14
|
+
teamId: string;
|
|
15
|
+
/** The session's id, or its harness's own (`claude-code:<id>`, or the id alone). */
|
|
16
|
+
sessionId: string;
|
|
17
|
+
/** The bearer to use now: a member's credential, or a workplace's delegated token refreshed as it runs out. */
|
|
18
|
+
token(): string | Promise<string>;
|
|
19
|
+
/** How many of the newest messages each read returns (1–500). */
|
|
20
|
+
tail?: number;
|
|
21
|
+
onSnapshot(snapshot: TeamSessionSnapshot): void;
|
|
22
|
+
onLive?(live: boolean): void;
|
|
23
|
+
/** A read or the follow failed; it retries by itself. `snapshot`: whether one was drawn before. */
|
|
24
|
+
onError?(failure: { stage: 'read' | 'follow'; status: number | null; message: string; snapshot?: boolean }): void;
|
|
25
|
+
onDeleted?(): void;
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
retryMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Follow one session: its snapshot read whole, then its follow stream from that snapshot's cursor, reopened as it drops. */
|
|
31
|
+
export function followTeamSession(options: FollowTeamSessionOptions): { stop(): void; read(): Promise<TeamSessionSnapshot> };
|
|
32
|
+
|
|
33
|
+
/** A message another session sent this one, from its envelope: its sender and body, or null for an ordinary turn. */
|
|
34
|
+
export function peerMessage(text: string): { name: string; address: string; body: string } | null;
|
|
35
|
+
|
|
36
|
+
export function personImage(seed?: string): string;
|
|
37
|
+
export function agentImage(seed: string): string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Who wrote each entry of a projected transcript (supercode-ui's `TranscriptEntryModel`s), from the messages' own
|
|
41
|
+
* annotations. Each actor carries `id` (a principal id, a session address or seed) for a host that draws its own faces.
|
|
42
|
+
*/
|
|
43
|
+
export function withPeople<Entry extends { role: string; text: string; ts: number | null; id: string }>(
|
|
44
|
+
transcript: Entry[],
|
|
45
|
+
messages: Array<{ role?: string; metadata?: Record<string, unknown> }>,
|
|
46
|
+
options?: { agentName?: string | null; sessionId?: string; personImage?(seed: string): string; agentImage?(seed: string): string; locale?: string | string[] },
|
|
47
|
+
): Entry[];
|
|
48
|
+
|
|
49
|
+
/** A session's message values as the harness SDK's normalized session, for supercode-ui's projection. */
|
|
50
|
+
export function normalizedSession(descriptor: { id: string; harness: string }, messages: unknown[]): Record<string, unknown>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// A team session, followed live from a browser (or any fetch host), and its messages as a transcript's authors: what
|
|
2
|
+
// the hosted viewer uses, exported so a workplace that draws the session itself (RH2's Conversation, supercode-ui in
|
|
3
|
+
// its own tree) reads and labels it the same way. No imports: it runs as the viewer's own module and in a host's bundle.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Follow one session: its snapshot read whole (`/messages?tail=`, a busy session's is over the follow stream's 1 MB
|
|
7
|
+
* buffer), then its follow stream from that snapshot's cursor, each change meaning "read again". The stream ends when
|
|
8
|
+
* the token does or the connection drops, and is reopened from where it left off. `token()` returns the bearer to use
|
|
9
|
+
* now (a workplace's delegated token, refreshed as it runs out). Returns `stop()`.
|
|
10
|
+
*
|
|
11
|
+
* onSnapshot(body) each read: { descriptor, messages, names, working, event_cursor, … }
|
|
12
|
+
* onLive(live) the follow stream opened (true) or dropped (false)
|
|
13
|
+
* onError({ stage, status, message }) a read or the follow failed (`stage`: read | follow); it retries by itself
|
|
14
|
+
* onDeleted() the session was deleted; following stops
|
|
15
|
+
*/
|
|
16
|
+
export function followTeamSession({ origin, teamId, sessionId, token, tail = 300, onSnapshot, onLive = () => {}, onError = () => {}, onDeleted = () => {}, fetchImpl = globalThis.fetch, retryMs = 2000 }) {
|
|
17
|
+
const base = `${origin}/api/v1/teams/${encodeURIComponent(teamId)}/sessions/${encodeURIComponent(sessionId)}`;
|
|
18
|
+
const decoder = new TextDecoder();
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const headers = async () => ({ Authorization: `Bearer ${await token()}` });
|
|
21
|
+
const failure = (stage, status, message) => Object.assign(new Error(message), { stage, status });
|
|
22
|
+
let reading = null;
|
|
23
|
+
const read = () => (reading ??= (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetchImpl(`${base}/messages?tail=${tail}`, { headers: await headers(), signal: controller.signal });
|
|
26
|
+
if (!response.ok) throw failure('read', response.status, `reading the session failed (${response.status})`);
|
|
27
|
+
const body = await response.json();
|
|
28
|
+
onSnapshot(body.data ?? body);
|
|
29
|
+
return body.data ?? body;
|
|
30
|
+
} finally { reading = null; }
|
|
31
|
+
})());
|
|
32
|
+
(async () => {
|
|
33
|
+
let cursor = null;
|
|
34
|
+
while (!controller.signal.aborted) {
|
|
35
|
+
try {
|
|
36
|
+
if (!cursor) cursor = String((await read()).event_cursor ?? '') || null;
|
|
37
|
+
const response = await fetchImpl(`${base}/events?${cursor ? `after=${encodeURIComponent(cursor)}` : 'tail=1'}`, { headers: await headers(), signal: controller.signal });
|
|
38
|
+
if (!response.ok) throw failure('follow', response.status, `following the session failed (${response.status})`);
|
|
39
|
+
onLive(true);
|
|
40
|
+
const reader = response.body.getReader();
|
|
41
|
+
let buffer = '';
|
|
42
|
+
for (;;) {
|
|
43
|
+
const { value, done } = await reader.read();
|
|
44
|
+
if (done) break;
|
|
45
|
+
buffer += decoder.decode(value, { stream: true });
|
|
46
|
+
let end;
|
|
47
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
48
|
+
const block = buffer.slice(0, end);
|
|
49
|
+
buffer = buffer.slice(end + 2);
|
|
50
|
+
const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
|
|
51
|
+
if (!data) continue;
|
|
52
|
+
const event = JSON.parse(data);
|
|
53
|
+
if (event.cursor) cursor = event.cursor;
|
|
54
|
+
// The server ends a stream it can no longer resume (a slow reader, a retired cursor): start from a snapshot.
|
|
55
|
+
if (event.terminal && event.type === 'resync_required') cursor = null;
|
|
56
|
+
if (event.type === 'snapshot') onSnapshot(event.data);
|
|
57
|
+
else if (event.type === 'session_changed') read().catch((error) => { if (!controller.signal.aborted) onError({ stage: error.stage ?? 'read', status: error.status ?? null, message: error.message }); });
|
|
58
|
+
else if (event.type === 'session_deleted') { onLive(false); onDeleted(); return; }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (controller.signal.aborted) return;
|
|
63
|
+
onError({ stage: error.stage ?? 'follow', status: error.status ?? null, message: error.message, snapshot: Boolean(cursor) });
|
|
64
|
+
}
|
|
65
|
+
onLive(false);
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
return { stop: () => controller.abort(), read };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A message another session sent this one, as its harness delivers it: an envelope naming the sender (a
|
|
74
|
+
* `cross-session-message` tag, the innermost one when the harness wraps it in its own) around the body. Returns
|
|
75
|
+
* { name, address, body }, or null for an ordinary turn.
|
|
76
|
+
*/
|
|
77
|
+
export function peerMessage(text) {
|
|
78
|
+
const close = text.search(/<\\?\/cross-session-message>/);
|
|
79
|
+
if (close < 0) return null;
|
|
80
|
+
const opens = [...text.slice(0, close).matchAll(/<\\?cross-session-message\b([^>]*)>/g)];
|
|
81
|
+
if (!opens.length) return null;
|
|
82
|
+
const inner = opens[opens.length - 1];
|
|
83
|
+
const attributes = (tag) => Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|
84
|
+
const own = attributes(inner[1]), outer = attributes(opens[0][1]);
|
|
85
|
+
const body = text.slice(inner.index + inner[0].length, close).trim();
|
|
86
|
+
return { name: own['from-name'] || outer['from-name'] || own.from || outer.from || 'Another session', address: own.from || outer.from || '', body };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A person's picture: the brand's person placeholder, seeded; an empty seed is its one standard anonymous face. */
|
|
90
|
+
export const personImage = (seed = '') => `https://brand.volter.ai/runner/svg?seed=${encodeURIComponent(seed)}&size=36&radius=18`;
|
|
91
|
+
/** An agent's picture (this session's own, a peer session's): the brand's agent mark, a rounded square, never a face. */
|
|
92
|
+
export const agentImage = (seed) => `https://brand.volter.ai/agent/svg?seed=${encodeURIComponent(seed)}&size=36&radius=7`;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Who wrote each entry of a projected transcript (supercode-ui's entries), from the messages' own annotations:
|
|
96
|
+
* this session's turns are its agent's (`agentName`); a peer session's message is that session's, its envelope kept
|
|
97
|
+
* as context; a Room member's turn is theirs (source_name / source_principal_id, recorded when they typed it), anyone
|
|
98
|
+
* unnamed the standard anonymous face. Each actor carries `id` (a principal id, a session address or seed), so a host
|
|
99
|
+
* may draw its own faces; `personImage`/`agentImage` choose the default pictures. One author's run within five minutes
|
|
100
|
+
* is `continued`, and each entry has a short local `timeLabel`.
|
|
101
|
+
*/
|
|
102
|
+
export function withPeople(transcript, messages, { agentName = null, sessionId = '', personImage: person = personImage, agentImage: agent = agentImage, locale = [] } = {}) {
|
|
103
|
+
const byTime = new Map();
|
|
104
|
+
for (const message of messages) {
|
|
105
|
+
if (message?.role !== 'user') continue;
|
|
106
|
+
const at = Date.parse(message.metadata?.timestamp ?? '');
|
|
107
|
+
if (Number.isFinite(at)) byTime.set(at, message.metadata);
|
|
108
|
+
}
|
|
109
|
+
const people = transcript.map((entry) => {
|
|
110
|
+
if (entry.role === 'assistant') return { ...entry, actor: { id: sessionId, ...(agentName ? { name: agentName } : {}), image: agent(sessionId), kind: 'agent' } };
|
|
111
|
+
if (entry.role !== 'user') return entry;
|
|
112
|
+
// A harness's delivery notice about a message is a compact system line, not a person's turn.
|
|
113
|
+
if (/^\s*\[Cross-session [a-z ]*notice\]/i.test(entry.text)) return { ...entry, role: 'notice', text: entry.text.trim().split('\n')[0] };
|
|
114
|
+
const peer = peerMessage(entry.text);
|
|
115
|
+
if (peer) {
|
|
116
|
+
const seed = peer.address.startsWith('sc:') ? peer.address : peer.name;
|
|
117
|
+
return { ...entry, text: peer.body, actor: { id: seed, name: peer.name, image: agent(seed), kind: 'agent' }, context: [{ id: `${entry.id}:envelope`, kind: 'envelope', label: 'Delivered by supercode', detail: entry.text }] };
|
|
118
|
+
}
|
|
119
|
+
const metadata = byTime.get(entry.ts) ?? {};
|
|
120
|
+
const name = metadata.source_name || null;
|
|
121
|
+
const seed = name ? (metadata.source_principal_id || name) : '';
|
|
122
|
+
return { ...entry, actor: { ...(seed ? { id: seed } : {}), name: name ?? 'Someone in the session', image: person(seed), kind: 'person' } };
|
|
123
|
+
});
|
|
124
|
+
let last = null;
|
|
125
|
+
return people.map((entry) => {
|
|
126
|
+
if (!entry.actor) return entry;
|
|
127
|
+
const author = `${entry.actor.kind ?? ''}:${entry.actor.name ?? entry.role}`;
|
|
128
|
+
const continued = last !== null && last.author === author && Number.isFinite(entry.ts) && Number.isFinite(last.ts) && entry.ts - last.ts < 5 * 60_000;
|
|
129
|
+
last = { author, ts: entry.ts };
|
|
130
|
+
const timeLabel = Number.isFinite(entry.ts) ? new Date(entry.ts).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }) : undefined;
|
|
131
|
+
return { ...entry, actor: { ...entry.actor, ...(continued ? { continued: true } : {}), ...(timeLabel ? { timeLabel } : {}) } };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const SOURCES = { 'claude-code': 'claude_code', codex: 'codex', gemini: 'gemini', goose: 'goose', grok: 'grok', opencode: 'opencode', pi: 'pi' };
|
|
136
|
+
|
|
137
|
+
/** A team session's captured messages (their values, oldest first) as the harness SDK's normalized session, for supercode-ui's projection. */
|
|
138
|
+
export function normalizedSession(descriptor, messages) {
|
|
139
|
+
return {
|
|
140
|
+
source: SOURCES[descriptor.harness] ?? 'native',
|
|
141
|
+
session_id: descriptor.id,
|
|
142
|
+
model: null,
|
|
143
|
+
cwd: null,
|
|
144
|
+
system_prompt: null,
|
|
145
|
+
agent_id: null,
|
|
146
|
+
parent_tool_use_id: null,
|
|
147
|
+
lineage: {},
|
|
148
|
+
trigger: 'human',
|
|
149
|
+
surface: null,
|
|
150
|
+
messages,
|
|
151
|
+
};
|
|
152
|
+
}
|
package/viewer/index.html
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
#status { padding: 4px 10px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--line); }
|
|
19
19
|
#status:empty { display: none; }
|
|
20
20
|
main { flex: 1; min-height: 0; display: flex; }
|
|
21
|
-
#terminal { flex: 1; min-width: 0; padding: 4px; }
|
|
21
|
+
#terminal { flex: 1; min-width: 0; padding: 4px; color-scheme: light dark; background: var(--volter-terminal-background); }
|
|
22
22
|
#folder { flex: 1; min-width: 0; display: none; }
|
|
23
23
|
#folder.open { display: flex; }
|
|
24
24
|
#entries { width: 280px; flex: none; border-right: 1px solid var(--line); overflow: auto; }
|
|
@@ -8,82 +8,7 @@ import { Composer } from '@volter/supercode-ui/preact/composer';
|
|
|
8
8
|
import { EMPTY_UI_STATE, normalizeUiState } from '@volter/supercode-ui';
|
|
9
9
|
import { projectSubagentTranscript } from '@volter/supercode-ui/controller';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
const personImage = (seed = '') => `https://brand.volter.ai/runner/svg?seed=${encodeURIComponent(seed)}&size=36&radius=18`;
|
|
13
|
-
// An agent's picture (this session's own, a peer session's): the brand's agent mark, a rounded square, never a face.
|
|
14
|
-
const agentImage = (seed) => `https://brand.volter.ai/agent/svg?seed=${encodeURIComponent(seed)}&size=36&radius=7`;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A message another session sent this one, as its harness delivers it: an envelope naming the sender (a
|
|
18
|
-
* `cross-session-message` tag, the innermost one when the harness wraps it in its own) around the body. Returns the
|
|
19
|
-
* sender and the body, or null for an ordinary turn.
|
|
20
|
-
*/
|
|
21
|
-
function peerMessage(text) {
|
|
22
|
-
const close = text.search(/<\\?\/cross-session-message>/);
|
|
23
|
-
if (close < 0) return null;
|
|
24
|
-
const opens = [...text.slice(0, close).matchAll(/<\\?cross-session-message\b([^>]*)>/g)];
|
|
25
|
-
if (!opens.length) return null;
|
|
26
|
-
const inner = opens[opens.length - 1];
|
|
27
|
-
const attributes = (tag) => Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
|
|
28
|
-
const own = attributes(inner[1]), outer = attributes(opens[0][1]);
|
|
29
|
-
const body = text.slice(inner.index + inner[0].length, close).trim();
|
|
30
|
-
return { name: own['from-name'] || outer['from-name'] || own.from || outer.from || 'Another session', address: own.from || outer.from || '', body };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Who wrote each user turn, from the message's own annotations (source_name / source_principal_id, recorded when a
|
|
35
|
-
* Room member typed it): their name and face, or, for anyone unnamed, the standard anonymous face. Never an agent mark.
|
|
36
|
-
*/
|
|
37
|
-
function withPeople(transcript, messages, { agentName = null, sessionId = '' } = {}) {
|
|
38
|
-
const byTime = new Map();
|
|
39
|
-
for (const message of messages) {
|
|
40
|
-
if (message?.role !== 'user') continue;
|
|
41
|
-
const at = Date.parse(message.metadata?.timestamp ?? '');
|
|
42
|
-
if (Number.isFinite(at)) byTime.set(at, message.metadata);
|
|
43
|
-
}
|
|
44
|
-
const people = transcript.map((entry) => {
|
|
45
|
-
// This session's own turns: named as the Room knows its agent, with the agent mark.
|
|
46
|
-
if (entry.role === 'assistant') return { ...entry, actor: { ...(agentName ? { name: agentName } : {}), image: agentImage(sessionId), kind: 'agent' } };
|
|
47
|
-
if (entry.role !== 'user') return entry;
|
|
48
|
-
// A harness's delivery notice about a message is a compact system line, not a person's turn.
|
|
49
|
-
if (/^\s*\[Cross-session [a-z ]*notice\]/i.test(entry.text)) return { ...entry, role: 'notice', text: entry.text.trim().split('\n')[0] };
|
|
50
|
-
const peer = peerMessage(entry.text);
|
|
51
|
-
if (peer) return { ...entry, text: peer.body, actor: { name: peer.name, image: agentImage(peer.address.startsWith('sc:') ? peer.address : peer.name), kind: 'agent' }, context: [{ id: `${entry.id}:envelope`, kind: 'envelope', label: 'Delivered by supercode', detail: entry.text }] };
|
|
52
|
-
const metadata = byTime.get(entry.ts) ?? {};
|
|
53
|
-
const name = metadata.source_name || null;
|
|
54
|
-
return { ...entry, actor: { name: name ?? 'Someone in the session', image: personImage(name ? (metadata.source_principal_id || name) : ''), kind: 'person' } };
|
|
55
|
-
});
|
|
56
|
-
// A row layout groups one author's run: a message by the same author within five minutes of their last is
|
|
57
|
-
// `continued` (no second avatar and name); a tool step does not break the run. Each shows a short local time.
|
|
58
|
-
let last = null;
|
|
59
|
-
return people.map((entry) => {
|
|
60
|
-
if (!entry.actor) return entry;
|
|
61
|
-
const author = `${entry.actor.kind ?? ''}:${entry.actor.name ?? entry.role}`;
|
|
62
|
-
const continued = last !== null && last.author === author && Number.isFinite(entry.ts) && Number.isFinite(last.ts) && entry.ts - last.ts < 5 * 60_000;
|
|
63
|
-
last = { author, ts: entry.ts };
|
|
64
|
-
const timeLabel = Number.isFinite(entry.ts) ? new Date(entry.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : undefined;
|
|
65
|
-
return { ...entry, actor: { ...entry.actor, ...(continued ? { continued: true } : {}), ...(timeLabel ? { timeLabel } : {}) } };
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const SOURCES = { 'claude-code': 'claude_code', codex: 'codex', gemini: 'gemini', goose: 'goose', grok: 'grok', opencode: 'opencode', pi: 'pi' };
|
|
70
|
-
|
|
71
|
-
/** A team session's captured messages as the harness SDK's normalized session, for supercode-ui's projection. */
|
|
72
|
-
function normalizedSession(descriptor, messages) {
|
|
73
|
-
return {
|
|
74
|
-
source: SOURCES[descriptor.harness] ?? 'native',
|
|
75
|
-
session_id: descriptor.id,
|
|
76
|
-
model: null,
|
|
77
|
-
cwd: null,
|
|
78
|
-
system_prompt: null,
|
|
79
|
-
agent_id: null,
|
|
80
|
-
parent_tool_use_id: null,
|
|
81
|
-
lineage: {},
|
|
82
|
-
trigger: 'human',
|
|
83
|
-
surface: null,
|
|
84
|
-
messages,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
11
|
+
import { withPeople, normalizedSession } from '../session-follow.mjs';
|
|
87
12
|
|
|
88
13
|
/**
|
|
89
14
|
* Mount one session into `element`. `update({ descriptor, messages, members, machines, live, status, error, sending })`
|
package/viewer/session-view.css
CHANGED
|
@@ -350,6 +350,12 @@
|
|
|
350
350
|
.scui-progress-step { height:2px; overflow:hidden; border-radius:2px; background:var(--scui-fill) }
|
|
351
351
|
.scui-progress-step[data-progress="done"] { background:var(--scui-accent) }
|
|
352
352
|
.scui-progress-step[data-progress="current"]::after { content:""; display:block; width:55%; height:100%; background:var(--scui-accent); animation:scui-progress 1s ease-in-out infinite alternate }
|
|
353
|
+
.scui-suggestions { list-style:none; margin:0 0 calc(6px * var(--scui-scaling)); padding:calc(3px * var(--scui-scaling)); max-height:calc(240px * var(--scui-scaling)); overflow-y:auto; border:1px solid var(--scui-border); border-radius:var(--scui-radius-md); background:var(--scui-bg-raised) }
|
|
354
|
+
.scui-suggestion { display:flex; align-items:center; gap:calc(8px * var(--scui-scaling)); padding:calc(5px * var(--scui-scaling)) calc(8px * var(--scui-scaling)); border-radius:var(--scui-radius-xs); color:var(--scui-fg); font-size:var(--scui-text-sm); cursor:pointer }
|
|
355
|
+
.scui-suggestion[data-active="true"] { background:var(--scui-fill) }
|
|
356
|
+
.scui-suggestion-image { width:20px; height:20px; border-radius:var(--scui-radius-xs); object-fit:cover; flex:0 0 auto }
|
|
357
|
+
.scui-suggestion-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
358
|
+
.scui-suggestion-detail { margin-left:auto; color:var(--scui-muted); font-size:var(--scui-text-xs); white-space:nowrap }
|
|
353
359
|
.scui-context-candidates { display:flex; gap:calc(5px * var(--scui-scaling)); margin-bottom:calc(6px * var(--scui-scaling)); overflow-x:auto; padding:1px; scrollbar-width:thin }
|
|
354
360
|
.scui-context-candidate { display:grid; grid-template-columns:auto minmax(0,1fr) auto; flex:0 0 auto; width:min(calc(180px * var(--scui-scaling)),55vw); min-height:calc(34px * var(--scui-scaling)); align-items:center; gap:calc(6px * var(--scui-scaling)); padding:calc(4px * var(--scui-scaling)) calc(6px * var(--scui-scaling)); border:1px solid var(--scui-border); border-radius:var(--scui-radius-md); background:var(--scui-bg); color:var(--scui-fg); text-align:left; cursor:pointer }
|
|
355
361
|
.scui-context-candidate:hover:not(:disabled) { border-color:var(--scui-border-strong); background:var(--scui-fill) }
|