@floless/app 0.15.0 → 0.16.1

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.
@@ -50969,6 +50969,13 @@ function readApp(id) {
50969
50969
  compilerVersion: typeof lockDoc["compiler-version"] === "string" ? lockDoc["compiler-version"] : null,
50970
50970
  agentPins
50971
50971
  };
50972
+ const skillCache = /* @__PURE__ */ new Map();
50973
+ const nodeSkill = (agent, command) => {
50974
+ if (!agent || !command) return null;
50975
+ const key = JSON.stringify([agent, command]);
50976
+ if (!skillCache.has(key)) skillCache.set(key, readNodeSkill(agent, command));
50977
+ return skillCache.get(key) ?? null;
50978
+ };
50972
50979
  const srcNodes = Array.isArray(src.nodes) ? src.nodes : [];
50973
50980
  const nodes = srcNodes.map((n) => {
50974
50981
  const nid = String(n.id);
@@ -50984,7 +50991,8 @@ function readApp(id) {
50984
50991
  mode: lockMode === "write" || lockMode === "read" ? lockMode : "unknown",
50985
50992
  inputs: asRecord(locked?.inputs),
50986
50993
  config: asRecord(n.config),
50987
- notes: notes2
50994
+ notes: notes2,
50995
+ skill: nodeSkill(n.agent != null ? String(n.agent) : null, command)
50988
50996
  };
50989
50997
  });
50990
50998
  const layout = src.layout === "dag" ? "dag" : "linear";
@@ -51038,7 +51046,7 @@ function sourceNodeId(nodes, connections) {
51038
51046
  return nodes.find((n) => !hasIncoming.has(n.id))?.id ?? nodes[0].id;
51039
51047
  }
51040
51048
  function readCommandSpec(agent, command, agentsDir = AGENTS_DIR) {
51041
- const safe = (n) => !n.includes("/") && !n.includes("\\") && !n.includes("..");
51049
+ const safe = (n) => !/[/\\\0]/.test(n) && !n.includes("..");
51042
51050
  if (!safe(agent) || !safe(command)) return null;
51043
51051
  const manifestPath = (0, import_node_path2.join)(agentsDir, agent, "manifest.yaml");
51044
51052
  if (!(0, import_node_fs3.existsSync)(manifestPath)) return null;
@@ -51065,6 +51073,55 @@ function readCommandSpec(agent, command, agentsDir = AGENTS_DIR) {
51065
51073
  });
51066
51074
  return { lifecycle, streaming, inputs };
51067
51075
  }
51076
+ function firstParagraph(s) {
51077
+ if (typeof s !== "string") return "";
51078
+ return (s.trim().split(/\n\s*\n/)[0] ?? "").replace(/\s+/g, " ").trim();
51079
+ }
51080
+ function parseManifestInputs(raw) {
51081
+ return Object.entries(asRecord(raw)).map(([name, spec]) => {
51082
+ const s = typeof spec === "string" ? { type: spec } : asRecord(spec);
51083
+ const input = {
51084
+ name,
51085
+ type: typeof s.type === "string" ? s.type : "string",
51086
+ default: "default" in s ? s.default : null,
51087
+ description: typeof s.description === "string" ? s.description : ""
51088
+ };
51089
+ if (Array.isArray(s.values)) input.values = s.values.map(String);
51090
+ return input;
51091
+ });
51092
+ }
51093
+ function readNodeSkill(agent, command, agentsDir = AGENTS_DIR) {
51094
+ const safe = (n) => !/[/\\\0]/.test(n) && !n.includes("..");
51095
+ if (!safe(agent) || !safe(command)) return null;
51096
+ const manifestPath = (0, import_node_path2.join)(agentsDir, agent, "manifest.yaml");
51097
+ if (!(0, import_node_fs3.existsSync)(manifestPath)) return null;
51098
+ let doc;
51099
+ try {
51100
+ doc = asRecord((0, import_yaml.parse)((0, import_node_fs3.readFileSync)(manifestPath, "utf8")));
51101
+ } catch {
51102
+ return null;
51103
+ }
51104
+ const cmd = asRecord(asRecord(doc.commands)[command]);
51105
+ if (Object.keys(cmd).length === 0) return null;
51106
+ const outRec = asRecord(cmd.outputs);
51107
+ const outputs = typeof outRec.type === "string" ? {
51108
+ type: outRec.type,
51109
+ schema: Object.fromEntries(
51110
+ Object.entries(asRecord(outRec.schema)).map(([k, v]) => [k, String(v)])
51111
+ )
51112
+ } : null;
51113
+ return {
51114
+ agent,
51115
+ agentDisplayName: typeof doc["display-name"] === "string" ? doc["display-name"] : agent,
51116
+ agentDescription: firstParagraph(doc.description),
51117
+ homepage: typeof doc.homepage === "string" ? doc.homepage : null,
51118
+ capabilities: Object.keys(asRecord(doc.requires)),
51119
+ command,
51120
+ commandDescription: firstParagraph(cmd.description),
51121
+ inputs: parseManifestInputs(cmd.inputs),
51122
+ outputs
51123
+ };
51124
+ }
51068
51125
  function detectTriggerSource(nodes, connections, lookup) {
51069
51126
  const srcId = sourceNodeId(nodes, connections);
51070
51127
  if (!srcId) return null;
@@ -51096,7 +51153,7 @@ function listIntegrations() {
51096
51153
  if (!(0, import_node_fs3.existsSync)(AGENTS_DIR)) return [];
51097
51154
  const out = [];
51098
51155
  const seen = /* @__PURE__ */ new Set();
51099
- const safe = (n) => !n.includes("/") && !n.includes("\\") && !n.includes("..");
51156
+ const safe = (n) => !/[/\\\0]/.test(n) && !n.includes("..");
51100
51157
  for (const agentId of (0, import_node_fs3.readdirSync)(AGENTS_DIR)) {
51101
51158
  if (!safe(agentId)) continue;
51102
51159
  const manifestPath = (0, import_node_path2.join)(AGENTS_DIR, agentId, "manifest.yaml");
@@ -52609,7 +52666,7 @@ function appVersion() {
52609
52666
  return resolveVersion({
52610
52667
  isSea: isSea2(),
52611
52668
  sqVersionXml: readSqVersionXml(),
52612
- define: true ? "0.15.0" : void 0,
52669
+ define: true ? "0.16.1" : void 0,
52613
52670
  pkgVersion: readPkgVersion()
52614
52671
  });
52615
52672
  }
@@ -52619,7 +52676,7 @@ function resolveChannel(s) {
52619
52676
  return "dev";
52620
52677
  }
52621
52678
  function appChannel() {
52622
- return resolveChannel({ isSea: isSea2(), define: true ? "0.15.0" : void 0 });
52679
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.16.1" : void 0 });
52623
52680
  }
52624
52681
 
52625
52682
  // oauth-presets.ts
package/dist/web/app.css CHANGED
@@ -46,7 +46,10 @@
46
46
  grid-template-rows: 60px 1fr 44px;
47
47
  --left-width: 340px;
48
48
  --right-width: 420px;
49
- grid-template-columns: var(--left-width) 1fr var(--right-width);
49
+ /* minmax(0, 1fr) (not the default minmax(auto, 1fr)) lets the center column
50
+ shrink below its content's min-size, so a wide center never blows the grid
51
+ past the viewport. See min-width:0 on the three areas below. */
52
+ grid-template-columns: var(--left-width) minmax(0, 1fr) var(--right-width);
50
53
  grid-template-areas:
51
54
  "header header header"
52
55
  "chat canvas inspect"
@@ -104,8 +107,16 @@
104
107
  justify-content: space-between;
105
108
  padding: 0 18px;
106
109
  border-bottom: 1px solid var(--border);
110
+ /* Let the header's flex children shrink rather than force the whole grid wider
111
+ than the viewport (the old cause of a page-wide horizontal scrollbar): the
112
+ workflow picker is the shrink sink — it truncates down to its 180px floor
113
+ while the run-spine buttons stay on one line (never wrapping). Below that
114
+ floor a horizontal scrollbar is the final fallback (the run spine stays
115
+ reachable by scrolling), at viewport widths narrower than the design (#53). */
116
+ min-width: 0;
107
117
  }
108
- .header-left { display: flex; align-items: center; gap: 10px; }
118
+ /* Brand + view-toggle stay full size; .controls is the shrink sink instead. */
119
+ .header-left { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
109
120
  .brand { display: flex; align-items: center; gap: 12px; }
110
121
  .brand .mark {
111
122
  display: inline-flex;
@@ -119,7 +130,11 @@
119
130
  .brand .mark .mark-node { fill: var(--bg); }
120
131
  .brand .name { font-size: 15px; font-weight: 700; letter-spacing: 0.08em; }
121
132
  .brand .tag { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.12em; }
122
- .controls { display: flex; align-items: center; gap: 10px; }
133
+ .controls { display: flex; align-items: center; gap: 10px; min-width: 0; }
134
+ /* Action buttons keep their labels on ONE line — under a tight header they may
135
+ compress, but the workflow picker (the shrink sink) gives way first; a button
136
+ must never wrap to two lines (#53 follow-up). */
137
+ .controls button { white-space: nowrap; }
123
138
  /* Vertical divider separating "which workflow + reference" from the run spine
124
139
  (state → Compile → Simulate → Run). Uses the existing border token. */
125
140
  .ctl-sep { width: 1px; height: 20px; background: var(--border-strong); flex: none; }
@@ -236,6 +251,7 @@
236
251
  display: flex;
237
252
  flex-direction: column;
238
253
  overflow: hidden;
254
+ min-width: 0;
239
255
  position: relative;
240
256
  }
241
257
  .host-banner {
@@ -326,6 +342,7 @@
326
342
  display: flex;
327
343
  flex-direction: column;
328
344
  overflow: hidden;
345
+ min-width: 0;
329
346
  position: relative;
330
347
  }
331
348
  /* While middle-mouse panning, force the grab cursor over the whole canvas. */
@@ -339,8 +356,16 @@
339
356
  padding: 24px 36px;
340
357
  position: relative;
341
358
  min-height: 0;
342
- overflow: auto;
359
+ /* This element is the transformed "world": it's translated/scaled for pan+zoom,
360
+ so it must NOT clip its own content — clipping here happens BEFORE the scale,
361
+ so zooming out would shrink an already-clipped view instead of revealing the
362
+ whole graph. The stable clip box is the parent `.canvas` (overflow:hidden).
363
+ overflow:visible also means no scrollbar — drag the background / middle-mouse
364
+ to pan, zoom, or Fit this infinite canvas. */
365
+ overflow: visible;
366
+ cursor: grab;
343
367
  }
368
+ /* (the grabbing cursor during a pan is already forced by `.canvas.panning *` above) */
344
369
 
345
370
  /* LINEAR */
346
371
  .topology:not(.dag) .agent-card { flex: 0 0 210px; }
@@ -738,6 +763,7 @@
738
763
  display: flex;
739
764
  flex-direction: column;
740
765
  overflow: hidden;
766
+ min-width: 0;
741
767
  position: relative;
742
768
  }
743
769
  .tabs {
@@ -1416,10 +1442,16 @@
1416
1442
  #rtn-delete-confirm:hover { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 10%, transparent); }
1417
1443
 
1418
1444
  /* ========== WORKFLOW COMBOBOX (searchable, provider-grouped) ========== */
1419
- .wf-combo { position: relative; display: inline-flex; }
1445
+ /* Shrinkable in the header flex row: the trigger's 300px width is the combo's
1446
+ preferred size (flex-basis:auto reads it), and flex-shrink gives space back
1447
+ down to a 180px floor when the header is tight, so the run spine fits without
1448
+ wrapping its buttons. The name ellipsizes within. The 300px lives on the
1449
+ TRIGGER (not flex-basis on the combo) so the combo's intrinsic size matches
1450
+ its flex size — otherwise the shrink leaks onto the buttons and wraps them. */
1451
+ .wf-combo { position: relative; display: inline-flex; flex: 0 1 auto; min-width: 180px; }
1420
1452
  .wf-trigger {
1421
1453
  display: inline-flex; align-items: center; gap: 8px;
1422
- min-width: 300px; max-width: 400px;
1454
+ min-width: 0; width: 300px; max-width: 100%;
1423
1455
  background: var(--surface-2); color: var(--text);
1424
1456
  border: 1px solid var(--border-strong); border-radius: 4px;
1425
1457
  padding: 7px 12px; font-family: var(--ui); font-size: 12px; cursor: pointer;
@@ -2041,6 +2073,20 @@ body {
2041
2073
  .kv td { font-family: var(--mono); color: var(--text); word-break: break-word; }
2042
2074
  .kv tr + tr th, .kv tr + tr td { border-top: 1px solid var(--border); }
2043
2075
 
2076
+ /* Skill tab (Inspect): capability/mode pills mirror the notes-strip .note-kind
2077
+ pill, and the agent docs link uses the accent — existing tokens only, no new
2078
+ palette (issue #54). */
2079
+ .inspect-content .cap-badge {
2080
+ display: inline-block; font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em;
2081
+ color: var(--info); border: 1px solid color-mix(in srgb, var(--info) 35%, transparent);
2082
+ border-radius: 3px; padding: 0 5px; margin: 0 2px; vertical-align: 1px;
2083
+ }
2084
+ .inspect-content .cap-badge.write { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
2085
+ /* Breathing room above the docs link — it follows a schema table (no margin-bottom). */
2086
+ .inspect-content .skill-docs-row { margin-top: 8px; }
2087
+ .inspect-content .skill-docs { color: var(--accent); text-decoration: none; }
2088
+ .inspect-content .skill-docs:hover { text-decoration: underline; }
2089
+
2044
2090
  /* ── HTML Viewer (report) modal ─────────────────────────────────────────────── */
2045
2091
  .modal.report-viewer {
2046
2092
  width: 1180px; max-width: 94vw; height: 86vh;
package/dist/web/app.js CHANGED
@@ -226,7 +226,7 @@ function renderTopology() {
226
226
  } else {
227
227
  selectAgent(state.selectedAgentId);
228
228
  }
229
- resetView(); // fresh 100% + centred view per render; also re-syncs the zoom % label
229
+ fitView(); // auto-fit the whole workflow into the canvas per render (never clipped)
230
230
  }
231
231
 
232
232
  function renderLinearTopology(p) {
@@ -354,8 +354,18 @@ function cardEl(id, ports) {
354
354
  `;
355
355
  div.onclick = (e) => {
356
356
  if (e.target.closest('.fav-btn')) return;
357
+ // A plain card click only SELECTS. Revealing a collapsed Inspect panel is
358
+ // reserved for the explicit "inspect ▸" affordance (handler below) — a general
359
+ // node click must not pop open a panel the user deliberately collapsed (#52).
357
360
  selectAgent(id);
358
361
  };
362
+ // The "inspect ▸" hint is the explicit inspect affordance: select AND reveal the
363
+ // Inspect panel if it's collapsed. stopPropagation so the card's plain-select
364
+ // onclick doesn't also fire (it would just re-select the same node).
365
+ div.querySelector('.inspect-hint').onclick = (e) => {
366
+ e.stopPropagation();
367
+ selectAgent(id, { reveal: true });
368
+ };
359
369
  div.querySelector('.fav-btn').onclick = (e) => {
360
370
  e.stopPropagation();
361
371
  toggleFav(id);
@@ -379,13 +389,26 @@ function wireEl(idx, label) {
379
389
  return wrap;
380
390
  }
381
391
 
382
- function selectAgent(id) {
392
+ // `reveal` is set by USER-initiated selections (clicking a node card / the
393
+ // "inspect ▸" hint / a favorite chip). When the Inspect panel is collapsed those
394
+ // would otherwise give zero visible feedback — the panel quietly re-renders behind
395
+ // the rail (issue #52) — so a revealing selection also expands it. Programmatic /
396
+ // initial selection (on render) passes no reveal, so a saved collapsed preference
397
+ // is respected on load.
398
+ function selectAgent(id, { reveal = false } = {}) {
383
399
  state.selectedAgentId = id;
384
400
  document.querySelectorAll('.agent-card').forEach(c => {
385
401
  c.classList.toggle('selected', c.dataset.agentId === id);
386
402
  });
387
403
  const a = AGENTS[id];
388
404
  if (a) $inspectRole.textContent = a.title.toLowerCase();
405
+ if (reveal && state.collapse.right) {
406
+ // Momentary reveal — expand the panel for THIS interaction but do NOT persist:
407
+ // the user collapsed it deliberately, so a single inspect must not overwrite
408
+ // that saved preference (only an explicit toggle/drag does). UX review on #52.
409
+ state.collapse.right = false;
410
+ applyCollapse();
411
+ }
389
412
  renderInspect();
390
413
  }
391
414
 
@@ -624,7 +647,7 @@ function renderFavBar() {
624
647
  const id = chip.dataset.favAgent;
625
648
  const p = PROMPTS[state.promptKey];
626
649
  if (nodeIds(p).includes(id)) {
627
- selectAgent(id);
650
+ selectAgent(id, { reveal: true });
628
651
  } else {
629
652
  chip.style.borderColor = 'var(--warn)';
630
653
  setTimeout(() => chip.style.borderColor = '', 600);
@@ -1154,8 +1177,16 @@ function showToast(msg, type) {
1154
1177
  const ZOOM_LEVELS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0];
1155
1178
  const MIN_ZOOM = ZOOM_LEVELS[0];
1156
1179
  const MAX_ZOOM = ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
1180
+ // Auto-fit/Fit may zoom further out than the interactive MIN_ZOOM so a large graph
1181
+ // in a tight canvas is shown whole rather than floored and cut. Floored at 20% for
1182
+ // readability — below that, hold here and let the user pan (never scale to noise).
1183
+ const FIT_MIN_ZOOM = 0.2;
1157
1184
  let zoom = 1; // continuous scale (fit produces arbitrary values, not just presets)
1158
1185
  let panX = 0, panY = 0; // pan offset in screen px (transform-origin is the canvas centre)
1186
+ // The view auto-fits the WHOLE workflow into the canvas (so nodes are never clipped)
1187
+ // and keeps re-fitting as the canvas resizes (panels toggle, window resize) — UNTIL
1188
+ // the user takes manual control (zoom / pan / 100%), after which their view is kept.
1189
+ let autoFit = true;
1159
1190
 
1160
1191
  function applyTransform() {
1161
1192
  $topology.style.transformOrigin = '50% 50%';
@@ -1176,17 +1207,19 @@ function zoomBy(delta) {
1176
1207
  : ([...ZOOM_LEVELS].reverse().find((z) => z < zoom - 1e-3) ?? MIN_ZOOM);
1177
1208
  if (Math.abs(next - zoom) < 1e-3) return;
1178
1209
  zoom = next;
1210
+ autoFit = false; // user chose a zoom — stop auto-fitting on resize
1179
1211
  applyTransform();
1180
1212
  }
1181
1213
 
1182
- // Reset to 100% + recentre. Also called by renderTopology so the zoom/pan state
1183
- // matches the transform it clears (otherwise the % label would go stale on switch).
1214
+ // Reset to 100% + recentre. Pure callers decide whether it's a user action.
1184
1215
  function resetView() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
1185
1216
 
1186
1217
  // Fit the whole workflow into the visible canvas (zooms in OR out), then recentre.
1187
1218
  // Measures the real node cluster — offset* are unscaled layout coords, so they're
1188
- // unaffected by the current transform.
1189
- function fitToScreen() {
1219
+ // unaffected by the current transform. `maxZoom` caps the scale: the auto-fit on
1220
+ // load/resize passes 1 so a small workflow stays 1:1 (never blown up); the explicit
1221
+ // Fit button passes the full range (may zoom in to fill).
1222
+ function fitToScreen(maxZoom = MAX_ZOOM) {
1190
1223
  const cards = [...$topology.querySelectorAll('.agent-card')];
1191
1224
  if (!cards.length) { resetView(); return; }
1192
1225
  let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;
@@ -1200,16 +1233,27 @@ function fitToScreen() {
1200
1233
  if (contentW <= 0 || contentH <= 0) { resetView(); return; }
1201
1234
  const pad = 40;
1202
1235
  const fit = Math.min(($topology.clientWidth - pad * 2) / contentW, ($topology.clientHeight - pad * 2) / contentH);
1203
- zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, fit));
1204
- panX = 0; panY = 0; // origin 50% 50% keeps the cluster centred
1236
+ // Floor at FIT_MIN_ZOOM (below the interactive MIN_ZOOM) so a big graph in a
1237
+ // tight canvas is fully visible, never clipped that's the whole point of Fit.
1238
+ zoom = Math.max(FIT_MIN_ZOOM, Math.min(maxZoom, fit));
1239
+ // Centre the content's bounding box in the viewport. transform-origin is the
1240
+ // topology centre and translate sits OUTSIDE the scale, so pan = (viewport
1241
+ // centre − content centre) × zoom. Required because the DAG grid sits top-left
1242
+ // in the topology — a 0 pan would leave it off-centre and clip the edge nodes.
1243
+ panX = ($topology.clientWidth / 2 - (minL + maxR) / 2) * zoom;
1244
+ panY = ($topology.clientHeight / 2 - (minT + maxB) / 2) * zoom;
1205
1245
  applyTransform();
1206
1246
  }
1207
1247
 
1248
+ // Auto-fit used on render + canvas resize: show the whole workflow (capped at 100%
1249
+ // so small graphs aren't blown up) and stay in auto-fit mode so it tracks resizes.
1250
+ function fitView() { fitToScreen(1); autoFit = true; }
1251
+
1208
1252
  document.getElementById('zoom-out').onclick = () => zoomBy(-1);
1209
1253
  document.getElementById('zoom-in').onclick = () => zoomBy(+1);
1210
- document.getElementById('zoom-reset').onclick = () => resetView();
1254
+ document.getElementById('zoom-reset').onclick = () => { resetView(); autoFit = false; };
1211
1255
  const $zoomFit = document.getElementById('zoom-fit');
1212
- if ($zoomFit) $zoomFit.onclick = () => fitToScreen();
1256
+ if ($zoomFit) $zoomFit.onclick = () => { fitToScreen(); autoFit = true; };
1213
1257
 
1214
1258
  // Ctrl/Cmd + scroll wheel = zoom on canvas (skips OS-level browser zoom)
1215
1259
  let zoomAccum = 0;
@@ -1228,18 +1272,32 @@ document.querySelector('.canvas').addEventListener('wheel', (e) => {
1228
1272
  // cursor) because translate sits OUTSIDE the scale in the transform list.
1229
1273
  const $canvasEl = document.querySelector('.canvas');
1230
1274
  let panning = false, panFromX = 0, panFromY = 0, panBaseX = 0, panBaseY = 0;
1231
- $canvasEl.addEventListener('mousedown', (e) => {
1232
- if (e.button !== 1) return;
1233
- e.preventDefault();
1275
+ function startPan(e) {
1234
1276
  panning = true;
1235
1277
  panFromX = e.clientX; panFromY = e.clientY;
1236
1278
  panBaseX = panX; panBaseY = panY;
1237
1279
  $canvasEl.classList.add('panning');
1280
+ }
1281
+ // Middle-mouse drag anywhere on the canvas pans (the classic gesture).
1282
+ $canvasEl.addEventListener('mousedown', (e) => {
1283
+ if (e.button !== 1) return;
1284
+ e.preventDefault();
1285
+ startPan(e);
1286
+ });
1287
+ // Left-drag the empty canvas background also pans — so the infinite, scrollbar-less
1288
+ // canvas is navigable with any pointer (trackpads have no middle button). Excludes
1289
+ // node cards (those keep click-to-select + their HTML5 drag); the toolbar/hint/
1290
+ // fav-bar/find aren't inside .topology, so they're naturally excluded.
1291
+ $topology.addEventListener('mousedown', (e) => {
1292
+ if (e.button !== 0 || e.target.closest('.agent-card')) return;
1293
+ e.preventDefault();
1294
+ startPan(e);
1238
1295
  });
1239
1296
  window.addEventListener('mousemove', (e) => {
1240
1297
  if (!panning) return;
1241
1298
  panX = panBaseX + (e.clientX - panFromX);
1242
1299
  panY = panBaseY + (e.clientY - panFromY);
1300
+ autoFit = false; // user dragged the view — stop auto-fitting on resize
1243
1301
  applyTransform();
1244
1302
  });
1245
1303
  window.addEventListener('mouseup', () => {
@@ -1249,6 +1307,18 @@ window.addEventListener('mouseup', () => {
1249
1307
  });
1250
1308
  $canvasEl.addEventListener('auxclick', (e) => { if (e.button === 1) e.preventDefault(); });
1251
1309
 
1310
+ // Keep the auto-fitted view fitted as the canvas resizes (a panel toggling/dragging,
1311
+ // the window resizing) — but never override a view the user has zoomed/panned. A
1312
+ // trailing debounce fires ONE clean re-fit after the resize settles (rather than on
1313
+ // every frame of a panel's 0.25s slide, which reads as jitter). The transform
1314
+ // fitToScreen sets doesn't change layout size, so this can't loop.
1315
+ let _fitTimer = 0;
1316
+ new ResizeObserver(() => {
1317
+ if (!autoFit) return;
1318
+ clearTimeout(_fitTimer);
1319
+ _fitTimer = setTimeout(() => { if (autoFit) fitToScreen(1); }, 120);
1320
+ }).observe($canvasEl);
1321
+
1252
1322
  /* ============= INTEGRATIONS ============= */
1253
1323
  // The real, server-backed Integrations window lives in aware.js (openIntegrations/
1254
1324
  // renderIntegrations are reassigned there from /api/integrations). app.js only owns
@@ -1279,8 +1349,8 @@ document.addEventListener('keydown', (e) => {
1279
1349
  if (cmd && e.key.toLowerCase() === 'i') { e.preventDefault(); openIntegrations(); return; }
1280
1350
  if (cmd && (e.key === '=' || e.key === '+')) { e.preventDefault(); zoomBy(+1); return; }
1281
1351
  if (cmd && e.key === '-') { e.preventDefault(); zoomBy(-1); return; }
1282
- if (cmd && e.key === '0') { e.preventDefault(); resetView(); return; }
1283
- if (e.key === 'Home') { e.preventDefault(); fitToScreen(); return; }
1352
+ if (cmd && e.key === '0') { e.preventDefault(); resetView(); autoFit = false; return; }
1353
+ if (e.key === 'Home') { e.preventDefault(); fitToScreen(); autoFit = true; return; }
1284
1354
  if (e.key === 'Escape') {
1285
1355
  if ($findOverlay.classList.contains('show')) closeFind();
1286
1356
  if ($integrationsModal.classList.contains('show')) $integrationsModal.classList.remove('show');
package/dist/web/aware.js CHANGED
@@ -144,6 +144,47 @@
144
144
  .join('')}</table>`;
145
145
  }
146
146
 
147
+ // Plain-text summary of one declared command input (for the Skill tab's inputs
148
+ // table — kvTable escapes it, so this stays text, no markup): "type (enum vals)
149
+ // · default X — description".
150
+ function inputSummary(i) {
151
+ let s = i.type || 'string';
152
+ if (i.values && i.values.length) s += ` (${i.values.join(' · ')})`;
153
+ if (i.default !== null && i.default !== undefined && i.default !== '') {
154
+ s += ` · default ${typeof i.default === 'string' ? i.default : JSON.stringify(i.default)}`;
155
+ }
156
+ if (i.description) s += ` — ${i.description}`;
157
+ return s;
158
+ }
159
+
160
+ // Inspect → Skill tab body. Rich when the agent manifest is installed (n.skill):
161
+ // what the agent + command do, capabilities, the command's DECLARED inputs and
162
+ // output schema, and a docs link. Falls back to the lock-pointer summary for
163
+ // exec/agent-less nodes or an uninstalled agent. DISPLAY of manifest data only —
164
+ // every file-derived string is escaped (kvTable/escapeHtml), never injected raw.
165
+ function skillBody(n, fallback, notesHtml) {
166
+ const sk = n.skill;
167
+ if (!sk) return fallback; // fallback already carries notesHtml
168
+ const modeBadge = `<span class="cap-badge${n.mode === 'write' ? ' write' : ''}">${escapeHtml(n.mode)}</span>`;
169
+ const capBadges = sk.capabilities.map((c) => `<span class="cap-badge">${escapeHtml(c)}</span>`).join(' ');
170
+ const inputsObj = Object.fromEntries(sk.inputs.map((i) => [i.name, inputSummary(i)]));
171
+ const docs = sk.homepage && /^https?:\/\//i.test(sk.homepage)
172
+ ? `<p class="skill-docs-row"><a class="skill-docs" href="${escapeHtml(sk.homepage)}" target="_blank" rel="noopener">Agent documentation ↗</a></p>`
173
+ : '';
174
+ const outputs = sk.outputs
175
+ ? `<p><strong>Returns</strong> · ${escapeHtml(sk.outputs.type)}</p>${Object.keys(sk.outputs.schema).length ? kvTable(sk.outputs.schema) : ''}`
176
+ : '';
177
+ return `
178
+ <h3>${escapeHtml(sk.agentDisplayName)}</h3>
179
+ ${sk.agentDescription ? `<p>${escapeHtml(sk.agentDescription)}</p>` : ''}
180
+ <h3>Command · <code>${escapeHtml(sk.command)}</code></h3>
181
+ ${sk.commandDescription ? `<p>${escapeHtml(sk.commandDescription)}</p>` : ''}
182
+ <p><strong>Mode</strong> ${modeBadge}${capBadges ? ' · <strong>Requires</strong> ' + capBadges : ''}</p>
183
+ <p><strong>Accepts</strong></p>${Object.keys(inputsObj).length ? kvTable(inputsObj) : '<p class="dim-note">No declared inputs.</p>'}
184
+ ${outputs}
185
+ ${docs}${notesHtml || ''}`;
186
+ }
187
+
147
188
  // Dependency-free C# highlighter for the Code tab. Single-pass scanner that
148
189
  // handles comments/strings/chars BEFORE keywords (so keywords inside strings
149
190
  // aren't recolored), escapes every token's text, and reuses the demo's token
@@ -250,8 +291,14 @@
250
291
  subtitle: `${agentLabel}${cmd ? '/' + cmd : ''} · ${mode}`,
251
292
  blurb: n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
252
293
  description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${Object.keys(inputsNoCode).length ? `<p><strong>Inputs</strong></p>${kvTable(inputsNoCode)}` : ''}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
253
- skill: `<h3>Provided by</h3><p><code>${escapeHtml(n.agent || n.kind)}</code>${pin ? ' · pinned <code>v' + escapeHtml(pin) + '</code>' : ''}${n.command ? ' · command <code>' + escapeHtml(n.command) + '</code>' : ''}</p>
294
+ // Rich agent/command detail when the manifest is installed (n.skill); else
295
+ // the lock-pointer fallback below (exec/agent-less nodes, uninstalled agents).
296
+ skill: skillBody(
297
+ n,
298
+ `<h3>Provided by</h3><p><code>${escapeHtml(n.agent || n.kind)}</code>${pin ? ' · pinned <code>v' + escapeHtml(pin) + '</code>' : ''}${n.command ? ' · command <code>' + escapeHtml(n.command) + '</code>' : ''}</p>
254
299
  <p>Resolved write/read mode is part of the approved <code>.lock</code> — see the <strong>Code</strong> tab.</p>${notesHtml}`,
300
+ notesHtml,
301
+ ),
255
302
  // Code tab: the REAL node source. For exec nodes (Roslyn C# run against a
256
303
  // live host) that's `config.code` straight from the .flo — the same text
257
304
  // the host compiles, so "Debug in VS" attaches to exactly this. For
@@ -3295,8 +3342,10 @@
3295
3342
  // button only for exec nodes (those carry C# the host can run under a debugger).
3296
3343
  // Debug lives HERE in the main window, not in the report modal.
3297
3344
  const _selectAgent = selectAgent;
3298
- selectAgent = function selectAgentTweakAware(id) {
3299
- _selectAgent(id);
3345
+ // Forward ALL args (id + the { reveal } opts from app.js) — dropping them here
3346
+ // silently swallowed the collapsed-panel reveal on inspect (issue #52).
3347
+ selectAgent = function selectAgentTweakAware(id, ...rest) {
3348
+ _selectAgent(id, ...rest);
3300
3349
  const tb = document.getElementById('tweak-btn');
3301
3350
  if (tb) tb.hidden = !id;
3302
3351
  const db = document.getElementById('debug-btn');
@@ -114,7 +114,7 @@
114
114
  ~/.floless/ui/extensions.json here; the canvas children hide via
115
115
  .canvas.view-dashboard). Composed by the terminal AI, rendered by us. -->
116
116
  <div class="dashboard" id="dashboard" hidden></div>
117
- <div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template.</div>
117
+ <div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
118
118
  <div class="fav-bar" id="fav-bar">
119
119
  <div class="fav-bar-label"><span class="star">★</span><span>Templates</span></div>
120
120
  <div class="fav-chip-row" id="fav-chip-row"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {