@bivy/bivy 0.17.3-staging.2 → 0.17.3-staging.21

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.
@@ -1,23 +1,138 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { scanListeners } from "./listeners.js";
5
+ import { takeShots } from "./screenshot.js";
6
+ const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
7
+ /** How long a screenshot waits for a desktop app's first window. */
8
+ const WINDOW_WAIT_MS = 15_000;
1
9
  /** Composes view providers; registry never spawns processes and the gateway
2
10
  * never knows about terminals. New providers can extend open/remove here. */
11
+ /** A managed server that keeps exiting is left down after this many restarts
12
+ * in RESTART_WINDOW; opening the view again tries afresh. */
13
+ const MAX_RESTARTS = 5;
14
+ /** Compare keeps this many shots per view, taken after servers settle. */
15
+ const COMPARE_KEEP = 4;
16
+ const COMPARE_SETTLE_MS = 1_500;
17
+ const RESTART_WINDOW = 10 * 60_000;
3
18
  export class AppService {
4
19
  registry;
5
20
  gateway;
6
21
  terminals;
7
22
  terminalStarts = new Map();
8
- constructor(registry, gateway, terminals) {
23
+ servers = new Map();
24
+ scan;
25
+ serverWatchMs;
26
+ screenshots;
27
+ displays;
28
+ shooting = Promise.resolve();
29
+ constructor(registry, gateway, terminals, options = {}) {
9
30
  this.registry = registry;
10
31
  this.gateway = gateway;
11
32
  this.terminals = terminals;
33
+ this.scan = options.scan ?? scanListeners;
34
+ this.serverWatchMs = options.serverWatchMs ?? 5_000;
35
+ this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
36
+ this.displays = options.displays ?? NO_DISPLAYS;
12
37
  }
13
- list(sessionId) { return { apps: this.registry.list(sessionId), previewAvailable: Boolean(this.gateway) && this.gateway?.available !== false }; }
14
- publish(sessionId, workspace, manifest) { return this.registry.publish(sessionId, workspace, manifest); }
15
- async open(sessionId, appId, viewId, returnTo) {
38
+ /** A session's apps, or every app on this machine when no session is given. */
39
+ list(sessionId) {
40
+ const available = Boolean(this.gateway) && this.gateway?.available !== false;
41
+ const apps = this.registry.list(sessionId);
42
+ for (const app of apps)
43
+ for (const view of app.views) {
44
+ if (view.kind !== "web")
45
+ continue;
46
+ if (available)
47
+ view.address = this.gateway?.address?.(view.id);
48
+ const entry = this.registry.getView(view.id);
49
+ if (entry?.notes?.length)
50
+ view.notes = structuredClone(entry.notes);
51
+ if (entry?.stats)
52
+ view.stats = structuredClone(entry.stats);
53
+ }
54
+ return { apps, previewAvailable: available };
55
+ }
56
+ publish(sessionId, workspace, manifest) {
57
+ // Tell the agent now, not on first open, when this machine can't show it.
58
+ const unavailable = manifest?.views?.some?.((view) => view?.kind === "display") && this.displays.unavailable();
59
+ if (unavailable)
60
+ throw new Error(unavailable);
61
+ return this.registry.publish(sessionId, workspace, manifest);
62
+ }
63
+ /** An agent turn changed files in this session's workspace. With agent
64
+ * screenshots on, Compare gets an "after" shot once servers have rebuilt. */
65
+ turnChanged(sessionId) {
66
+ const bumped = this.registry.touch(sessionId);
67
+ // Desktop apps that asked for it are restarted, so they run the new code.
68
+ for (const id of bumped) {
69
+ const entry = this.registry.getView(id);
70
+ const server = this.servers.get(id);
71
+ if (entry?.target.kind !== "display" || !server?.termId)
72
+ continue;
73
+ this.terminals.close(server.termId);
74
+ server.termId = undefined;
75
+ void this.ensureServer(entry).catch(() => { });
76
+ }
77
+ if (bumped.length && this.screenshots.enabled())
78
+ setTimeout(() => void this.capture(bumped), COMPARE_SETTLE_MS).unref?.();
79
+ return bumped;
80
+ }
81
+ /** One phone-width shot per view of the page last viewed, kept for Compare. */
82
+ async capture(viewIds) {
83
+ for (const id of viewIds) {
84
+ const entry = this.registry.getView(id);
85
+ if (!entry || entry.view.kind !== "web")
86
+ continue;
87
+ await this.windowShown(entry).catch(() => { });
88
+ const outDir = path.join(os.tmpdir(), "bivy-shots", "compare", id);
89
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [390], themes: ["light"], path: entry.lastPath ?? "/" }, outDir));
90
+ this.shooting = run;
91
+ try {
92
+ const [shot] = await run;
93
+ if (!shot)
94
+ continue;
95
+ const png = fs.readFileSync(shot.file);
96
+ fs.rmSync(outDir, { recursive: true, force: true });
97
+ entry.shots = [...(entry.shots ?? []), { revision: entry.revision, at: Date.now(), png }].slice(-COMPARE_KEEP);
98
+ }
99
+ catch { /* a failed shot just leaves Compare without this turn */ }
100
+ }
101
+ }
102
+ /** Servers running in the workspace that this session doesn't preview yet. */
103
+ async offers(sessionId, workspace) {
104
+ const claimed = this.registry.claimedPorts(sessionId);
105
+ return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
106
+ }
107
+ /** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
108
+ async adopt(sessionId, workspace, port) {
109
+ const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
110
+ if (!offer)
111
+ throw new Error(`Nothing in this session's workspace is listening on port ${port} any more.`);
112
+ return this.registry.publish(sessionId, workspace, { version: 1, name: `${offer.command} · :${port}`.slice(0, 100), views: [{ kind: "web", name: `Port ${port}`, source: { kind: "service", port } }] });
113
+ }
114
+ /** `direct` opens the app origin itself (no shell): a signed-in device
115
+ * returning to a view's stable address. */
116
+ /** `scale`: the opening device's pixel density, used if this starts a display. */
117
+ async open(sessionId, appId, viewId, returnTo, direct = false, scale) {
16
118
  const entry = this.registry.requireView(sessionId, appId, viewId);
119
+ if (entry.target.kind === "display" && !entry.display)
120
+ entry.displayScale = scale === 2 ? 2 : 1;
17
121
  if (entry.view.kind === "web") {
18
122
  if (!this.gateway)
19
123
  throw new Error("Bivy's preview service is unavailable on this connection.");
20
- return { kind: "web", url: this.gateway.open(viewId, returnTo) };
124
+ if (direct && !this.gateway.openDirect)
125
+ throw new Error("This machine can't open previews directly.");
126
+ const url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
127
+ // Don't wait for a managed server to boot: the preview shows it starting
128
+ // and reloads itself once it answers.
129
+ if (this.servers.get(viewId))
130
+ this.servers.get(viewId).restarts = [];
131
+ void this.ensureServer(entry).catch(() => { });
132
+ // Compare needs a "before": take a baseline the first time it's opened.
133
+ if (!entry.shots?.length && this.screenshots.enabled())
134
+ setTimeout(() => void this.capture([viewId]), COMPARE_SETTLE_MS).unref?.();
135
+ return { kind: "web", url };
21
136
  }
22
137
  if (entry.target.kind !== "terminal")
23
138
  throw new Error("Unsupported app view provider.");
@@ -48,6 +163,125 @@ export class AppService {
48
163
  throw error;
49
164
  }
50
165
  }
166
+ /** Screenshots of a session's web views (one app, or all), for agents to
167
+ * check their own UI. One browser at a time node-wide; PNGs in a temp dir. */
168
+ async shot(sessionId, appId, input) {
169
+ if (!this.screenshots.enabled())
170
+ throw new Error("Agent screenshots are off on this machine. Turn on “Let agents screenshot their app previews” in Bivy → Settings → this machine, or run: bivy config set sessions.appScreenshots true");
171
+ const widths = input.widths ?? [390, 1280];
172
+ const themes = input.themes ?? ["light"];
173
+ const page = input.path ?? "/";
174
+ if (!Array.isArray(widths) || !widths.length || widths.length > 4 || widths.some((w) => !Number.isInteger(w) || w < 240 || w > 2560))
175
+ throw new Error("Widths must be 1–4 whole numbers between 240 and 2560.");
176
+ if (!Array.isArray(themes) || !themes.length || themes.some((t) => t !== "light" && t !== "dark"))
177
+ throw new Error("Themes must be light and/or dark.");
178
+ if (typeof page !== "string" || !page.startsWith("/") || page.startsWith("//") || page.length > 2048)
179
+ throw new Error("Path must start with /.");
180
+ const apps = appId ? [this.registry.require(sessionId, appId)] : this.registry.list(sessionId);
181
+ const views = apps.flatMap((app) => app.views).filter((view) => view.kind === "web").map((view) => this.registry.getView(view.id)).filter(Boolean);
182
+ if (!views.length)
183
+ throw new Error("This session has no web views to screenshot. Publish one with bivy app publish, or preview a detected server.");
184
+ await Promise.all(views.map((entry) => this.windowShown(entry)));
185
+ const outDir = path.join(os.tmpdir(), "bivy-shots", sessionId.replace(/[^A-Za-z0-9_-]/g, "_"), String(Date.now()));
186
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take(views, { widths, themes: [...new Set(themes)], path: page }, outDir));
187
+ this.shooting = run;
188
+ return { shots: await run };
189
+ }
190
+ /** The managed server's output, as an attachable terminal (starts it if needed). */
191
+ async logs(sessionId, appId, viewId) {
192
+ const entry = this.registry.requireView(sessionId, appId, viewId);
193
+ const termId = await this.ensureServer(entry);
194
+ if (!termId)
195
+ throw new Error("Only views with a start command have server logs.");
196
+ return { kind: "terminal", termId };
197
+ }
198
+ /** A desktop app's display is up and showing a window (or gave up waiting),
199
+ * so a screenshot shows the app rather than an empty screen. */
200
+ async windowShown(entry) {
201
+ if (entry.target.kind !== "display")
202
+ return;
203
+ await this.ensureServer(entry);
204
+ const display = await this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale);
205
+ for (const until = Date.now() + WINDOW_WAIT_MS; !display.wm.count && Date.now() < until;)
206
+ await new Promise((r) => setTimeout(r, 100));
207
+ await new Promise((r) => setTimeout(r, 800)); // first paint
208
+ }
209
+ /** The program Bivy runs for a view: a service's start command, or a desktop
210
+ * app on its display (started first). */
211
+ program(entry) {
212
+ const target = entry.target;
213
+ if (target.kind === "service" && target.start)
214
+ return { ...target.start, name: `${entry.app.name} · ${entry.view.name} server` };
215
+ if (target.kind !== "display")
216
+ throw new Error("This view has no program.");
217
+ return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
218
+ entry.display = display.socket;
219
+ return { command: target.command, args: target.args, workspace: target.workspace, env: display.env, name: `${entry.app.name} · ${entry.view.name}` };
220
+ });
221
+ }
222
+ /** Services start synchronously; a desktop app waits for its display. */
223
+ startProgram(entry) {
224
+ const program = this.program(entry);
225
+ return program instanceof Promise ? program.then((spec) => this.terminals.start(spec)) : this.terminals.start(program);
226
+ }
227
+ ensureServer(entry) {
228
+ if (!(entry.target.kind === "service" && entry.target.start) && entry.target.kind !== "display")
229
+ return Promise.resolve(undefined);
230
+ const id = entry.view.id;
231
+ let state = this.servers.get(id);
232
+ if (!state) {
233
+ state = { restarts: [] };
234
+ this.servers.set(id, state);
235
+ }
236
+ const server = state;
237
+ if (server.termId && this.terminals.has(server.termId))
238
+ return Promise.resolve(server.termId);
239
+ server.pending ??= this.startProgram(entry)
240
+ .then((termId) => {
241
+ if (!this.registry.getView(id)) {
242
+ this.terminals.close(termId);
243
+ throw new Error("App was removed while starting.");
244
+ }
245
+ server.termId = termId;
246
+ return termId;
247
+ })
248
+ .finally(() => { server.pending = undefined; });
249
+ // Watch for exits and restart, backing off a crash loop.
250
+ server.timer ??= setInterval(() => {
251
+ const current = this.registry.getView(id);
252
+ if (!current) {
253
+ this.stopServer(id);
254
+ return;
255
+ }
256
+ if (server.pending || !server.termId || this.terminals.has(server.termId))
257
+ return;
258
+ const now = Date.now();
259
+ server.restarts = server.restarts.filter((at) => now - at < RESTART_WINDOW);
260
+ if (server.restarts.length >= MAX_RESTARTS)
261
+ return;
262
+ server.restarts.push(now);
263
+ server.termId = undefined;
264
+ void this.ensureServer(current).catch(() => { });
265
+ }, this.serverWatchMs);
266
+ server.timer.unref?.();
267
+ return server.pending;
268
+ }
269
+ stopServer(id) {
270
+ this.displays.stop(id);
271
+ const entry = this.registry.getView(id);
272
+ if (entry) {
273
+ entry.display = undefined;
274
+ entry.displayScale = undefined;
275
+ }
276
+ const server = this.servers.get(id);
277
+ if (!server)
278
+ return;
279
+ clearInterval(server.timer);
280
+ if (server.termId)
281
+ this.terminals.close(server.termId);
282
+ void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
283
+ this.servers.delete(id);
284
+ }
51
285
  share(sessionId, appId, viewId) {
52
286
  if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
53
287
  throw new Error("Only web views have preview links.");
@@ -55,6 +289,10 @@ export class AppService {
55
289
  throw new Error("Bivy's preview service is unavailable on this connection.");
56
290
  return this.gateway.share(viewId);
57
291
  }
292
+ clearNotes(sessionId, appId, viewId) {
293
+ delete this.registry.requireView(sessionId, appId, viewId).notes;
294
+ return { ok: true };
295
+ }
58
296
  /** Ends every link, browser session and open connection for one view; the app stays. */
59
297
  revoke(sessionId, appId, viewId) {
60
298
  this.registry.requireView(sessionId, appId, viewId);
@@ -66,6 +304,7 @@ export class AppService {
66
304
  this.registry.remove(sessionId, appId);
67
305
  for (const view of app.views) {
68
306
  this.gateway?.revoke(view.id);
307
+ this.stopServer(view.id);
69
308
  const pending = this.terminalStarts.get(view.id);
70
309
  this.terminalStarts.delete(view.id);
71
310
  if (pending)
@@ -551,6 +551,8 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
551
551
  .hljs-addition { color: var(--syntax-addition); }
552
552
  .msg.system { align-self: center; color: var(--muted); font-size: 13px; font-style: italic; display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; }
553
553
  .msg.system .system-text { display: block; }
554
+ .entry-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); font-style: normal; }
555
+ .msg.system .entry-actions { justify-content: center; }
554
556
  .msg.thinking { align-self: flex-start; color: var(--muted); font-size: 13px; border-left: 2px solid var(--line-strong); padding-left: 10px; }
555
557
  .msg.error {
556
558
  align-self: stretch; display: flex; gap: 8px; align-items: flex-start;
@@ -586,6 +588,12 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
586
588
  discoverability pattern as .row-menu-btn — and a touch-friendly baseline
587
589
  opacity on devices with no hover. */
588
590
  .assistant-row { align-self: stretch; width: 100%; max-width: 680px; }
591
+ /* The message "Show in chat" landed on (Artifacts / Apps pages). */
592
+ .is-jump-target { border-radius: var(--radius-md); animation: jump-target 2.4s var(--ease-standard); }
593
+ @keyframes jump-target {
594
+ 0%, 50% { background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: 0 0 0 var(--space-2) color-mix(in srgb, var(--accent) 10%, transparent); }
595
+ 100% { background: transparent; box-shadow: 0 0 0 var(--space-2) transparent; }
596
+ }
589
597
  .msg-actions { display: flex; align-items: center; gap: 2px; margin-top: var(--space-1); margin-left: -6px; }
590
598
  .msg-copy-btn, .msg-speak-btn {
591
599
  border: 0; background: transparent; color: var(--muted);
@@ -1269,6 +1277,7 @@ button.sheet-action:last-child { border-bottom: 0; }
1269
1277
  .fork-select-field select { width: 100%; padding: 10px 12px; border: 1px solid var(--line-strong); border-radius: var(--radius-md); background: var(--surface); color: var(--ink); }
1270
1278
  .fork-toggle { display: flex; align-items: center; gap: 8px; padding: 10px 12px; font-size: 13px; color: var(--ink); }
1271
1279
  .fork-toggle input { width: 16px; height: 16px; }
1280
+ .fork-note { margin: 0; padding: var(--space-2) var(--space-3); color: var(--muted); font-size: var(--text-sm); line-height: var(--lh-sm); }
1272
1281
  .fork-error { margin: 4px 12px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--surface-2); color: var(--danger); font-size: 12px; }
1273
1282
  .fork-warn { margin: 4px 12px; padding: 8px 10px; border-radius: var(--radius-md); background: var(--surface-2); color: var(--muted); font-size: 12px; }
1274
1283
  .fork-submit { width: calc(100% - 24px); margin: var(--space-2) var(--space-3); }
@@ -1393,7 +1402,12 @@ button.sheet-action:last-child { border-bottom: 0; }
1393
1402
  font-weight: var(--weight-semibold); cursor: pointer; white-space: nowrap;
1394
1403
  }
1395
1404
  .seg-btn:hover { color: var(--ink); }
1396
- .seg-btn[aria-selected="true"] { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); }
1405
+ .seg-btn[aria-selected="true"], .seg-btn[aria-checked="true"] { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); }
1406
+ /* Chat | Terminal in the session topbar (SessionViewToggle). */
1407
+ .topbar-subline { display: flex; align-items: center; gap: var(--space-3); min-width: 0; margin-top: 2px; }
1408
+ .topbar-subline .node-switcher { flex: 0 1 auto; }
1409
+ .session-view-toggle { flex: none; padding: 2px; }
1410
+ .session-view-toggle .seg-btn { display: inline-flex; align-items: center; gap: var(--space-1); padding: 2px var(--space-2); line-height: 1.25; }
1397
1411
  .automations-tabs {
1398
1412
  flex: none; align-self: center; max-width: calc(100% - var(--space-6));
1399
1413
  margin: var(--space-2) auto 0; overflow-x: auto;
@@ -1409,6 +1423,51 @@ button.sheet-action:last-child { border-bottom: 0; }
1409
1423
  .automations-view-head-actions > .btn.primary { padding: 9px 18px; }
1410
1424
  .automations-view-body { padding: 8px 24px max(48px, env(safe-area-inset-bottom)); }
1411
1425
  }
1426
+ /* Artifacts / Apps pages (LibraryView.tsx) share the Automations page shell:
1427
+ a quiet image grid for files, a plain list for apps. */
1428
+ .library-view:focus { outline: none; }
1429
+ .library-view .automations-view-head { width: 100%; max-width: 1080px; margin: 0 auto; box-sizing: border-box; }
1430
+ .library-body { max-width: 1080px; }
1431
+ .library-toolbar { display: flex; margin: var(--space-2) 0 var(--space-4); }
1432
+ .library-loading { display: flex; justify-content: center; padding: var(--space-8) 0; }
1433
+ .library-body > .banner { margin: var(--space-2) 0; }
1434
+ .library-note { margin: var(--space-2) 0; font-size: var(--text-sm); color: var(--muted); }
1435
+ .library-grid {
1436
+ list-style: none; margin: var(--space-2) 0 0; padding: 0;
1437
+ display: grid; gap: var(--space-5) var(--space-3);
1438
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1439
+ }
1440
+ .library-tile { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
1441
+ .library-tile-preview {
1442
+ width: 100%; aspect-ratio: 4 / 3; padding: 0; overflow: hidden;
1443
+ display: grid; place-items: center;
1444
+ border: 1px solid var(--line); border-radius: var(--radius-lg);
1445
+ background: var(--surface-2); color: var(--muted); font: inherit; cursor: pointer;
1446
+ transition: border-color var(--dur-fast) var(--ease-standard);
1447
+ }
1448
+ .library-tile-preview:hover:not(:disabled) { border-color: var(--line-strong); }
1449
+ .library-tile-preview:disabled { cursor: progress; }
1450
+ .library-tile-preview img { display: block; width: 100%; height: 100%; object-fit: cover; }
1451
+ .library-tile-file { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); }
1452
+ .library-tile-ext { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; }
1453
+ .library-tile-caption { display: flex; flex-direction: column; gap: 2px; min-width: 0; padding: 0 2px; }
1454
+ .library-tile-name {
1455
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1456
+ font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--ink);
1457
+ }
1458
+ .library-tile-note { font-size: var(--text-xs); color: var(--danger); }
1459
+ .library-source {
1460
+ align-self: flex-start; display: inline-flex; align-items: center; gap: var(--space-1);
1461
+ max-width: 100%; min-width: 0; min-height: 28px; padding: 0; border: 0; background: transparent;
1462
+ color: var(--muted); font: inherit; font-size: var(--text-xs); text-align: left; cursor: pointer;
1463
+ }
1464
+ .library-source:hover { color: var(--ink); }
1465
+ .library-source svg, .library-source-when { flex: none; }
1466
+ .library-source-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1467
+ @media (min-width: 640px) {
1468
+ .library-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-6) var(--space-4); }
1469
+ }
1470
+
1412
1471
  .autom-section { margin-top: 26px; }
1413
1472
  .autom-section-head {
1414
1473
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
@@ -2443,6 +2502,12 @@ textarea.picker-search { resize: vertical; font-family: ui-monospace, monospace;
2443
2502
  height: var(--app-h, 100dvh); z-index: var(--z-sheet); display: flex; flex-direction: column;
2444
2503
  background: var(--bg); padding-top: env(safe-area-inset-top);
2445
2504
  }
2505
+ /* The same terminal inside the session pane (Chat | Terminal toggle): in the
2506
+ main column's flow under the topbar, not a fixed full-screen layer. */
2507
+ .term-overlay.term-embedded {
2508
+ position: relative; top: auto; left: auto; width: auto; height: auto;
2509
+ flex: 1 1 auto; min-height: 0; z-index: auto; padding-top: 0;
2510
+ }
2446
2511
  .term-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
2447
2512
  .term-title { font-weight: 700; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2448
2513
  .term-status { color: var(--muted); font-weight: 400; margin-left: 8px; font-size: 12px; }
@@ -2490,6 +2555,10 @@ textarea.picker-search { resize: vertical; font-family: ui-monospace, monospace;
2490
2555
  }
2491
2556
  .term-out .xterm { height: 100%; }
2492
2557
  .term-out .xterm-viewport { overflow-y: auto; overscroll-behavior: contain; }
2558
+ /* xterm.css paints the viewport #000; the GPU renderers cover it with the theme
2559
+ background, the DOM fallback does not — a light-theme sliver below the last
2560
+ row. Match readTheme()'s background. */
2561
+ .term-out .xterm .xterm-viewport { background-color: var(--bg); }
2493
2562
  /* Smooth momentum scrolling for the terminal viewport on touch devices. */
2494
2563
  .term-out .xterm-viewport { -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
2495
2564
 
@@ -2850,6 +2919,9 @@ select.picker-search { appearance: auto; }
2850
2919
  .scrim { position: fixed; inset: 0; background: var(--overlay); z-index: var(--z-dropdown); }
2851
2920
  @media (max-width: 900px) {
2852
2921
  .app { grid-template-columns: 1fr; }
2922
+ /* Share the subtitle line with the machine name: icons only (the accessible
2923
+ name stays via aria-label). */
2924
+ .session-view-label { display: none; }
2853
2925
  .only-mobile { display: inline-flex; }
2854
2926
  .sidebar {
2855
2927
  position: fixed; top: var(--app-top, 0px); left: 0; height: var(--app-h, 100dvh);
@@ -2951,15 +3023,53 @@ select.picker-search { appearance: auto; }
2951
3023
 
2952
3024
  /* Artifacts sheet (components/ArtifactsSheet.tsx) — agent-sent attachments,
2953
3025
  user uploads, and resolved inline images grouped by kind. */
2954
- .app-views-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
2955
- .app-views-heading .artifact-name, .app-message .artifact-name { white-space: normal; overflow-wrap: anywhere; }
2956
- .app-view-row { flex-wrap: wrap; }
2957
- .app-views-heading > .btn, .app-view-actions .btn { min-block-size: var(--space-7); flex-shrink: 0; }
2958
- .app-view-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); margin-inline-start: auto; }
2959
- .app-view-notice { display: flex; flex-direction: column; gap: var(--space-2); flex-basis: 100%; min-width: 0; }
3026
+ /* An app, a view of it, or a server that could become one (AppRow.tsx): the
3027
+ same tile, type and trailing action in the chat card, the Apps page and the
3028
+ Apps sheet. Views nest inside their app's card as small rows. */
3029
+ .apps-card { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
3030
+ .apps-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
3031
+ .app-row { display: flex; align-items: center; gap: var(--space-3); min-width: 0; padding: var(--space-3); }
3032
+ .app-tile { flex: none; display: grid; place-items: center; inline-size: 40px; block-size: 40px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); color: var(--ink); font-weight: var(--weight-semibold); }
3033
+ .app-row.small .app-tile { inline-size: 36px; block-size: 36px; border-radius: var(--radius-sm); color: var(--muted); }
3034
+ .app-tile-port { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ok); }
3035
+ .app-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
3036
+ .app-row-name { font-size: var(--text-base); font-weight: var(--weight-semibold); }
3037
+ .app-row.small .app-row-name { font-size: var(--text-sm); }
3038
+ .app-row-meta { color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
3039
+ .app-row-action { flex: none; display: flex; align-items: center; gap: var(--space-1); }
3040
+ .app-message { margin-block: var(--space-2); }
3041
+
3042
+ /* Apps sheet (AppsSheet.tsx): one card per app, its views nested, one primary
3043
+ action per view; secondary tools as quiet icon buttons; rare or destructive
3044
+ actions behind ⋯ (MoreMenu, the canonical .menu). Status uses .banner. */
3045
+ .apps-sheet { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-2) var(--space-2) var(--space-4); }
3046
+ .apps-state { display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-6) 0; color: var(--muted); font-size: var(--text-sm); }
3047
+ .apps-group { display: flex; flex-direction: column; gap: var(--space-2); }
3048
+ .apps-group-title { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
3049
+ .apps-view { display: flex; flex-direction: column; border-top: 1px solid var(--line); }
3050
+ .apps-cmd { display: block; margin: 0 var(--space-3) var(--space-3); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: var(--text-xs); white-space: pre-wrap; overflow-wrap: anywhere; }
3051
+ .apps-view-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0; padding: 0 var(--space-1) var(--space-1); }
3052
+ .apps-view-tools .btn { gap: 6px; padding-inline: var(--space-2); color: var(--muted); }
3053
+ .apps-view-tools .btn:hover:not(:disabled) { color: var(--ink); }
3054
+ .apps-more { position: relative; }
3055
+ .apps-view-tools .apps-more { margin-inline-start: auto; }
3056
+ .apps-more-menu { position: absolute; right: 0; top: calc(100% + var(--space-1)); z-index: var(--z-dropdown); }
3057
+ .apps-notice { display: flex; align-items: flex-start; gap: var(--space-2); margin: 0 var(--space-3) var(--space-3); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--ok) 12%, transparent); font-size: var(--text-sm); }
3058
+ .apps-notice > svg { flex: none; margin-top: 1px; color: var(--ok); }
3059
+ .apps-notes { display: flex; flex-direction: column; gap: var(--space-2); margin: 0 var(--space-3) var(--space-3); padding-top: var(--space-3); border-top: 1px dashed var(--line); }
3060
+ .apps-notes-head { display: flex; align-items: center; gap: var(--space-2); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
3061
+ .apps-count { min-inline-size: 18px; padding: 0 6px; border-radius: var(--radius-full); background: var(--surface-3); color: var(--ink); text-align: center; letter-spacing: 0; }
3062
+ .apps-notes ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
3063
+ .apps-note { display: flex; flex-direction: column; gap: 2px; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); }
3064
+ .apps-note-text { color: var(--ink); font-size: var(--text-sm); }
3065
+ .apps-notes-actions { display: flex; gap: var(--space-2); }
3066
+ .apps-empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-6) var(--space-4); color: var(--muted); font-size: var(--text-sm); text-align: center; }
3067
+ .apps-empty strong { color: var(--ink); font-size: var(--text-base); }
3068
+ .apps-empty p { margin: 0; max-inline-size: 34ch; }
3069
+ .apps-foot { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
3070
+ /* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
3071
+ .preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
2960
3072
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
2961
- .app-view-command { overflow-wrap: anywhere; white-space: pre-wrap; font-size: var(--text-xs); color: var(--muted); }
2962
- .app-view-row .artifact-main { flex-basis: 60%; }
2963
3073
  .artifacts-group { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
2964
3074
  .artifacts-group:last-child { margin-bottom: 0; }
2965
3075
  .artifacts-group-title { font-size: 12px; font-weight: 600; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.04em; }
@@ -3196,3 +3306,8 @@ select.picker-search { appearance: auto; }
3196
3306
  .automation-history-view .autom-section-head { align-items: flex-start; gap: var(--space-3); }
3197
3307
  .automation-history-view .autom-section-head > strong { min-width: 0; line-height: var(--lh-lg); }
3198
3308
  }
3309
+
3310
+ /* Handoff notes above the composer (HandoffBanner, StandbyNotice): shaped like
3311
+ the composer itself, with one line of another device's draft. */
3312
+ .handoff-banner { border-radius: var(--radius-md); }
3313
+ .handoff-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }