Skip to content

fix(bin): prioritize bridge talk and photo controls - #98

Merged
Amplify-Logic merged 1 commit into
mainfrom
fm/fm-bridge-utility-top-u1
Aug 23, 2026
Merged

fix(bin): prioritize bridge talk and photo controls#98
Amplify-Logic merged 1 commit into
mainfrom
fm/fm-bridge-utility-top-u1

Conversation

@Amplify-Logic

Copy link
Copy Markdown
Owner

What Changed

  • Move hold-to-speak and photo upload controls above the fleet glance so they are immediately reachable on phones.
  • Document the new bridge layout and update section-order regression coverage.

Risk Assessment

✅ Low: The change only reorders existing authenticated-page sections and updates the matching documentation and ordering assertion; no broken DOM assumptions, call-site regressions, or material risks were found.

Testing

The focused bridge behavior suite passed, and an authenticated end-to-end phone render visibly confirmed Talk and Send photos appear first above every fleet-glance section; the worktree remained clean.

  • Evidence: Phone-sized authenticated bridge view showing Talk and Send photos above fleet status (local file: /var/folders/1g/hctp3vpn27b1zrlsn4nsfg680000gn/T/no-mistakes-evidence/01M0Q9E0XRBYKRTRTJ792W0CZS/bridge-phone.png)
Evidence: Authenticated bridge page returned by the running server
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>STARSHIP</title>
<style nonce="2BKwZZELCo36yPL7lKSqpA">
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
  background: #101418;
  color: #f2f4f3;
  line-height: 1.45;
  padding: max(1rem, env(safe-area-inset-top)) 1.1rem 2rem;
}
main { max-width: 40rem; margin: 0 auto; }
h1 { font-size: 0.8rem; letter-spacing: 0.18em; text-transform: uppercase; margin: 0 0 0.4rem; color: #9aa7a0; }
h2 { font-size: 0.78rem; letter-spacing: 0.12em; text-transform: uppercase; margin: 1.4rem 0 0.5rem; color: #c5d0c8; }
.meta { color: #c5d0c8; font-size: 0.95rem; }
.warn { color: #e6c07b; font-size: 0.92rem; margin: 0.4rem 0 0; }
ul { list-style: none; padding: 0; margin: 0; }
li { padding: 0.55rem 0; border-bottom: 1px solid #2a3330; font-size: 1.05rem; }
.dot { display: inline-block; width: 0.65rem; height: 0.65rem; border-radius: 50%; margin-right: 0.55rem; background: #6ea8fe; }
.dot.needs { background: #c084fc; }
.dot.under { background: #6ea8fe; }
.dot.wait { background: #fbbf24; }
.dot.ready { background: #34d399; }
.dot.failed { background: #f87171; }
.dot.stuck { background: #fb923c; }
.more, .empty, .incomplete { color: #9aa7a0; font-size: 0.92rem; margin: 0.4rem 0 0; }
a { color: #9cdcfe; }
input, button {
  font: inherit; width: 100%; min-height: 2.75rem; border-radius: 0.5rem;
  border: 1px solid #3b4742; padding: 0.6rem 0.8rem;
}
input { background: #1b2220; color: inherit; margin: 0.8rem 0; }
button { background: #d7e0d8; color: #101418; font-weight: 600; }
.note { color: #9aa7a0; font-size: 0.9rem; }
.ok { color: #34d399; font-size: 0.95rem; margin: 0.4rem 0 0; }
#photo-form, #speak-box { margin: 1.1rem 0 0.4rem; }
#photo-form label, #speak-box label { display: block; margin-bottom: 0.35rem; }
#hold-speak {
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  background: #6ea8fe;
  color: #101418;
}
#hold-speak.held { background: #f87171; color: #101418; }
#speak-answer {
  white-space: pre-wrap;
  margin: 0.6rem 0 0;
  font-size: 1.05rem;
}
.wait-chip, .more-fold {
  margin: 0.35rem 0.4rem 0 0;
  border: 1px solid #2a3330;
  border-radius: 999px;
  padding: 0;
  vertical-align: top;
}
.wait-chip { display: inline-block; }
.more-fold { display: block; border-radius: 0.5rem; margin-left: 0; }
.wait-chip > summary, .more-fold > summary {
  min-height: 2.75rem;
  display: flex;
  align-items: center;
  cursor: pointer;
  color: #c5d0c8;
  font-size: 0.95rem;
  padding: 0.35rem 0.9rem;
  list-style: none;
}
.wait-chip > summary::-webkit-details-marker,
.more-fold > summary::-webkit-details-marker { display: none; }
.wait-chip[open], .more-fold[open] {
  display: block;
  border-radius: 0.5rem;
}
.wait-chip[open] > summary, .more-fold[open] > summary {
  border-bottom: 1px solid #2a3330;
}
.wait-chip ul, .more-fold ul { padding: 0 0.9rem 0.4rem; }
#stale {
  display: none; position: fixed; inset: 0; background: #101418;
  color: #f2f4f3; align-items: center; justify-content: center;
  text-align: center; padding: 2rem; font-size: 1.4rem; z-index: 9;
}
#stale.on { display: flex; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
form.logout { margin: 0; width: auto; }
form.logout button { width: auto; min-height: 2rem; padding: 0.3rem 0.7rem; background: transparent; color: #c5d0c8; border-color: #3b4742; }
</style>
<script nonce="2BKwZZELCo36yPL7lKSqpA">
const STALE_MS = 90 * 1000;
const REFRESH_MS = 30 * 1000;
let lastSuccess = 0;
const SPEAK_TIMESLICE_MS = 1000;
const SPEAK_MIN_BYTES_FLOOR = 1024;
const SPEAK_MIN_BYTES_PER_SEC = 1024;
let speakBusy = false;
let speakRecorder = null;
let speakChunks = [];
let speakStream = null;
let speakPollTimer = 0;
let speakHeld = false;
let speakHeldAt = 0;
let speakStarting = false;
let speakPress = 0;
function esc(value) {
  return String(value).replace(/[&<>"']/g, function(ch) {
    return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]);
  });
}
function setStale(on) {
  const el = document.getElementById('stale');
  if (!el) return;
  el.classList.toggle('on', on);
}
function setPhotoCount(n) {
  const count = document.getElementById('photo-count');
  if (count) count.textContent = 'Photos received today: ' + n;
}
function markBucketsUnreachable() {
  ['needs','underway','finished','waiting'].forEach(function(id) {
    const root = document.getElementById(id);
    if (!root) return;
    if (root.textContent.indexOf('Loading') !== -1) {
      root.innerHTML = '<p class="empty">Cannot reach the desk.</p>';
    }
  });
}
function dotClass(name) {
  if (name === 'Needs you') return 'needs';
  if (name === 'Under way') return 'under';
  if (name === 'Waiting') return 'wait';
  if (name === 'Ready') return 'ready';
  if (name === 'Failed') return 'failed';
  if (name === 'Stuck') return 'stuck';
  return 'under';
}
function itemLine(item) {
  const title = esc(item.title || '');
  const url = item.url || '';
  const safeUrl = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[0-9]+$/.test(url) ? url : '';
  const label = safeUrl
    ? '<a href="' + safeUrl + '" rel="noreferrer">' + title + '</a>'
    : title;
  return '<li><span class="dot ' + dotClass(item.dot) + '"></span>' + label + '</li>';
}
function renderList(items) {
  return '<ul>' + items.map(itemLine).join('') + '</ul>';
}
function renderBucket(id, bucket, emptyText) {
  const root = document.getElementById(id);
  if (!root || !bucket) return;
  const items = bucket.items || [];
  let extra = '';
  if (!items.length) extra = '<p class="empty">' + emptyText + '</p>';
  root.innerHTML = (items.length ? renderList(items) : '') + extra;
}
function renderNeeds(bucket) {
  const root = document.getElementById('needs');
  if (!root || !bucket) return;
  const items = bucket.items || [];
  const rest = bucket.rest || [];
  let html = items.length ? renderList(items) : '';
  if (!items.length && !rest.length) {
    html = '<p class="empty">Nothing needs you right now.</p>';
  }
  if (bucket.more) {
    html += '<details class="more-fold"><summary>' + esc(String(bucket.more)) +
      ' more waiting on you - show all</summary>' + renderList(rest) + '</details>';
  }
  root.innerHTML = html;
}
function renderWaiting(bucket) {
  const root = document.getElementById('waiting');
  if (!root || !bucket) return;
  const groups = bucket.groups || [];
  if (!groups.length) {
    root.innerHTML = '<p class="empty">Nothing is waiting.</p>';
    return;
  }
  root.innerHTML = groups.map(function(group) {
    const label = esc(group.label || 'Other');
    const items = group.items || [];
    const n = group.count != null ? group.count : items.length;
    return '<details class="wait-chip"><summary>' + label + ' ' + n +
      '</summary>' + renderList(items) + '</details>';
  }).join('');
}
function apply(data) {
  lastSuccess = Date.now();
  setStale(false);
  const age = document.getElementById('observed');
  if (age) age.textContent = 'Observed just now';
  const desk = document.getElementById('desk');
  if (desk) desk.textContent = 'Desk reachable';
  const mail = document.getElementById('mailbox');
  if (mail) mail.textContent = data.mailbox_listener ? 'Mailbox on' : 'Mailbox listener off';
  renderNeeds(data.needs_you);
  renderBucket('underway', data.under_way, 'Nothing is under way.');
  renderBucket('finished', data.just_finished, 'No recent completions.');
  renderWaiting(data.waiting);
  setPhotoCount(data.photos_today != null ? data.photos_today : 0);
}
function setSpeakStatus(kind, text) {
  const status = document.getElementById('speak-result');
  if (!status) return;
  status.className = kind;
  status.textContent = text;
}
function stopTracks() {
  if (!speakStream) return;
  speakStream.getTracks().forEach(function(track) { track.stop(); });
  speakStream = null;
}
function pickRecorderType() {
  if (typeof MediaRecorder === 'undefined') 

... [2096 bytes truncated] ...

? Math.max(0, Date.now() - speakHeldAt) : 0;
  speakHeldAt = 0;
  const blob = await new Promise(function(resolve) {
    let settled = false;
    function finish() {
      if (settled) return;
      settled = true;
      const type = recorder.mimeType || (chunks[0] && chunks[0].type) || 'audio/mp4';
      resolve(new Blob(chunks, { type: type }));
    }
    recorder.addEventListener('stop', function() {
      setTimeout(finish, 0);
    });
    try {
      if (recorder.state && recorder.state !== 'recording') {
        finish();
      } else {
        try {
          if (typeof recorder.requestData === 'function') recorder.requestData();
        } catch (err) {}
        recorder.stop();
      }
    } catch (err) {
      finish();
    }
  });
  stopTracks();
  if (btn) {
    btn.classList.remove('held');
    btn.textContent = 'Hold to speak';
  }
  const minBytes = Math.max(
    SPEAK_MIN_BYTES_FLOOR,
    Math.ceil(heldMs / 1000 * SPEAK_MIN_BYTES_PER_SEC)
  );
  if (!blob.size || blob.size < minBytes) {
    speakBusy = false;
    setSpeakStatus('warn', 'Recording was too small to send.');
    return;
  }
  setSpeakStatus('note', 'Sending…');
  try {
    const body = new FormData();
    const name = blob.type.indexOf('webm') !== -1 ? 'bridge.webm' : 'bridge.m4a';
    body.append('audio', blob, name);
    const res = await fetch('/speak', {
      method: 'POST',
      body: body,
      credentials: 'same-origin',
      cache: 'no-store'
    });
    const payload = await res.json().catch(function() { return {}; });
    if (!res.ok) {
      speakBusy = false;
      setSpeakStatus('warn', payload.error || ('Send failed (' + res.status + ')'));
      return;
    }
    setSpeakStatus('ok', 'Sent');
    if (payload.request_id) pollAnswer(payload.request_id, 0);
    else speakBusy = false;
  } catch (err) {
    speakBusy = false;
    setSpeakStatus('warn', 'Send failed.');
  }
}
async function pollAnswer(requestId, attempt) {
  if (speakPollTimer) clearTimeout(speakPollTimer);
  try {
    const res = await fetch('/api/answer/' + encodeURIComponent(requestId), {
      credentials: 'same-origin',
      cache: 'no-store'
    });
    const payload = await res.json().catch(function() { return {}; });
    if (res.ok && payload && payload.text) {
      const box = document.getElementById('speak-answer');
      if (box) box.textContent = payload.text;
      setSpeakStatus('ok', 'Answered');
      speakBusy = false;
      return;
    }
    if (res.ok && payload && payload.pending && attempt < 150) {
      speakPollTimer = setTimeout(function() { pollAnswer(requestId, attempt + 1); }, 2000);
      return;
    }
    if (attempt < 150 && (res.status === 204 || (payload && payload.pending))) {
      speakPollTimer = setTimeout(function() { pollAnswer(requestId, attempt + 1); }, 2000);
      return;
    }
    speakBusy = false;
    if (!document.getElementById('speak-answer').textContent) {
      setSpeakStatus('warn', 'No answer yet.');
    }
  } catch (err) {
    if (attempt < 150) {
      speakPollTimer = setTimeout(function() { pollAnswer(requestId, attempt + 1); }, 2000);
      return;
    }
    speakBusy = false;
    setSpeakStatus('warn', 'Could not read the answer.');
  }
}
async function sendPhotos(ev) {
  ev.preventDefault();
  const input = document.getElementById('photo');
  const status = document.getElementById('photo-result');
  if (!input || !status) return;
  const files = input.files ? Array.prototype.slice.call(input.files) : [];
  if (!files.length) {
    status.className = 'warn';
    status.textContent = 'Choose a photo first.';
    return;
  }
  const failed = [];
  let sent = 0;
  for (let i = 0; i < files.length; i++) {
    status.className = 'note';
    status.textContent = 'Sending ' + (i + 1) + ' of ' + files.length + '…';
    try {
      const body = new FormData();
      body.append('photo', files[i]);
      const res = await fetch('/upload', {
        method: 'POST',
        body: body,
        credentials: 'same-origin',
        cache: 'no-store'
      });
      const payload = await res.json().catch(function() { return {}; });
      if (!res.ok) {
        failed.push('Photo ' + (i + 1) + ': ' + (payload.error || ('failed (' + res.status + ')')));
        continue;
      }
      sent += 1;
      status.textContent = sent + ' of ' + files.length + ' sent';
      if (payload.received_today != null) setPhotoCount(payload.received_today);
    } catch (err) {
      failed.push('Photo ' + (i + 1) + ': send failed');
    }
  }
  input.value = '';
  if (failed.length && failed.length === files.length) {
    status.className = 'warn';
    status.textContent = failed.join(' ');
  } else if (failed.length) {
    status.className = 'warn';
    status.textContent = sent + ' of ' + files.length + ' sent. ' + failed.join(' ');
  } else {
    status.className = 'ok';
    status.textContent = files.length === 1 ? 'Photo received.' : (files.length + ' of ' + files.length + ' sent');
  }
}
function tickObserved() {
  const age = document.getElementById('observed');
  if (!age) return;
  if (!lastSuccess) return;
  const seconds = Math.max(0, Math.round((Date.now() - lastSuccess) / 1000));
  age.textContent = 'Observed ' + seconds + ' seconds ago';
  if (Date.now() - lastSuccess > STALE_MS) setStale(true);
}
async function refresh() {
  try {
    const ctl = new AbortController();
    const timer = setTimeout(function() { ctl.abort(); }, 10000);
    const res = await fetch('/api/observation', { credentials: 'same-origin', cache: 'no-store', signal: ctl.signal });
    clearTimeout(timer);
    const payload = await res.json().catch(function() { return {}; });
    if (payload && payload.photos_today != null) setPhotoCount(payload.photos_today);
    if (!res.ok) throw new Error('status ' + res.status);
    apply(payload);
  } catch (err) {
    if (!lastSuccess) {
      setStale(true);
      markBucketsUnreachable();
    } else {
      tickObserved();
    }
  }
}
document.addEventListener('DOMContentLoaded', function() {
  refresh();
  setInterval(refresh, REFRESH_MS);
  setInterval(tickObserved, 1000);
  document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
  window.addEventListener('pageshow', function() { refresh(); });
  const form = document.getElementById('photo-form');
  if (form) form.addEventListener('submit', sendPhotos);
  const hold = document.getElementById('hold-speak');
  if (hold) {
    hold.addEventListener('pointerdown', function(ev) {
      try { hold.setPointerCapture(ev.pointerId); } catch (err) {}
      startSpeak(ev);
    });
    hold.addEventListener('pointerup', finishSpeak);
    hold.addEventListener('pointercancel', finishSpeak);
    hold.addEventListener('lostpointercapture', finishSpeak);
  }
});
</script>
</head>
<body>
<div id="stale">Cannot reach the desk.</div>
<main>
<header>
  <h1>Starship</h1>
  <form class="logout" method="post" action="/logout"><button type="submit">Log out</button></form>
</header>
<h2>Talk</h2>
<div id="speak-box">
  <label for="hold-speak">Hold to speak</label>
  <button type="button" id="hold-speak">Hold to speak</button>
  <p id="speak-result" class="note"></p>
  <p id="speak-answer" class="meta"></p>
</div>
<h2>Send photos</h2>
<form id="photo-form" method="post" action="/upload" enctype="multipart/form-data">
  <label for="photo">Photos</label>
  <input id="photo" name="photo" type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,image/*" multiple>
  <button type="submit">Send photos</button>
</form>
<p class="meta" id="photo-count">Photos received today: 0</p>
<p id="photo-result" class="note"></p>
<p class="meta"><span id="desk">Checking the desk</span> · <span id="mailbox">Mailbox…</span></p>
<p class="meta" id="observed">Observing…</p>
<p class="warn">Summary only. Do not approve from this page.</p>
<h2>Needs you</h2>
<div id="needs"><p class="empty">Loading…</p></div>
<h2>Under way</h2>
<div id="underway"><p class="empty">Loading…</p></div>
<h2>Waiting</h2>
<div id="waiting"><p class="empty">Loading…</p></div>
<h2>Just finished</h2>
<div id="finished"><p class="empty">Loading…</p></div>
</main>
</body>
</html>

Pipeline

Updates from git push no-mistakes

⏭️ **intent** - skipped

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Inspected git diff 9f158d3f25ca1d2182cc168ce806a7c3a94df00f..871c2655f03b68c887e45953a9554f08dfb97c7d to establish user intent and affected behavior
  • bin/fm-test-run.sh tests/fm-bridge-view.test.sh
  • Started bin/fm-bridge-view.sh serve --host bridge.localhost --port 18766 with an isolated evidence home, initialized authentication, logged in via HTTP request, and captured the authenticated page response
  • Rendered the authenticated response at a 390×844 phone viewport and visually inspected /var/folders/1g/hctp3vpn27b1zrlsn4nsfg680000gn/T/no-mistakes-evidence/01M0Q9E0XRBYKRTRTJ792W0CZS/bridge-phone.png
  • Verified git status --short remained clean after testing
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

The captain often opens the phone page just for those utilities, so they need to sit above the fleet glance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Amplify-Logic
Amplify-Logic merged commit ef164dd into main Aug 23, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant