@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/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
- const link = Object.assign(document.createElement('link'), { id: 'brand-tokens', rel: 'stylesheet', href: 'https://brand.volter.ai/tokens.css' });
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
- let reading = null;
174
- const read = () => (reading ??= (async () => {
175
- try {
176
- const response = await fetch(`${base}/messages?tail=300`, { headers: await headers() });
177
- if (!response.ok) throw Object.assign(new Error(`reading the session failed (${response.status})`), { stage: 'read', status: response.status });
178
- const body = await response.json();
179
- apply(body);
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 (!cursor) view.update({ status: 'error', error: error.message });
219
- }
220
- view.update({ live: false });
221
- await new Promise((resolve) => setTimeout(resolve, 2000));
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({ cursorBlink: control, disableStdin: !control, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 13, scrollback: 5000, theme: { background: '#0b0d10' } });
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);