@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
package/viewer/viewer.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// showing where it is.
|
|
6
6
|
import { attachBrowserPane, openBrowserMachine, openBrowserSession, sendBrowserTurn } from './channel.mjs';
|
|
7
7
|
import { StreamRpc } from './stream-rpc.mjs';
|
|
8
|
+
import { followTeamSession } from './session-follow.mjs';
|
|
8
9
|
|
|
9
10
|
const $ = (id) => document.getElementById(id);
|
|
10
11
|
const decoder = new TextDecoder();
|
|
@@ -60,12 +61,37 @@ function useLayout(layout) {
|
|
|
60
61
|
const rows = layout === 'rows';
|
|
61
62
|
$('session').classList.toggle('layout-rows', rows);
|
|
62
63
|
if (rows && !document.getElementById('brand-tokens')) {
|
|
63
|
-
|
|
64
|
-
document.head.append(link);
|
|
64
|
+
brandTokens();
|
|
65
65
|
// A chat's rows are light unless the host's theme names a scheme.
|
|
66
66
|
if (!themed) document.documentElement.style.setProperty('--scui-color-scheme', 'light');
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
// The brand's tokens stylesheet, loaded once; resolves when it has loaded (or failed to).
|
|
70
|
+
let brandLoaded = null;
|
|
71
|
+
function brandTokens() {
|
|
72
|
+
if (brandLoaded) return brandLoaded;
|
|
73
|
+
const link = Object.assign(document.createElement('link'), { id: 'brand-tokens', rel: 'stylesheet', href: 'https://brand.volter.ai/tokens.css' });
|
|
74
|
+
brandLoaded = new Promise((resolve) => { link.onload = link.onerror = resolve; });
|
|
75
|
+
document.head.append(link);
|
|
76
|
+
return brandLoaded;
|
|
77
|
+
}
|
|
78
|
+
// A pane looks like Terminal.app's Basic profile: the brand's terminal roles, resolved on the terminal element, which
|
|
79
|
+
// (like Terminal.app) follows the system's appearance. xterm takes concrete values, so each is read through a probe.
|
|
80
|
+
const TERMINAL_SLOTS = { background: 'background', foreground: 'foreground', cursor: 'cursor', selectionBackground: 'selection', black: 'black', red: 'red', green: 'green', yellow: 'yellow', blue: 'blue', magenta: 'magenta', cyan: 'cyan', white: 'white', brightBlack: 'bright-black', brightRed: 'bright-red', brightGreen: 'bright-green', brightYellow: 'bright-yellow', brightBlue: 'bright-blue', brightMagenta: 'bright-magenta', brightCyan: 'bright-cyan', brightWhite: 'bright-white' };
|
|
81
|
+
function terminalLook(element) {
|
|
82
|
+
const probe = document.createElement('span');
|
|
83
|
+
probe.hidden = true;
|
|
84
|
+
element.append(probe);
|
|
85
|
+
const theme = {};
|
|
86
|
+
for (const [slot, role] of Object.entries(TERMINAL_SLOTS)) {
|
|
87
|
+
probe.style.color = `var(--volter-terminal-${role})`;
|
|
88
|
+
theme[slot] = getComputedStyle(probe).color;
|
|
89
|
+
}
|
|
90
|
+
probe.style.font = 'var(--volter-font-size-terminal, 11px) var(--volter-font-terminal, ui-monospace, Menlo, monospace)';
|
|
91
|
+
const { fontFamily, fontSize } = getComputedStyle(probe);
|
|
92
|
+
probe.remove();
|
|
93
|
+
return { fontFamily, fontSize: Number.parseFloat(fontSize), theme };
|
|
94
|
+
}
|
|
69
95
|
// A fragment value that is base64url JSON (UTF-8), or null.
|
|
70
96
|
function fragmentJson(name) {
|
|
71
97
|
try {
|
|
@@ -155,7 +181,6 @@ async function showSession(session, control) {
|
|
|
155
181
|
} catch (error) { status(`Not delivered: ${error.message}`); return false; } finally { view.update({ sending: false }); }
|
|
156
182
|
},
|
|
157
183
|
});
|
|
158
|
-
const headers = async () => ({ Authorization: `Bearer ${await freshToken()}` });
|
|
159
184
|
applyLabels = () => view.update({ ...labels });
|
|
160
185
|
applyLabels();
|
|
161
186
|
const HARNESSES = { 'claude-code': 'Claude Code', codex: 'Codex', gemini: 'Gemini CLI', goose: 'Goose', opencode: 'OpenCode', pi: 'Pi', grok: 'Grok' };
|
|
@@ -170,56 +195,18 @@ async function showSession(session, control) {
|
|
|
170
195
|
scope: control ? 'control' : 'view', working: body.working ?? null,
|
|
171
196
|
} }, '*');
|
|
172
197
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return body;
|
|
181
|
-
} finally { reading = null; }
|
|
182
|
-
})());
|
|
183
|
-
// The snapshot is read whole (a busy session's is over the follow stream's 1 MB buffer, which ended the stream
|
|
184
|
-
// before it was sent); the follow stream then carries only the events after that snapshot's cursor, and each one
|
|
185
|
-
// means "read again". It ends when the token does or the connection drops, and is reopened from where it left off.
|
|
186
|
-
let cursor = null;
|
|
187
|
-
for (;;) {
|
|
188
|
-
try {
|
|
189
|
-
if (!cursor) cursor = String((await read()).event_cursor ?? '') || null;
|
|
190
|
-
const response = await fetch(`${base}/events?${cursor ? `after=${encodeURIComponent(cursor)}` : 'tail=1'}`, { headers: await headers() });
|
|
191
|
-
if (!response.ok) throw Object.assign(new Error(`following the session failed (${response.status})`), { stage: 'follow', status: response.status });
|
|
192
|
-
view.update({ live: true });
|
|
193
|
-
status('');
|
|
194
|
-
const reader = response.body.getReader();
|
|
195
|
-
let buffer = '';
|
|
196
|
-
for (;;) {
|
|
197
|
-
const { value, done } = await reader.read();
|
|
198
|
-
if (done) break;
|
|
199
|
-
buffer += decoder.decode(value, { stream: true });
|
|
200
|
-
let end;
|
|
201
|
-
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
202
|
-
const block = buffer.slice(0, end);
|
|
203
|
-
buffer = buffer.slice(end + 2);
|
|
204
|
-
const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
|
|
205
|
-
if (!data) continue;
|
|
206
|
-
const event = JSON.parse(data);
|
|
207
|
-
if (event.cursor) cursor = event.cursor;
|
|
208
|
-
// The server ends a stream it can no longer resume (a slow reader, a retired cursor): start from a snapshot.
|
|
209
|
-
if (event.terminal && event.type === 'resync_required') cursor = null;
|
|
210
|
-
if (event.type === 'snapshot') apply(event.data);
|
|
211
|
-
else if (event.type === 'session_changed') read().catch((error) => failed('read', error, error.status ?? null));
|
|
212
|
-
else if (event.type === 'session_deleted') { view.update({ live: false, status: 'error', error: 'This session was deleted.' }); return; }
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} catch (error) {
|
|
216
|
-
failed(error.stage ?? 'follow', error, error.status ?? null);
|
|
198
|
+
// The session, live: its snapshot, then its follow stream (session-follow.mjs, as a workplace drawing it itself uses).
|
|
199
|
+
followTeamSession({
|
|
200
|
+
origin: location.origin, teamId: session.team, sessionId: session.id, token: freshToken,
|
|
201
|
+
onSnapshot: apply,
|
|
202
|
+
onLive: (live) => { view.update({ live }); if (live) status(''); },
|
|
203
|
+
onError: ({ stage, status: code, message, snapshot }) => {
|
|
204
|
+
failed(stage, message, code);
|
|
217
205
|
// Nothing drawn yet: the transcript says why instead of loading forever.
|
|
218
|
-
if (!
|
|
219
|
-
}
|
|
220
|
-
view.update({ live: false })
|
|
221
|
-
|
|
222
|
-
}
|
|
206
|
+
if (!snapshot) view.update({ status: 'error', error: message });
|
|
207
|
+
},
|
|
208
|
+
onDeleted: () => view.update({ live: false, status: 'error', error: 'This session was deleted.' }),
|
|
209
|
+
});
|
|
223
210
|
}
|
|
224
211
|
|
|
225
212
|
async function showPane(connect, key, control) {
|
|
@@ -227,9 +214,10 @@ async function showPane(connect, key, control) {
|
|
|
227
214
|
import('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm'),
|
|
228
215
|
import('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/+esm'),
|
|
229
216
|
]);
|
|
217
|
+
await brandTokens();
|
|
230
218
|
const element = $('terminal');
|
|
231
219
|
element.hidden = false;
|
|
232
|
-
const terminal = new Terminal({
|
|
220
|
+
const terminal = new Terminal({ disableStdin: !control, scrollback: 5000, ...terminalLook(element) });
|
|
233
221
|
const fit = new FitAddon();
|
|
234
222
|
terminal.loadAddon(fit);
|
|
235
223
|
terminal.open(element);
|