relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
14,970 lines • 870 kB
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; img-src 'self' data: blob:;" />
<title>Relay</title>
<style>
/* Bundled fonts — the pill never fetches from the network. Newsreader and
Inter ship as variable files (opsz/weight ranges intact); Plex Mono as the
two weights the UI uses. */
@font-face { font-family:'Newsreader'; font-style:normal; font-weight:200 800; font-display:block; src:url("fonts/newsreader-var.woff2") format("woff2"); }
@font-face { font-family:'Inter'; font-style:normal; font-weight:100 900; font-display:block; src:url("fonts/inter-var.woff2") format("woff2"); }
@font-face { font-family:'IBM Plex Mono'; font-style:normal; font-weight:400; font-display:block; src:url("fonts/plexmono-400.woff2") format("woff2"); }
@font-face { font-family:'IBM Plex Mono'; font-style:normal; font-weight:500; font-display:block; src:url("fonts/plexmono-500.woff2") format("woff2"); }
/* ---- the token sheet ----
One sheet of warm paper, day and night. Type has six sizes (10/11/12/13/15/19),
radii six steps (6/9/12/16/22/999), motion three durations on two curves, and
the ink casts exactly five alpha washes. Night is chosen by the moon button in
the header — never read from the OS. */
:root {
--bg:#FFFFFF;
--ink:#1F1A17; --ink-2:#2C2824;
--muted:#7C736B; --muted-2:#9A928A; --muted-3:#B7B1A6;
--accent:#305566; --warn:#8A5A32; --on-accent:#FFFFFF;
--brand-anthropic:#D9704E;
--brand-codex:#5F74F6;
--agent-sq:#5B7BD0; /* the agent's square — the codex-blue of the mark, as a solid */
--danger:#B4332A; --ok:#1A7F37;
--hair:rgba(31,26,23,.07); /* dividers */
--wash:rgba(31,26,23,.03); /* row hover */
--wash-2:rgba(31,26,23,.06); /* control hover / inset surfaces */
--wash-3:rgba(31,26,23,.10); /* pressed */
--edge:rgba(31,26,23,.16); /* input borders, scrollbar */
--paper-edge:rgba(255,255,255,.9);
/* The native window is fitted exactly to this surface. Exterior CSS shadows
are visible only while a shrink is pending, then get clipped at rest — a
dark halo that appears to collapse into an outline. Keep the surface edge
inside its bounds; popovers retain --shadow-pop below. */
--shadow-card:inset 0 1px 0 var(--paper-edge);
--shadow-pill:inset 0 1px 0 var(--paper-edge);
--shadow-pop:0 12px 30px -18px rgba(31,26,23,.35);
--accent-soft:color-mix(in srgb, var(--accent) 10%, transparent);
--accent-press:color-mix(in srgb, var(--accent) 15%, transparent);
--danger-soft:color-mix(in srgb, var(--danger) 9%, transparent);
--ok-soft:color-mix(in srgb, var(--ok) 12%, transparent);
--edge-accent:color-mix(in srgb, var(--accent) 42%, transparent);
--focus-ring:0 0 0 3px color-mix(in srgb, var(--accent) 9%, transparent);
--av-bg-l:88%; --av-fg-l:34%; /* sender-disc lightness, day */
--fs-chip:10px; --fs-meta:11px; --fs-note:12px; --fs-body:13px; --fs-title:15px; --fs-word:19px;
--r-1:6px; --r-2:9px; --r-3:12px; --r-card:16px; --r-fold:22px; --r-pill:999px;
--t-fast:.13s; --t-reveal:.16s; --t-base:.2s; --t-slow:.42s;
--serif:'Newsreader',Georgia,serif;
--sans:'Inter',ui-sans-serif,system-ui,-apple-system,sans-serif;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,monospace;
--settle:cubic-bezier(.4,0,.2,1);
--soft:cubic-bezier(.22,1,.28,1);
}
/* Night: the same sheet, warm — not inverted gray. Two layers: the pill
follows the OS appearance until the header toggle ever picks a side —
then that explicit choice sticks (persisted) and the system stops
mattering. Resolution happens in JS; this attribute is the only switch. */
:root[data-theme="dark"] {
--bg:#221E1B;
--ink:#EDE7E1; --ink-2:#DDD6CE;
--muted:#A2988E; --muted-2:#837A71; --muted-3:#615950;
--accent:#8FB0C0; --warn:#C99A6A; --on-accent:#16232A;
--brand-anthropic:#E08862;
--brand-codex:#8B9BF9;
--agent-sq:#8CA2E0;
--danger:#E06258; --ok:#58B575;
--hair:rgba(237,231,225,.08);
--wash:rgba(237,231,225,.04);
--wash-2:rgba(237,231,225,.07);
--wash-3:rgba(237,231,225,.11);
--edge:rgba(237,231,225,.18);
--paper-edge:rgba(255,255,255,.06);
--shadow-card:inset 0 1px 0 var(--paper-edge);
--shadow-pill:inset 0 1px 0 var(--paper-edge);
--shadow-pop:0 12px 30px -18px rgba(0,0,0,.6);
--av-bg-l:24%; --av-fg-l:76%;
}
* { box-sizing:border-box; }
html, body { margin:0; height:100%; background:transparent; overflow:hidden; }
body {
font-family:var(--sans); color:var(--ink);
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
cursor:default;
}
/* One sheet of warm-white paper. Width/height are written by a JS spring each frame;
the shadow + radius ride a matching CSS curve so the sheet lightens as it folds.
Elevation is three tokens: the card's own drop shadow (this one), the
folded pill's lighter cousin, and one popover shadow — nothing else casts. */
.card {
--relay-loop-state:paused;
/* The native window preserves its current screen-space top-right point while
it changes size. Anchor the visible sheet to that same point so a folded
pill never lands at the old left edge and then jumps home when AppKit
trims the transparent remainder. This remains local to the current native
window, so a user-dragged pill morphs exactly where it was placed. */
position:absolute; top:0; right:0;
width:344px; height:524px;
background:transparent;
border-radius:var(--r-card);
overflow:visible;
display:flex; flex-direction:column;
box-shadow:var(--shadow-card);
transition:box-shadow var(--t-slow) var(--soft), border-radius var(--t-slow) var(--settle);
animation:appIn var(--t-slow) var(--soft) both;
}
.card[data-continuous-motion="running"] { --relay-loop-state:running; }
.card.collapsed {
border-radius:var(--r-fold);
box-shadow:var(--shadow-pill);
}
/* Keep the shadow on the outer shell and clip all promoted content layers on
a dedicated surface. During list -> chat transitions Chromium snapshots the
scroller into a compositor layer; relying on the shell's overflow clip can
expose that layer through square bottom corners for one frame. clip-path is
retained by the compositor for the whole resize without cutting the shadow. */
.card-surface {
width:100%; height:100%;
display:flex; flex-direction:column;
overflow:hidden;
border-radius:inherit;
background:var(--bg);
clip-path:inset(0 round 16px);
transition:clip-path .42s var(--settle);
}
.card.collapsed .card-surface { clip-path:inset(0 round 22px); }
@keyframes appIn { from { opacity:0; transform:scale(.97); } to { opacity:1; transform:none; } }
/* Dismissal states. `offstage` parks the card invisibly while the OS window is hidden.
`bye` is the graceful exit (✕ click, or a ghost notification finishing). A `ghost`
card is the designed notification shown while the pill is dismissed: it emerges like
a banner (drift down + fade) and NEVER folds back into a pill — it just leaves. */
.card.offstage { visibility:hidden; animation:none; }
.card.bye { animation:byeOut var(--t-base) var(--settle) both; pointer-events:none; }
@keyframes byeOut { from { opacity:1; transform:none; } to { opacity:0; transform:scale(.96) translateY(-10px); } }
.card.ghost { animation:ghostIn var(--t-slow) var(--soft) both; }
@keyframes ghostIn { from { opacity:0; transform:translateY(-14px) scale(.98); } to { opacity:1; transform:none; } }
.card.ghost.bye { animation:ghostOut var(--t-slow) var(--settle) both; }
@keyframes ghostOut { from { opacity:1; transform:none; } to { opacity:0; transform:translateY(-12px) scale(.985); } }
/* brand lockup — present in BOTH states; no surface, no rule */
.lockup {
flex:0 0 44px; display:flex; align-items:center; gap:9px;
padding:0 18px; cursor:pointer;
}
.mark { display:block; }
.mark rect { transition:fill var(--t-base) var(--settle); }
.mark rect:nth-child(1) { fill:var(--brand-anthropic); }
.mark rect:nth-child(2) { fill:var(--ink); }
.mark rect:nth-child(3) { fill:url(#relayCodexGradient); }
.word { font-family:var(--serif); font-size:19px; font-weight:500; letter-spacing:-.01em; color:var(--ink); line-height:1; }
.spacer { flex:1 1 auto; }
.count {
font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
color:var(--accent); line-height:1; white-space:nowrap; transition:color var(--t-base) var(--settle);
}
.count.zero { color:var(--muted-3); }
/* The counter belongs to the FOLDED pill ("3 unread" is its content) and the
peek banner. On the big card it's redundant — the tab badge and the rows
already say it — so it steps aside for the window buttons. */
.card:not(.collapsed):not(.peek) .count { display:none; }
/* A notification is one message, not the app. The wordmark and the counter
say nothing a single visible row does not already say, and both were
stealing width from the only line that matters. The mark stays: it is how
you know at a glance whose notice this is. */
.card.peek .word, .card.peek .count { display:none; }
/* A notification has room the list does not: it shows one or two arrivals,
not a hundred, so the title is allowed to finish its sentence rather than
stopping mid-word. This is the whole reason the card got wider. */
.card.peek .rk-subject, .card.peek .th-title { -webkit-line-clamp:4; }
/* Banner spacing (David): the delete-lane time inset and the body's edge
games are list furniture — in a banner they just read as a fat empty
right gutter. Balance the card: text runs to the same margin both sides. */
/* The banner is 400 wide but .scroll is width-FIXED at pill width for
containment — without this, rows render in a 344px column inside the
400px card and the leftover reads as a fat dead right gutter (David's
screenshot; measured 56px + row padding = 72px). */
.card.peek .scroll { width:400px; }
.card.peek .rk-time, .card.peek .th-item.deletable .th-time { margin-right:0; }
.card.peek .rk-body { margin-right:0; padding-right:0; }
.card.peek .row { padding-right:16px; }
/* How many relays a conversation holds is reference for the list. On an
arrival it is a second number competing with the one that matters. */
.card.peek .th-count.quiet { display:none; }
/* Window buttons: moon/sun flips the theme (the pill's own choice, stored —
never read from the OS), − folds, ✕ hides. − exists only on the big card:
the folded pill IS the minimized state. Hovers diverge so muscle memory
forms: theme/fold get the neutral wash, ✕ warms toward danger. */
.winbtn {
flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
width:20px; height:20px; border-radius:var(--r-1); padding:0;
display:inline-flex; align-items:center; justify-content:center;
color:var(--muted-3);
transition:color var(--t-fast) var(--settle), background-color var(--t-fast) var(--settle);
}
.winbtn:hover { color:var(--ink-2); background:var(--wash-2); }
.winbtn:active { color:var(--ink); }
.winbtn .sun { display:none; }
:root[data-theme="dark"] .winbtn .sun { display:block; }
:root[data-theme="dark"] .winbtn .moon { display:none; }
.card.collapsed .theme-toggle, .card.peek .theme-toggle,
.card.collapsed .minimize-btn, .card.peek .minimize-btn { display:none; }
/* ✕ — fully hide the overlay; the Relay mark in the OS status area brings it back.
Present in every card state (expanded, pill, notification) so the pill is always escapable. */
.close-x {
flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
width:20px; height:20px; border-radius:var(--r-1); padding:0; margin-left:6px; margin-right:-6px;
display:inline-flex; align-items:center; justify-content:center;
color:var(--muted-3);
transition:color var(--t-fast) var(--settle), background-color var(--t-fast) var(--settle);
}
.close-x:hover { color:var(--danger); background:var(--danger-soft); }
.close-x:active { color:var(--ink); }
.close-x.unavailable { opacity:.38; cursor:default; }
.close-x.unavailable:hover { color:var(--muted-3); background:transparent; }
@keyframes countRoll { from { transform:translateY(-6px); opacity:.3; } to { transform:none; opacity:1; } }
/* tab control — individual rounded-rect buttons (the loved control) */
.tabs {
flex:0 0 auto;
display:flex; gap:4px; flex-wrap:nowrap;
padding:0 12px 6px;
opacity:1;
transition:opacity var(--t-base) var(--settle);
}
.card.collapsed .tabs { opacity:0; pointer-events:none; }
/* a notification (peek) collapses the tab rail to nothing, so the peek shows just
the brand lockup and the new relay. */
.card.peek .tabs {
height:0;
padding-top:0;
padding-bottom:0;
opacity:0;
overflow:hidden;
pointer-events:none;
}
.tab {
appearance:none; border:0; background:transparent; color:var(--muted-2);
font-family:var(--sans); font-size:11px; font-weight:500;
height:24px; padding:0 6px; border-radius:var(--r-1); cursor:pointer;
display:inline-flex; align-items:center; gap:3px;
transition:background-color var(--t-fast) var(--settle), color var(--t-fast) var(--settle);
}
.tab:hover { background:var(--wash-2); color:var(--ink-2); }
.tab.active { background:var(--accent-soft); color:var(--accent); }
.tab-badge {
font-family:var(--sans); font-size:10px; font-weight:600;
font-variant-numeric:tabular-nums; line-height:1;
color:var(--on-accent); background:var(--accent);
border-radius:var(--r-pill); padding:3px 5px; min-width:17px; text-align:center;
display:inline-block; transform:translateY(-1px);
transition:opacity var(--t-fast) var(--settle);
}
.tab:not(.active) .tab-badge { opacity:.45; }
.tab-badge.gone { display:none; }
/* Tasks debt is a different color from unread ink — amber, not accent. */
.tab-badge.amber { background:color-mix(in srgb, var(--warn, #8A5A32) 16%, transparent); color:var(--warn, #8A5A32); }
.mark-all-read { display:none !important; } /* leaving the rail — tray menu owns bulk-clear next */
/* scrolling content layer (host for each view) */
.scroll {
flex:1 1 auto; min-height:0; width:344px;
overflow-y:auto; overscroll-behavior:contain;
background:var(--bg);
/* The card's open/close animation changes its HEIGHT every frame. Without
containment each of those frames re-lays-out every row in this scroller
(150+ on a real inbox) — ~60 full reflows a second, which is what made
opening judder and delayed the click that starts it. `contain` tells the
engine this subtree's layout can't affect (or be affected by) the box
around it, so resizing the card no longer touches the rows. */
contain:layout paint style;
padding:0 0 8px;
transition:opacity var(--t-base) var(--settle) .06s;
}
.card.collapsed .scroll { opacity:0; pointer-events:none; transition:opacity var(--t-fast) var(--settle); }
.card.peek .scroll {
opacity:1; pointer-events:auto;
/* The banner is measured to its whole row. Give the final line a real
bottom inset; a fade mask made complete text LOOK clipped even when the
measured card was technically tall enough. */
padding-bottom:20px;
}
/* A banner is sized to its rows, so it has nothing to scroll — and a scrollbar here
is worse than cosmetic: claiming its 10px narrows the rows, which re-wraps the
title, which makes the rows taller than the height we just measured them at. The
wheel still works; the mask above is what says "there's more". */
.card.peek .scroll::-webkit-scrollbar { width:0; }
.scroll::-webkit-scrollbar { width:10px; }
.scroll::-webkit-scrollbar-thumb { background:var(--edge); border-radius:var(--r-pill); border:3px solid var(--bg); background-clip:padding-box; }
.scroll::-webkit-scrollbar-thumb:hover { background:var(--edge); background-clip:padding-box; }
/* The list must NEVER scroll sideways. overflow-y:auto computes overflow-x
to auto, so any row whose min-content crosses the width (the custom
scrollbar already eats 10px of it) sprouted a horizontal scrollbar — and
with both bars present, the UNSTYLED scrollbar corner painted as a white
square over the card's bottom-right (field report, 2026-08-12). Rows
already ellipsize/wrap internally; sideways overflow is always a bug, so
clip it categorically and keep the corner transparent as belt-and-braces. */
.scroll { overflow-x:hidden; }
.scroll::-webkit-scrollbar:horizontal { height:0; }
.scroll::-webkit-scrollbar-corner { background:transparent; }
.view.hidden { display:none; }
/* Every compact surface -> reader frame is one morph, not a navigation
followed by a resize. The source snapshot holds the exact pixels the click
began on while the prepared reader/chat fades in underneath. This keeps a
720px destination from appearing as a cropped debug view inside the still-
narrow card. Tasks, Chat, a compact room and Sent share this contract. */
@keyframes readerSourceOut {
from { opacity:1; transform:translateX(0) scale(1); }
to { opacity:0; transform:translateX(8px) scale(.985); }
}
@keyframes readerDestinationIn {
from { opacity:0; transform:translateX(-8px) scale(.992); }
to { opacity:1; transform:none; }
}
.scroll.reader-morph { position:relative; }
.reader-morph-snapshot {
position:absolute; right:0; width:344px; z-index:4; pointer-events:none;
transform-origin:right top; contain:layout paint style;
}
/* The frozen face cannot keep duplicate DOM ids, but expanded Chat's split
geometry is intentionally expressed with id selectors. Mirror the few
structural selectors against inert data markers so a scrolled room is a
pixel-faithful snapshot instead of flashing back to its compact/top layout. */
.reader-morph-snapshot[data-reader-morph-id="threadsView"] { min-height:100%; display:flex; flex-direction:column; }
.reader-morph-snapshot[data-reader-morph-id="threadsView"] > [data-reader-morph-id="thDetail"] { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.reader-morph-snapshot [data-reader-morph-id="thDetail"] > .th-detail-head { flex:none; }
.reader-morph-snapshot [data-reader-morph-id="thDetail"] > [data-reader-morph-id="thHistory"] { flex:1 1 auto; display:flex; flex-direction:column; justify-content:flex-end; }
.reader-morph-snapshot [data-reader-morph-id="thHistory"] > * { flex:none; }
.reader-morph-snapshot [data-reader-morph-id="thRows"] { display:flex; flex-direction:column; }
.reader-morph-snapshot [data-reader-morph-id="thRows"] > * { flex:none; }
.reader-morph-snapshot [data-reader-morph-id="thRows"].inbox-order .th-msg:last-of-type { margin-bottom:10px; }
.reader-morph-snapshot[data-reader-morph-id="threadsView"].chat-max { flex-direction:row; height:100%; min-height:0; overflow:hidden; }
.reader-morph-snapshot[data-reader-morph-id="threadsView"].chat-max .chat-rail { display:flex; flex-direction:column; width:182px; height:100%; min-height:0; flex:0 0 auto; border-right:1px solid var(--hair); overflow-y:auto; overflow-x:hidden; padding-top:4px; overflow-anchor:none; }
.reader-morph-snapshot[data-reader-morph-id="threadsView"].chat-max [data-reader-morph-id="thDetail"] { flex:1 1 auto; min-width:0; height:100%; margin:0; max-width:none; overflow-y:auto; }
.scroll.reader-morph:not(.reader-morph-go) .reader-morph-destination { opacity:0; }
.scroll.reader-morph.reader-morph-go:not(.reader-morph-push) .reader-morph-snapshot {
animation:readerSourceOut .18s var(--settle) both;
}
.scroll.reader-morph.reader-morph-go:not(.reader-morph-push) .reader-morph-destination {
transform-origin:center top;
animation:readerDestinationIn .28s var(--soft) .035s both;
}
/* Compact list <-> room navigation uses Chromium's View Transition snapshots.
These are the exact pixels from the two rendered states, so the sticky room
header/composer and the scrolled list cannot become independent moving
layers or be re-laid-out as a clone halfway through the push. The tab rail
is outside the named .scroll snapshot and therefore stays perfectly still. */
@keyframes roomPixelsForwardOld {
from { transform:translate3d(0,0,0); }
to { transform:translate3d(-24%,0,0); }
}
@keyframes roomPixelsForwardNew {
from { transform:translate3d(100%,0,0); }
to { transform:translate3d(0,0,0); }
}
@keyframes roomPixelsBackOld {
from { transform:translate3d(0,0,0); }
to { transform:translate3d(100%,0,0); }
}
@keyframes roomPixelsBackNew {
from { transform:translate3d(-24%,0,0); }
to { transform:translate3d(0,0,0); }
}
html.room-view-transition::view-transition-old(root),
html.room-view-transition::view-transition-new(root) {
animation:none; mix-blend-mode:normal;
}
html.room-view-transition::view-transition-group(relay-room) {
animation-duration:.36s;
animation-timing-function:cubic-bezier(.32,.72,0,1);
overflow:hidden;
}
html.room-view-transition::view-transition-image-pair(relay-room) {
isolation:isolate;
}
html.room-view-transition::view-transition-old(relay-room),
html.room-view-transition::view-transition-new(relay-room) {
background:var(--bg);
}
html.room-view-transition[data-room-motion="forward"]::view-transition-old(relay-room) {
animation:roomPixelsForwardOld .36s cubic-bezier(.32,.72,0,1) both;
}
html.room-view-transition[data-room-motion="forward"]::view-transition-new(relay-room) {
animation:roomPixelsForwardNew .36s cubic-bezier(.32,.72,0,1) both;
}
html.room-view-transition[data-room-motion="back"]::view-transition-old(relay-room) {
animation:roomPixelsBackOld .36s cubic-bezier(.32,.72,0,1) both;
}
html.room-view-transition[data-room-motion="back"]::view-transition-new(relay-room) {
animation:roomPixelsBackNew .36s cubic-bezier(.32,.72,0,1) both;
}
/* A room is spatial navigation, not a dissolve. Keep the application chrome
still and move one opaque content face at a time, using the same direction
on entry and its exact inverse on Back. The trailing face travels farther;
the covered face only recedes a little, which gives the quiet depth cue of
Apple's navigation stacks without scaling text or showing two readable
conversations on top of each other. */
@keyframes roomForwardSourceOut {
from { transform:translate3d(0,0,0); }
to { transform:translate3d(-24%,0,0); }
}
@keyframes roomForwardDestinationIn {
from { transform:translate3d(100%,0,0); }
to { transform:translate3d(0,0,0); }
}
@keyframes roomBackSourceOut {
from { transform:translate3d(0,0,0); }
to { transform:translate3d(100%,0,0); }
}
@keyframes roomBackDestinationIn {
from { transform:translate3d(-24%,0,0); }
to { transform:translate3d(0,0,0); }
}
.scroll.reader-morph-push .reader-morph-snapshot,
.scroll.reader-morph-push .reader-morph-destination {
min-height:100%; background:var(--bg); will-change:transform;
}
.scroll.reader-morph-push:not(.reader-morph-back) .reader-morph-snapshot { z-index:4; }
.scroll.reader-morph-push:not(.reader-morph-back) .reader-morph-destination { position:relative; z-index:5; }
.scroll.reader-morph-push.reader-morph-back .reader-morph-snapshot { z-index:5; }
.scroll.reader-morph-push.reader-morph-back .reader-morph-destination { position:relative; z-index:4; }
.scroll.reader-morph-push.reader-morph-go:not(.reader-morph-back) .reader-morph-snapshot {
animation:roomForwardSourceOut .36s cubic-bezier(.32,.72,0,1) both;
}
.scroll.reader-morph-push.reader-morph-go:not(.reader-morph-back) .reader-morph-destination {
animation:roomForwardDestinationIn .36s cubic-bezier(.32,.72,0,1) both;
}
.scroll.reader-morph-push.reader-morph-go.reader-morph-back .reader-morph-snapshot {
animation:roomBackSourceOut .36s cubic-bezier(.32,.72,0,1) both;
}
.scroll.reader-morph-push.reader-morph-go.reader-morph-back .reader-morph-destination {
animation:roomBackDestinationIn .36s cubic-bezier(.32,.72,0,1) both;
}
@media (prefers-reduced-motion:reduce) {
.reader-morph-snapshot { display:none; }
.scroll.reader-morph .reader-morph-destination { opacity:1; animation:none; transform:none; }
html.room-view-transition::view-transition-old(relay-room),
html.room-view-transition::view-transition-new(relay-room) { animation-duration:.001s; }
}
/* ---- group headers (Tasks) ---- */
/* Calm, refined section labels: the serif for warmth, sentence case, gentle weight,
no all-caps / wide tracking / mono. The whole list reads quiet rather than shouty. */
.group-head {
font-family:var(--serif); font-size:13px; font-weight:500; letter-spacing:0; text-transform:none;
color:var(--muted-2); padding:14px 16px 6px; display:flex; align-items:baseline; gap:7px;
}
.group-head .ghc { font-family:var(--sans); font-size:11px; font-variant-numeric:tabular-nums; color:var(--muted-3); }
/* ---- relay items — flat content on the sheet, one hairline between. No box, no hover-lift. ---- */
.row {
position:relative; display:flex; gap:11px; cursor:pointer;
padding:13px 16px 0; margin:0;
background:transparent;
transition:background-color var(--t-fast) var(--settle);
}
.row:hover { background:var(--wash); }
.row-delete {
appearance:none; position:absolute; top:9px; right:9px; width:25px; height:25px;
display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--r-1);
background:transparent; color:var(--muted-3); cursor:pointer; opacity:0;
transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle), background var(--t-fast) var(--settle);
}
.row:hover .row-delete, .row-delete:focus-visible { opacity:1; }
.row-delete:hover { color:var(--danger); background:var(--danger-soft); }
.row.settled .row-delete, .th-item .row-delete { background:transparent; }
.row-delete:disabled { opacity:.45; cursor:default; }
.row.deletable .rk-time, .th-item.deletable .th-time { margin-right:22px; }
@keyframes rowEnter { from { opacity:0; transform:translateY(-12px); } to { opacity:1; transform:none; } }
.row.enter { animation:rowEnter .52s var(--soft) both; }
/* Messenger anatomy: sender disc · name · title · mono time. Per row, one
bold thing (the name), one serif thing (the title), one mono thing (the
time); read-state changes ink, never weight. */
/* The divider belongs to the text column — inset past the disc (messenger
convention), running to the card's right edge. Row padding-bottom moves in
here so the hairline sits outside the content. */
.rk-body { flex:1 1 auto; min-width:0; display:block; border-bottom:1px solid var(--hair); padding-bottom:13px; margin-right:-16px; padding-right:16px; }
.th-item .rk-body { padding-bottom:11px; }
.avatar {
position:relative; flex:0 0 auto; width:28px; height:28px; border-radius:50%; margin-top:1px;
background:hsl(var(--av-h,24) 22% var(--av-bg-l));
color:hsl(var(--av-h,24) 30% var(--av-fg-l));
font-size:10px; font-weight:600; letter-spacing:.02em; line-height:1;
display:flex; align-items:center; justify-content:center; user-select:none;
}
.row.settled .avatar { opacity:.6; }
/* opening: a tapered accent arc rings the sender's disc, from click until the
relay has materialized + opened (stopped by the openDone signal from main.cjs). */
.avatar::after {
content:""; position:absolute; inset:-3px; border-radius:50%;
background:conic-gradient(from 90deg, transparent, var(--accent) 280deg, transparent 318deg);
-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - 1.6px), #000 0);
mask:radial-gradient(farthest-side, #0000 calc(100% - 1.6px), #000 0);
opacity:0; transition:opacity var(--t-base) var(--settle); pointer-events:none;
}
.row.opening .avatar::after, .th-item.opening .avatar::after { opacity:1; animation:dotSpin .7s linear infinite; animation-play-state:var(--relay-loop-state); }
@keyframes dotSpin { to { transform:rotate(1turn); } }
.rk-top { display:flex; align-items:baseline; gap:7px; }
.rk-sender { font-size:13px; font-weight:600; color:var(--ink); letter-spacing:.1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.rk-time { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; }
/* ONE chip recipe. A number on a conversation means unread, nothing else. */
.chip {
flex:0 0 auto; align-self:center; margin-left:auto;
font-family:var(--sans); font-size:10px; font-weight:600;
font-variant-numeric:tabular-nums; letter-spacing:.02em; line-height:1;
color:var(--on-accent); background:var(--accent);
border-radius:var(--r-pill); padding:3px 7px; min-width:17px; text-align:center;
}
.rk-top .chip + .rk-time, .th-top .chip + .th-time { margin-left:0; }
/* Sent receipt: a word, not a glyph — "Sent 3h" until the recipient reads it,
then "Read 3h" in accent. Delivery plumbing stays out of the product copy. */
.st { flex:0 0 auto; margin-left:auto; align-self:baseline; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; white-space:nowrap; }
.st.read { color:var(--accent); font-weight:500; }
.st.failed { color:var(--danger); font-weight:500; }
/* Task receipts extend the same word ladder: Started rides the accent like
Read; Done settles green — the one place the ok color appears in a row. */
.st.done { color:var(--ok); font-weight:500; }
/* The task marker: the tab-badge recipe gone soft — accent ink on accent
wash, sitting with the sender because "Shane · Task" is the sentence. */
.kchip {
flex:0 0 auto; font-family:var(--sans); font-size:10px; font-weight:600;
letter-spacing:.02em; line-height:1; color:var(--accent);
background:var(--accent-soft); border-radius:var(--r-pill); padding:3px 7px;
transform:translateY(-1px);
}
.echip {
flex:0 0 auto; font-family:var(--sans); font-size:10px; font-weight:600;
letter-spacing:.01em; line-height:1; color:var(--ok);
border:1px solid color-mix(in srgb, var(--ok) 38%, transparent);
border-radius:var(--r-pill); padding:3px 7px; transform:translateY(-1px);
}
.row.settled .rk-subject { color:var(--muted-2); }
/* subject: single-line serif, right-edge fade mask */
.rk-subject {
font-family:var(--serif); font-size:15px; line-height:1.32; color:var(--muted); font-weight:400;
margin-top:5px;
/* Two full lines with an ellipsis: titles must be scannable when choosing
which relay to open — the old right-edge fade cut them after a few words. */
overflow:hidden; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical;
overflow-wrap:anywhere;
transition:color var(--t-base) var(--settle);
user-select:text; cursor:text;
}
.row.unread .rk-subject { color:var(--ink); font-weight:500; }
/* body preview beneath the subject (plain messages + results) */
.rk-preview {
font-size:12px; line-height:1.4; color:var(--muted); margin-top:3px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* reply composer: rounded reply field + prussian round send (thread replies) */
.qr { display:flex; gap:8px; margin-top:11px; align-items:flex-end; }
/* Thread reply composer — a REAL send (createRelay + attachments). */
.th-reply { padding:8px 14px 12px; }
.th-reply .qr { margin-top:0; align-items:center; }
.cmp-files { display:flex; flex-wrap:wrap; gap:5px; padding:0 0 6px; }
.cmp-files:empty { display:none; }
.cmp-chip { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--edge); border-radius:9px; padding:3px 6px 3px 8px;
font-size:11px; font-weight:600; color:var(--ink); background:var(--bg); max-width:100%; min-width:0; }
.cmp-chip .sz { font-family:var(--mono); font-size:9.5px; font-weight:400; color:var(--muted-3); }
.cmp-chip .x { appearance:none; border:0; background:transparent; color:var(--muted-3); font-size:12px; line-height:1; padding:0 2px; cursor:pointer; }
.cmp-chip .x:hover { color:var(--ink); }
.th-reply .cmp-plus { appearance:none; flex:0 0 auto; align-self:center; width:20px; height:20px; min-width:20px; padding:0; margin:0;
border-radius:6px; border:1px solid var(--hair); background:transparent; color:var(--muted-2); cursor:pointer;
display:inline-flex; align-items:center; justify-content:center; }
.th-reply .cmp-plus:hover { color:var(--ink); border-color:var(--muted-3); background:var(--wash); }
.th-reply-err { font-size:10.5px; color:var(--danger, #B4332A); padding:4px 2px 0; min-height:0; }
.th-reply-err:empty { display:none; }
.qr-input {
flex:1 1 auto; min-height:34px; max-height:86px; resize:none;
border:1px solid var(--edge); border-radius:999px; padding:8px 14px;
font-family:var(--sans); font-size:13px; line-height:1.3; color:var(--ink);
background:var(--bg); outline:none;
transition:border-color var(--t-fast) var(--settle), box-shadow var(--t-fast) var(--settle);
}
.qr-input::placeholder { color:var(--muted-3); }
.qr-input:focus { border-color:var(--edge-accent); box-shadow:var(--focus-ring); }
/* round Send: 32px prussian circle, white up-arrow */
.qr-send {
flex:0 0 auto; appearance:none; border:0; cursor:pointer;
width:32px; height:32px; border-radius:50%; background:var(--accent);
display:inline-flex; align-items:center; justify-content:center; padding:0;
transition:opacity var(--t-fast) var(--settle);
}
.qr-send:hover { opacity:.88; }
.qr-send:active { opacity:.7; }
.qr-send:disabled { opacity:.4; cursor:default; }
/* inline actions — flat text, prussian accept / muted decline */
.rk-actions { display:flex; gap:18px; margin-top:12px; align-items:center; flex-wrap:wrap; }
.act-btn {
appearance:none; cursor:pointer; border:0; background:transparent; padding:0;
font-family:var(--sans); font-size:13px; font-weight:600; line-height:1;
transition:opacity var(--t-fast) var(--settle);
}
.act-btn:hover { opacity:.72; }
.act-btn:disabled { opacity:.4; cursor:default; }
.act-btn.accept { color:var(--accent); }
.act-btn.decline { color:var(--muted-2); font-weight:500; }
.row-note { font-size:12px; color:var(--muted-2); margin-top:9px; line-height:1.45; }
.row-err { font-size:12px; color:var(--danger); margin-top:8px; line-height:1.4; }
.row-err:empty { display:none; }
/* Positive variant: injection confirmations reuse the same slot in green. */
.row-err.ok { color:var(--ok); }
/* ---- request items — also flat, divided by one hairline ---- */
.request {
position:relative; display:block; width:100%; text-align:left; appearance:none; cursor:pointer; font-family:var(--sans);
padding:13px 16px; margin:0;
background:transparent; border:0; border-bottom:1px solid var(--hair);
transition:background-color var(--t-fast) var(--settle);
}
.task:hover { background:var(--wash); }
.task-top { display:flex; align-items:center; gap:7px; }
.t-dot { flex:0 0 auto; width:6px; height:6px; border-radius:50%; background:transparent; border:1.5px solid var(--muted-3); box-sizing:border-box; }
.t-dot.s-active { background:var(--accent); border-color:var(--accent); }
/* state kicker: calm Inter, sentence case, gentle weight + tracking (no mono / all-caps). */
.t-state {
font-family:var(--sans); font-size:11px; font-weight:500; letter-spacing:.01em; text-transform:none; color:var(--muted-2);
}
.t-state.s-active { color:var(--accent); }
.request-time { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; }
.request-title {
font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--ink); font-weight:500; margin-top:6px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.task-obj { font-size:12px; color:var(--muted); line-height:1.4; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.task-meta { display:flex; align-items:center; gap:6px; margin-top:7px; font-size:12px; color:var(--muted-2); flex-wrap:wrap; }
.task-meta .sep { color:var(--muted-3); }
.task-approve { color:var(--accent); font-weight:600; }
/* ---- request detail (in-pill) — conversation-first ---- */
.td-head { display:flex; align-items:center; gap:8px; padding:12px 16px 0; }
.td-back {
appearance:none; border:0; background:transparent; cursor:pointer; padding:2px 8px 2px 0;
font-family:var(--sans); font-size:13px; font-weight:500; color:var(--muted-2);
transition:color var(--t-fast) var(--settle);
}
.td-back:hover { color:var(--ink); }
.td-when { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--muted-2); font-variant-numeric:tabular-nums; }
.td-title { font-family:var(--serif); font-size:19px; font-weight:500; line-height:1.25; color:var(--ink); padding:10px 16px 0; overflow-wrap:break-word; }
.td-meta { display:flex; align-items:center; gap:7px; padding:7px 16px 0; font-size:12px; color:var(--muted-2); flex-wrap:wrap; }
.td-meta .sep { color:var(--muted-3); }
.td-state { font-weight:500; }
.td-state.s-active { color:var(--accent); }
.td-obj { padding:6px 16px 0; font-size:12px; line-height:1.5; color:var(--muted); overflow-wrap:break-word; }
.td-err { margin:12px 16px 0; padding:8px 10px; border-radius:var(--r-2); background:var(--danger-soft); color:var(--danger); font-size:12px; line-height:1.4; overflow-wrap:break-word; }
.td-quiet { padding:8px 16px; font-size:12px; line-height:1.5; color:var(--muted-2); }
/* action card — the one thing waiting on you, pinned above the thread */
.td-card { margin:20px 16px 0; padding:14px; border-radius:var(--r-3); background:var(--accent-soft); }
.td-card + .td-card { margin-top:10px; }
.td-kicker { font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:.02em; color:var(--accent); }
.td-card-summary { margin-top:6px; font-size:13px; font-weight:500; line-height:1.45; color:var(--ink); white-space:pre-line; overflow-wrap:break-word; }
.td-card-to { margin-top:4px; font-size:12px; color:var(--muted-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.td-card-quote {
margin-top:8px; padding-left:10px; border-left:2px solid color-mix(in srgb, var(--accent) 28%, transparent);
font-size:12px; line-height:1.5; color:var(--muted); white-space:pre-line; overflow-wrap:break-word;
display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden;
}
.td-card-actions { display:flex; gap:10px; margin-top:12px; align-items:center; }
.td-expires { margin-left:auto; flex:none; font-family:var(--mono); font-size:10px; color:var(--muted-2); white-space:nowrap; }
.td-btn {
appearance:none; cursor:pointer; border:0; border-radius:var(--r-2); padding:8px 16px;
font-family:var(--sans); font-size:13px; font-weight:600; line-height:1;
transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle);
}
.td-btn:disabled { opacity:.4; cursor:default; }
.td-btn.primary { background:var(--accent); color:var(--on-accent); }
.td-btn.primary:hover:not(:disabled) { opacity:.9; }
.td-btn.primary:active:not(:disabled) { opacity:.75; }
.td-btn.ghost { background:color-mix(in srgb, var(--bg) 55%, transparent); border:1px solid var(--edge); color:var(--muted); padding:7px 14px; font-weight:500; }
.td-btn.ghost:hover:not(:disabled) { color:var(--ink); }
/* relay-row expand card: clicking a relay row reveals its two open actions
("Open in current chat" / "Open in new chat") inside the row; a second
click folds them away. Rides the .td-card surface, tightened to row padding. */
.row-open-card { margin:11px 0 0; padding:11px 12px; }
.row-open-card .td-card-actions { margin-top:0; }
/* ---- Open actions: a small native-feeling action menu, not buttons.
One quiet inset surface, hairline-separated text rows; hierarchy from
weight and the single accent, never from fills, gradients, icons, or
explanatory subtitles. States are flat washes. Motion settles in 160ms. ---- */
.open-actions {
/* padding:3px is not arbitrary. Item radius (6px) + inset (3px) equals the
container radius (9px) exactly, so the hover pill's arc runs parallel to
the tray's. At 2px the arcs disagree by a pixel and the tray stops
reading as one machined object. */
margin:10px 0 2px; padding:3px; border-radius:var(--r-2);
background:var(--wash);
display:flex; flex-direction:column;
animation:oaReveal var(--t-reveal) var(--settle) both;
}
@keyframes oaReveal {
from { opacity:0; transform:translateY(-2px); }
to { opacity:1; transform:none; }
}
.oa-item {
appearance:none; border:0; background:transparent; cursor:pointer;
width:100%; text-align:left; border-radius:var(--r-1);
padding:8px 10px; font-family:var(--sans);
/* Body size, via the token. The literal 12px this replaced was caption size
(--fs-note), which made the decision surface set SMALLER than the sender
name that spawned it — the menu was tokenized rather than redesigned, and
absorbed one step of shrink from every scale it touched. */
font-size:var(--fs-body); font-weight:400; letter-spacing:-0.01em;
color:var(--ink); line-height:1.2;
transition:background-color var(--t-fast) var(--settle);
}
.oa-item:hover { background:var(--wash-2); }
.oa-item:active { background:var(--wash-3); }
.oa-primary { font-weight:600; color:var(--accent); }
.oa-primary:hover { background:color-mix(in srgb, var(--accent) 8%, transparent); }
.oa-primary:active { background:color-mix(in srgb, var(--accent) 13%, transparent); }
.oa-sep { height:1px; margin:2px 8px; background:var(--hair); }
/* zone label — one calm sans overline, sentence case */
.td-label { padding:24px 16px 2px; font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:.02em; color:var(--muted-2); }
/* results — compact, read-only */
.td-result { padding:8px 16px 0; }
.td-result-title { font-size:13px; font-weight:600; line-height:1.4; color:var(--ink); overflow-wrap:break-word; }
.td-result-body {
margin-top:2px; font-size:12px; line-height:1.5; color:var(--muted); white-space:pre-line; overflow-wrap:break-word;
display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden;
}
/* conversation thread — the spine */
.td-msg { padding:0 16px; margin-top:14px; }
.td-msg-top { display:flex; align-items:baseline; gap:8px; min-width:0; }
.td-msg-sender { font-size:12px; font-weight:600; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.td-msg-time { margin-left:auto; flex:none; font-family:var(--mono); font-size:10px; color:var(--muted-3); font-variant-numeric:tabular-nums; }
.td-msg-body {
margin-top:3px; font-size:13px; line-height:1.55; color:var(--ink-2); white-space:pre-line; overflow-wrap:break-word;
display:-webkit-box; -webkit-line-clamp:6; -webkit-box-orient:vertical; overflow:hidden;
}
/* human-typed message: accent rule + accent identity */
.td-msg.human { position:relative; padding-left:26px; }
.td-msg.human::before { content:""; position:absolute; left:16px; top:2px; bottom:2px; width:2px; border-radius:1px; background:var(--accent); }
.td-msg.human .td-msg-sender { color:var(--accent); }
/* system / notice line — quiet, no identity */
.td-sys { margin-top:14px; padding:0 16px; font-size:11px; line-height:1.4; color:var(--muted-3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* attachment chips (markup emitted by attachmentChips helper) */
.td-att { display:flex; align-items:center; gap:7px; margin-top:8px; padding:6px 9px; border:1px solid var(--hair); border-radius:var(--r-2); min-width:0; background:color-mix(in srgb, var(--bg) 60%, transparent); }
/* clickable variant: same chip, rendered as a real button that opens the file */
button.td-att { width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer; }
button.td-att:hover { border-color:var(--muted-2); background:color-mix(in srgb, var(--bg) 85%, transparent); }
button.td-att:disabled { opacity:.55; cursor:default; }
.td-att svg { flex:none; opacity:.75; }
.td-att-name { font-size:12px; font-weight:600; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.td-att-meta { margin-left:auto; flex:none; font-family:var(--mono); font-size:10px; color:var(--muted-2); }
/* Image attachments are content, not files waiting to be opened. The shared
attachment renderer unfolds image chips into design-system plates while
leaving every non-image attachment as the quiet row above. */
.att-zone { display:flex; flex-direction:column; gap:8px; width:100%; min-width:0; margin-top:8px; }
.att-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px; width:100%; }
.att-grid.one { grid-template-columns:minmax(0, 1fr); }
button.att-fig { appearance:none; display:flex; flex-direction:column; min-width:0; padding:0; border:0;
background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
.att-plate { position:relative; display:grid; place-items:center; width:100%; aspect-ratio:16 / 10; overflow:hidden;
border:1px solid var(--hair); border-radius:12px; background:var(--wash); }
.att-plate::before { content:""; width:5px; height:5px; background:var(--accent); opacity:.52; }
.att-plate img { display:block; width:100%; height:auto; object-fit:contain; opacity:0;
transform:scale(.98); transition:opacity var(--t-slow) var(--settle), transform var(--t-slow) var(--spring, var(--soft)); }
.att-fig.ready .att-plate { aspect-ratio:auto; background:transparent; }
.att-fig.ready .att-plate::before { display:none; }
.att-fig.ready .att-plate img { opacity:1; transform:none; }
.att-fig:hover .att-plate { border-color:var(--muted-2); }
.att-cap { display:flex; align-items:baseline; gap:6px; min-width:0; padding:5px 2px 0; }
.att-cap .td-att-name { flex:1 1 auto; }
.att-cap .td-att-meta { margin-left:0; }
/* footer — people, then the one navigation act */
.td-people { padding:20px 16px 0; font-size:12px; color:var(--muted-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.td-foot { margin-top:12px; padding:12px 16px 14px; border-top:1px solid var(--hair); }
.td-open {
appearance:none; cursor:pointer; width:100%; border:1px solid var(--hair); border-radius:var(--r-2); padding:8px 12px;
background:transparent; font-family:var(--sans); font-size:12px; font-weight:600; color:var(--accent);
transition:background-color var(--t-fast) var(--settle);
}
.td-open:hover { background:var(--accent-soft); }
/* ---- empty states ---- */
.empty {
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:11px; padding:34px 28px; text-align:center;
}
.empty .t1 { font-family:var(--serif); font-size:15px; color:var(--ink-2); }
.empty .t2 { font-size:13px; color:var(--muted-2); max-width:210px; line-height:1.45; }
.gone { display:none !important; }
/* Keyboard focus is visible on every interactive element — one ring. */
button:focus-visible, [role="button"]:focus-visible, textarea:focus-visible, input:focus-visible, [contenteditable="true"]:focus-visible {
outline:none; box-shadow:var(--focus-ring);
}
@media (prefers-reduced-motion: reduce) {
.card { animation:none; }
*, *::before, *::after { transition-duration:.001s !important; }
}
/* ---- People tab ---- */
.cv-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 16px 8px; }
.cv-add {
appearance:none; border:0; background:var(--accent-soft); color:var(--accent);
font-family:var(--sans); font-size:12px; font-weight:600; padding:5px 11px; border-radius:var(--r-2); cursor:pointer;
transition:background var(--t-base) var(--settle);
}
.cv-add:hover { background:var(--accent-press); }
.cv-list { display:flex; flex-direction:column; padding:0; }
.cv-item {
display:flex; align-items:center; gap:12px; padding:13px 16px; cursor:pointer;
text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair);
background:transparent; width:100%; font-family:var(--sans);
transition:background-color var(--t-fast) var(--settle);
}
.cv-item:hover { background:var(--wash); }
.cv-avatar {
flex:0 0 auto; width:36px; height:36px; border-radius:50%;
background:hsl(var(--cv-h,24) 22% var(--av-bg-l)); color:hsl(var(--cv-h,24) 30% var(--av-fg-l));
font-size:11.5px; font-weight:600; display:flex; align-items:center; justify-content:center; letter-spacing:.02em;
}
.cv-body { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; }
.cv-name { font-size:13px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-subrow { display:flex; align-items:baseline; gap:6px; min-width:0; margin-top:2px; }
.cv-sub { flex:0 1 auto; min-width:0; font-family:var(--serif); font-size:14.5px; line-height:1.2; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-more, .cv-via { flex:0 0 auto; font-family:var(--serif); font-size:14.5px; line-height:1.2; color:var(--muted-2); white-space:nowrap; }
.cv-meta { flex:0 0 auto; align-self:flex-start; padding-top:1px; font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; white-space:nowrap; }
.cv-edit { flex:0 0 auto; color:var(--muted-2); font-size:11px; font-weight:600; opacity:0; transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle); }
.cv-item:hover .cv-edit, .cv-item:focus-visible .cv-edit { opacity:1; color:var(--ink-2); }
.cv-empty { padding:34px 16px; text-align:center; color:var(--muted-2); font-size:13px; line-height:1.5; }
.cv-empty-retry { display:block; margin:12px auto 0; border:1px solid var(--edge); border-radius:999px; padding:7px 13px; background:transparent; color:var(--ink); font:inherit; cursor:pointer; }
.cv-empty-retry:hover { border-color:var(--muted); }
/* Composer attachments: staged chips above the field, a + ghost in the rail. */
.cmp-files { display:flex; flex-wrap:wrap; gap:5px; padding:2px 2px 6px; }
.cmp-chip { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--edge); border-radius:9px; padding:3px 6px 3px 8px;
font-family:var(--sans); font-size:11px; font-weight:600; color:var(--ink); background:var(--bg); max-width:100%; min-width:0; }
.cmp-chip .fico-mini { width:9px; height:11px; flex:none; border-radius:2px; background:var(--wash-2); border:1px solid var(--hair); }
.cmp-chip .sz { font-family:var(--mono); font-size:9.5px; font-weight:400; color:var(--muted-3); }
.cmp-chip .x { appearance:none; border:0; background:transparent; color:var(--muted-3); font-size:12px; line-height:1; padding:0 2px; cursor:pointer; }
.cmp-chip .x:hover { color:var(--ink); }
.qr .cmp-plus, .qr .ta-rail .cmp-plus { appearance:none; flex:0 0 auto; align-self:center; width:20px; height:20px; min-width:20px;
padding:0; margin:0; border-radius:6px; border:1px solid var(--hair); background:transparent; color:var(--muted-2);
cursor:pointer; display:inline-flex; align-items:center; justify-content:center;
transition:color var(--t-fast) var(--settle), border-color var(--t-fast) var(--settle), background var(--t-fast) var(--settle); }
.qr .cmp-plus:hover { color:var(--ink); border-color:var(--muted-3); background:var(--wash); }
.qr .cmp-plus svg { display:block; }
/* The "Your people" row — the Chat list's last row; opens contacts
management as a pushed pane (the Contacts tab is retired). */
/* Chat's head carries the verb only — the TAB already names the view, and a
second hairline under it split the list from nothing (David). */
.chat-head { display:flex; justify-content:flex-end; padding:6px 14px 2px; }
.people-row { display:flex; align-items:center; gap:11px; width:100%; padding:12px 16px; background:transparent; border:0;
border-top:1px solid var(--hair); margin-top:4px; cursor:pointer; text-align:left; font-family:var(--sans);
transition:background-color var(--t-fast) var(--settle); }
.people-row:hover { background:var(--wash); }
.people-row .pd { width:32px; flex:0 0 auto; display:flex; }
.people-row .pd .avatar { width:20px; height:20px; font-size:7.5px; margin-top:0; }
.people-row .pd .avatar + .avatar { margin-left:-7px; box-shadow:0 0 0 2px var(--bg); }
.people-row .lab { font-size:12.5px; font-weight:600; color:var(--ink); }
.people-row .n { font-family:var(--mono); font-size:10px; color:var(--muted-3); margin-left:auto; font-variant-numeric:tabular-nums; }
.people-row .chev { color:var(--muted-3); margin-left:4px; font-size:13px; }
/* ---- contact groups (rosters) ---- */
/* Groups sits ABOVE the people list: a long contact list used to bury it
entirely (you had to scroll past every contact to discover groups exist).
Small set first, long list second. */
.cvg { padding-bottom:6px; }
/* Segmented island: an inset track with the selected segment lifted out of it —
depth from light, not borders, matching the open-actions menu. */
.cv-seg {
display:inline-flex; gap:2px; padding:2px; border-radius:var(--r-2);
background:var(--wash-2);
}
.cv-seg-btn {
appearance:none; border:0; background:transparent; cursor:pointer;
font-family:var(--sans); font-size:11px; font-weight:500; letter-spacing:-0.01em;
color:var(--muted-2); height:24px; padding:0 10px; border-radius:var(--r-1);
display:inline-flex; align-items:center; gap:5px;
transition:background-color var(--t-fast) var(--settle), color var(--t-fast) var(--settle), box-shadow var(--t-fast) var(--settle);
}
.cv-seg-btn:hover { color:var(--ink-2); }
.cv-seg-btn.active {
background:var(--bg); color:var(--ink); font-weight:600;
box-shadow:0 1px 2px var(--wash-3), 0 0 0 0.5px var(--wash-2);
}
.cv-seg-n { font-size:10px; font-variant-numeric:tabular-nums; opacity:.55; }
.cv-seg-btn.active .cv-seg-n { opacity:.65; }
.cvg-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px 6px; }
.cvg-newform { display:flex; gap:7px; padding:8px 16px 12px; align-items:center; border-bottom:1px solid var(--hair); }
.cvg-newform.hidden { display:none; }
.cvg-newform .cv-input { flex:1; }
.cvg-error { color:var(--danger); font-size:12px; padding:0 16px; line-height:1.4; }
.cvg-error:empty { display:none; }
.cvg-item { display:flex; align-items:center; gap:12px; padding:13px 16px; cursor:pointer; text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair); background:transparent; width:100%; font-family:var(--sans); transition:background-color var(--t-fast) var(--settle); }
.cvg-item:hover { background:var(--wash); }
/* Three 28px avatars with two 10px overlaps occupy 64px. Reserve that full
footprint so the last avatar cannot spill across the gap into the name. */
.cvg-stack { width:64px; flex:0 0 64px; display:flex; align-items:center; }
.cvg-stack .cv-avatar { width:28px; height:28px; font-size:9px; box-shadow:0 0 0 2px var(--bg); }
.cvg-stack .cv-avatar + .cv-avatar { margin-left:-10px; }
.cvg-stack .cvg-slack-logo { width:28px; height:28px; object-fit:contain; }
.cvg-body { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; }
.cvg-name { font-size:13px; font-weight:600; color:var(--ink); min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cvg-sub { font-family:var(--serif); font-size:14.5px; line-height:1.2; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; }
.cvg-meta { flex:0 0 auto; align-self:flex-start; display:flex; flex-direction:column; align-items:flex-end; gap:4px; padding-top:1px; }
.cvg-count { font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; white-space:nowrap; }
/* Same affordance as a person's row: the roster is one hover away, and the
row itself belongs to the conversation. */
.cvg-edit { flex:0 0 auto; color:var(--muted-2); font-size:11px; font-weight:600; opacity:0; transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle); }
.cvg-item:hover .cvg-edit, .cvg-item:focus-visible .cvg-edit { opacity:1; color:var(--ink-2); }
/* Somebody else's roster. Readable, never editable — the badge is what stops
a member hunting for a Rename button that would only ever 404. */
.cvg-badge { font-size:10px; font-weight:600; letter-spacing:0.02em; color:var(--muted-2); background:var(--wash-2); border-radius:var(--r-1); padding:2px 6px; white-space:nowrap; }
.cvg-owner { font-size:12px; color:var(--muted-2); line-height:1.45; }
.cvg-panel { padding:10px 16px 13px 76px; border-bottom:1px solid var(--hair); display:flex; flex-direction:column; gap:9px; }
.cvg-members { display:flex; flex-wrap:wrap; gap:5px; }
.cvg-none { font-size:12px; color:var(--muted-2); }
.cvg-addwrap { position:relative; }
.cvg-sugg { display:flex; flex-direction:column; gap:3px; margin-top:4px; }
.cvg-sugg button { text-align:left; appearance:none; border:1px solid var(--hair); background:transparent; border-radius:var(--r-1); padding:5px 9px; font-size:12px; color:var(--ink); cursor:pointer; font-family:var(--sans); }
.cvg-sugg button:hover { background:var(--wash-2); }
.cvg-actions { display:flex; gap:10px; }
.cvg-act { appearance:none; border:0; background:transparent; padding:0; font-size:12px; color:var(--muted-2); cursor:pointer; font-family:var(--sans); }
.cvg-act:hover { color:var(--ink); text-decoration:underline; }
.cvg-act.danger { color:var(--danger); }
.cv-form { display:flex; flex-direction:column; gap:7px; padding:8px 16px 14px; border-bottom:1px solid var(--hair); }
.cv-form.hidden { display:none; }
.cv-row2 { display:flex; gap:7px; }
.cv-row2 .cv-input { flex:1 1 0; min-width:0; }
.cv-input {
appearance:none; border:1px solid var(--edge); border-radius:var(--r-2); padding:8px 10px;
font-family:var(--sans); font-size:13px; color:var(--ink); background:var(--bg); outline:none;
transition:border-color var(--t-fast) var(--settle), box-shadow var(--t-fast) var(--settle);
}
.cv-input::placeholder { color:var(--muted-3); }
.cv-input:focus { border-color:var(--edge-accent); box-shadow:var(--focus-ring); }
.cv-form-actions { display:flex; align-items:center; justify-content:flex-end; gap:14px; margin-top:1px; }
.cv-delete {
appearance:none; border:0; background:transparent; color:var(--danger); font-family:var(--sans);
font-size:13px; font-weight:600; cursor:pointer; transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle);
margin-right:auto; padding:0;
}
.cv-delete:hover { opacity:.72; }
.cv-delete.armed { color:var(--danger); }
.cv-delete:disabled { opacity:.4; cursor:default; }
.cv-cancel { appearance:none; border:0; background:transparent; color:var(--muted); font-family:var(--sans); font-size:13px; font-weight:500; cursor:pointer; transition:color var(--t-fast) var(--settle); }
.cv-cancel:hover { color:var(--ink-2); }
.cv-save {
appearance:none; border:0; background:var(--accent); color:var(--on-accent); font-family:var(--sans); font-size:13px;
font-weight:600; padding:7px 15px; border-radius:var(--r-2); cursor:pointer; transition:opacity var(--t-base) var(--settle);
}
.cv-save:hover { opacity:.9; }
.cv-save:disabled { opacity:.4; cursor:default; }
.cv-error { color:var(--danger); font-size:12px; line-height:1.3; padding:0; }
.cv-error:empty { display:none; }
/* field label above the emails control — quiet, matches the website's "Emails" + hint */
.cv-flabel { display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
.cv-flabel .l { font-size:12px; font-weight:600; color:var(--ink-2); }
.cv-flabel .h { font-size:10px; color:var(--muted-2); }
/* multi-email chip input (ported from the website's EmailsInput) — type an address,
press Enter / comma / space to set it as a prussian chip; × removes; Backspace on an
empty field pops the last chip. The whole control is one bordered field that grows. */
.cv-emails {
display:flex; flex-wrap:wrap; align-items:center; gap:5px;
border:1px solid var(--edge); border-radius:var(--r-2); padding:5px 6px;
background:var(--bg); cursor:text;
transition:border-color var(--t-fast) var(--settle), box-shadow var(--t-fast) var(--settle);
}
.cv-emails.focus { border-color:var(--edge-accent); box-shadow:var(--focus-ring); }
.cv-chip {
display:inline-flex; align-items:center; gap:5px; max-width:100%;
background:var(--accent-soft); color:var(--accent);
border-radius:var(--r-1); padding:3px 4px 3px 9px;
font-size:12px; font-weight:500; letter-spacing:.1px;
}
.cv-chip .e { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.cv-chip-x {
flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
width:16px; height:16px; border-radius:var(--r-1); padding:0; line-height:1;
display:inline-flex; align-items:center; justify-content:center;
color:var(--accent); opacity:.7; font-size:13px;
transition:background-color var(--t-fast) var(--settle), opacity var(--t-fast) var(--settle);
}
.cv-chip-x:hover { background:var(--accent-press); opacity:1; }
.cv-emails-input {
flex:1 1 110px; min-width:110px; appearance:none; border:0; outline:none; background:transparent;
padding:4px 4px; font-family:var(--sans); font-size:13px; color:var(--ink);
}
.cv-emails-input::placeholder { color:var(--muted-3); }
/* domain autocomplete dropdown beneath the emails field */
.cv-suggest {
margin-top:5px; overflow:hidden; border:1px solid var(--hair); border-radius:var(--r-2); background:var(--bg);
box-shadow:var(--shadow-pop);
}
.cv-suggest.hidden { display:none; }
.cv-sugg {
display:block; width:100%; text-align:left; appearance:none; border:0; background:transparent; cursor:pointer;
padding:7px 10px; font-family:var(--sans); font-size:13px; color:var(--ink-2);
transition:background-color var(--t-fast) var(--settle);
}
.cv-sugg:hover, .cv-sugg.active { background:var(--wash); color:var(--ink); }
.cv-hint { font-size:11px; line-height:1.45; color:var(--muted-2); margin-top:2px; }
.cv-hint:empty { display:none; }
/* ---- Threads tab: thread rows + one thread's message history ---- */
/* A div, not a button: it hosts the delete button, and nesting buttons is
invalid HTML (the parser hoists the inner one and breaks the row). */
.th-item {
position:relative; display:flex; gap:11px; width:100%; text-align:left; appearance:none; border:0; cursor:pointer;
padding:11px 16px 0; background:transparent;
font-family:var(--sans); transition:background-color var(--t-fast) var(--settle);
}
.th-item:hover { background:var(--wash); }
.th-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
.th-time { flex:0 0 auto; }
.rk-line { display:flex; align-items:flex-start; gap:8px; min-width:0; }
.rk-line .th-title { flex:1 1 auto; min-width:0; }
.rk-line .th-count { margin-left:0; margin-top:4px; }
.th-party { font-size:12px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
/* Unread-count chip: QUIET — hairline and muted, like the pre-restyle list.
A filled accent pill per row fought the tab badges and made every unread
row shout; the ink/weight shift on the title already says "unread". */
.th-count {
flex:0 0 auto; align-self:flex-start; margin-left:auto;
font-family:var(--sans); font-size:10px; font-weight:600;
font-variant-numeric:tabular-nums; letter-spacing:.02em; line-height:1;
color:var(--muted-2); background:transparent; border:1px solid var(--hair);
border-radius:var(--r-pill); padding:3px 7px; min-width:17px; text-align:center;
}
/* The same chip holding a different fact: how many relays the conversation
holds. Unread is the loud one because it is the one that wants something
from you; this is reference, so it recedes to an outline. */
.th-count.quiet { color:var(--muted-2); background:transparent; box-shadow:inset 0 0 0 1px var(--hair); font-weight:500; }
.th-time { flex:0 0 auto; margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; }
.th-title {
font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--muted); margin-top:4px;
overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
overflow-wrap:anywhere;
}
.th-item.unread .th-title { color:var(--ink); font-weight:500; }
.th-detail-head { display:flex; align-items:center; gap:9px; padding:10px 16px 8px; border-bottom:1px solid var(--hair); }
/* The chevron only means back; the person is the title (identity is the
header — no "Relays" label doing double duty). */
.th-back {
appearance:none; border:0; background:transparent; cursor:pointer; padding:0;
flex:0 0 auto; width:26px; height:26px; border-radius:var(--r-1);
display:inline-flex; align-items:center; justify-content:center;
color:var(--muted-2);
transition:background-color var(--t-fast) var(--settle), color var(--t-fast) var(--settle);
}
.th-back:hover { background:var(--wash-2); color:var(--ink); }
.th-bar-sticky, .reader-bar { position:sticky; top:0; background:var(--bg); z-index:3; }
.th-detail-meta { margin-left:auto; flex:0 0 auto; font-family:var(--mono); font-size:10px; color:var(--muted-3); font-variant-numeric:tabular-nums; }
.th-detail-name {
font-family:var(--sans); font-size:13px; font-weight:600; color:var(--ink);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; flex:1 1 auto;
}
.th-info {
appearance:none; border:0; background:transparent; padding:4px 7px; border-radius:var(--r-1);
color:var(--muted-2); font-family:var(--sans); font-size:11px; font-weight:600; cursor:pointer;
transition:background-color var(--t-fast) var(--settle), color var(--t-fast) var(--settle);
}
.th-info:hover { background:var(--wash-2); color:var(--ink); }
.th-slack-toggle { display:inline-flex; align-items:center; gap:5px; white-space:nowrap; }
.th-slack-toggle img { width:12px; height:12px; object-fit:contain; }
/* The only generic .hidden rule is scoped to .view — every other element
toggling "hidden" needs its own display:none or the class is a silent
no-op (See info rendered on every room, every frame, until this line). */
.th-info.hidden { display:none; }
/* One group-details language in both People and the room-level See info
sheet. The sheet is roomier, but identity, roster and actions are the same
component so the two surfaces cannot drift. */
.group-info-backdrop {
position:absolute; inset:0; z-index:30; display:flex; align-items:center; justify-content:center;
padding:24px; background:color-mix(in srgb, var(--bg) 56%, transparent); backdrop-filter:blur(6px);
}
.group-info-backdrop.hidden { display:none; }
.group-info-sheet {
position:relative; width:min(480px, calc(100% - 32px)); max-height:calc(100% - 56px); overflow:auto;
border:1px solid var(--edge); border-radius:var(--r-card); background:var(--bg); box-shadow:var(--shadow-pop);
padding:0;
}
.group-info-close {
position:absolute; z-index:2; top:17px; right:17px; appearance:none; border:0; background:transparent;
color:var(--muted-2); font-size:20px; line-height:1; cursor:pointer; padding:2px 5px;
}
.group-info-close:hover { color:var(--ink); }
.group-detail { display:flex; flex-direction:column; min-width:0; color:var(--ink); }
.group-detail.in-sheet { padding-top:10px; }
.gd-back {
appearance:none; align-self:flex-start; display:inline-flex; align-items:center; gap:5px; margin:10px 16px 2px;
padding:4px 0; border:0; background:transparent; color:var(--accent); font-family:var(--sans); font-size:11.5px; cursor:pointer;
}
.gd-back:hover { opacity:.76; }
.gd-identity { display:flex; align-items:center; gap:13px; padding:12px 16px 16px; }
.group-detail.in-sheet .gd-identity { padding:16px 22px 19px; }
.gd-title { min-width:0; flex:1; }
.gd-title h2 { margin:0; overflow:hidden; color:var(--ink); font-family:var(--sans); font-size:18px; line-height:1.22; font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
.gd-title p { margin:3px 0 0; color:var(--muted); font-family:var(--serif); font-size:14.5px; line-height:1.25; }
.gd-section-label { padding:13px 16px 7px; border-top:1px solid var(--hair); color:var(--muted-3); font-family:var(--mono); font-size:9.5px; letter-spacing:.09em; text-transform:uppercase; }
.group-detail.in-sheet .gd-section-label { padding-inline:22px; }
.gd-members { display:flex; flex-direction:column; }
.gd-member { display:grid; grid-template-columns:38px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:58px; padding:7px 16px; border-bottom:1px solid var(--hair); }
.group-detail.in-sheet .gd-member { min-height:64px; padding-inline:22px; }
.gd-member .avatar { width:36px; height:36px; margin:0; font-size:11px; }
.gd-member-copy { display:flex; flex-direction:column; min-width:0; }
.gd-member-copy .nm { overflow:hidden; color:var(--ink); font-family:var(--sans); font-size:13px; font-weight:600; text-overflow:ellipsis; white-space:nowrap; }
.gd-member-copy .em { margin-top:2px; overflow:hidden; color:var(--muted); font-family:var(--serif); font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
.gd-role { padding:3px 7px; border-radius:var(--r-1); background:var(--wash-2); color:var(--muted-2); font-family:var(--sans); font-size:10.5px; white-space:nowrap; }
.gd-remove { appearance:none; border:0; background:transparent; color:var(--danger); font-family:var(--sans); font-size:10.5px; cursor:pointer; }
.gd-owner-tools { display:flex; flex-direction:column; gap:9px; padding:14px 16px; border-bottom:1px solid var(--hair); }
.group-detail.in-sheet .gd-owner-tools { padding-inline:22px; }
.gd-add { position:relative; }
.gd-add .cv-input { width:100%; box-sizing:border-box; }
.gd-actions { display:flex; align-items:center; gap:14px; }
.gd-action { appearance:none; border:0; background:transparent; padding:0; color:var(--muted-2); font-family:var(--sans); font-size:11.5px; cursor:pointer; }
.gd-action:hover { color:var(--ink); }
.gd-action.danger { color:var(--danger); }
.gd-rename-form { display:flex; gap:7px; }
.gd-rename-form .cv-input { min-width:0; flex:1; }
.gd-membership { display:flex; align-items:center; min-height:58px; padding:0 16px; }
.group-detail.in-sheet .gd-membership { padding-inline:22px; }
.gd-membership-confirm { padding-block:14px 18px; }
.group-detail.in-sheet .gd-membership-confirm { padding-block:16px 22px; }
.gd-leave {
appearance:none; border:0; background:transparent; padding:8px 0; color:var(--danger);
font-family:var(--sans); font-size:12.5px; font-weight:500; text-align:left; cursor:pointer;
}
.gd-leave:hover { color:color-mix(in srgb, var(--danger) 84%, var(--ink)); }
.gd-confirm { width:100%; padding:12px 13px; border:1px solid color-mix(in srgb, var(--danger) 26%, var(--edge)); border-radius:var(--r-3); background:var(--wash); }
.gd-confirm strong { display:block; color:var(--ink); font-family:var(--sans); font-size:12.5px; font-weight:600; }
.gd-confirm p { margin:5px 0 0; color:var(--muted); font-family:var(--serif); font-size:12.5px; line-height:1.4; }
.gd-confirm-actions { display:flex; justify-content:flex-end; gap:12px; margin-top:11px; }
.gd-confirm-actions button { appearance:none; border:0; background:transparent; padding:3px 0; font-family:var(--sans); font-size:11.5px; font-weight:600; cursor:pointer; }
.gd-confirm-actions .cancel { color:var(--muted-2); }
.gd-confirm-actions .confirm { color:var(--danger); }
.gd-status { color:var(--muted); font-family:var(--serif); font-size:12.5px; line-height:1.45; }
.gd-error { min-height:16px; padding:0 16px 12px; color:var(--danger); font-family:var(--sans); font-size:11px; line-height:1.4; }
.group-detail.in-sheet .gd-error { padding-inline:22px; }
.contacts-group-detail .cv-head, .contacts-group-detail #cvgNewForm,
.contacts-group-detail #cvgError, .contacts-group-detail #cvgList,
.contacts-group-detail #cvgEmpty { display:none !important; }
.th-room-status { margin:10px 16px 12px; padding:10px 12px; border-top:1px solid var(--hair); color:var(--muted); font-family:var(--serif); font-size:12.5px; line-height:1.4; text-align:center; }
/* Conversation messages read like a group chat: every message is named on
its own line, and every party's name keeps a stable color so a busy
thread is scannable by who-said-what (the Slack convention). */
.th-msg { position:relative; display:flex; flex-wrap:wrap; align-items:flex-end; gap:2px 10px; width:auto; max-width:88%;
text-align:left; appearance:none; border:0; cursor:pointer; background:var(--wash);
margin:7px 16px 0; padding:9px 13px 8px; border-radius:14px; font-family:var(--sans); }
.th-msg:hover { background:var(--wash-2); }
.th-msg.mine { background:color-mix(in srgb, var(--accent) 8%, transparent); }
.th-msg.live-arrival { animation:thMessageArrival .28s var(--soft) both; }
@keyframes thMessageArrival {
from { opacity:.25; transform:translateY(7px) scale(.985); }
to { opacity:1; transform:translateY(0) scale(1); }
}
.th-msg .open-actions { margin-top:9px; text-align:left; }
.th-run { display:flex; align-items:baseline; gap:7px; padding:14px 16px 3px; }
/* WhatsApp sides (David's law — overturns C4): your messages approach from
the right, everyone else's from the left. Ownership readable at a glance. */
.th-run.mine { justify-content:flex-end; }
.th-msg { width:fit-content; min-width:132px; } /* WhatsApp geometry: bubbles hug their words (min protects the clock anchor) */
.th-msg.mine { margin-left:auto; }
/* A channel Task keeps the deployed message bubble unchanged. Ownership is
a separate full-width sibling beneath it, sharing only a sizing wrapper so
the button can never drift onto or inside the message frame. */
.th-task-stack { width:fit-content; min-width:132px; max-width:88%; margin:7px 16px 0; }
.th-task-stack.mine { margin-left:auto; }
.th-task-stack > .th-msg { width:100%; max-width:none; min-width:0; margin:0 !important; }
.task-claim-control { width:100%; margin-top:8px; font-family:var(--sans); }
.task-claim-button { appearance:none; width:100%; min-height:44px; padding:9px 14px; border-radius:10px;
font:650 12px/1.2 var(--sans); cursor:pointer; transition:background var(--t-fast) var(--settle), color var(--t-fast) var(--settle), border-color var(--t-fast) var(--settle), opacity var(--t-fast) var(--settle); }
.task-claim-button.claim { border:1px solid var(--accent); background:var(--accent); color:white; }
.task-claim-button.claim:hover { background:color-mix(in srgb,var(--accent) 88%,black); }
.task-claim-button.unclaim { border:1px solid color-mix(in srgb,var(--accent) 58%,var(--edge)); background:transparent; color:var(--accent); }
.task-claim-button.unclaim:hover { background:color-mix(in srgb,var(--accent) 8%,transparent); border-color:var(--accent); }
.task-claim-button:disabled { opacity:.55; cursor:default; }
.task-claim-status { width:100%; padding:7px 2px 2px; color:var(--muted); font:600 11px/1.35 var(--sans); }
.task-claim-status.mine { color:var(--accent); }
.task-claim-release { padding:0 2px 6px; color:var(--faint); font:500 10.5px/1.35 var(--sans); }
.task-claim-error { padding:6px 2px 0; color:var(--danger); font:500 10.5px/1.35 var(--sans); }
/* Reactions B2 — live badges cling to the original bubble; the separately
rendered serif whisper is the chronological conversation record. */
.th-msg.has-rx { margin-bottom:12px; padding-bottom:19px; }
.rx-badges { position:absolute; bottom:-12px; left:10px; display:flex; gap:4px; z-index:5; }
.th-msg.mine .rx-badges { left:auto; right:10px; }
.rx-badge { appearance:none; display:flex; align-items:center; gap:3px; background:var(--bg); color:var(--ink); border:1px solid var(--edge);
border-radius:999px; padding:2px 7px; font-size:12px; line-height:1.3; box-shadow:0 2px 6px -2px rgba(42,37,33,.28); cursor:pointer;
transform-origin:center bottom; animation:rxBadgePop .6s var(--spring) both; }
.rx-badge.mine { border-color:color-mix(in srgb,var(--brand-anthropic) 55%,var(--edge)); }
.rx-badge .n { font-family:var(--mono); font-size:9px; color:var(--muted-2); }
@keyframes rxBadgePop { from { transform:scale(.2); } 60% { transform:scale(1.18); } }
/* The picker belongs to ONE message, never to a reader, Task or run.
It appears just outside the hovered bubble and unfurls back across that
bubble, so its target remains visually unambiguous at every width. */
.th-msg .rx-msg-picker { position:absolute; z-index:8; top:50%; right:-32px; width:28px; height:30px; margin:0; transform:translateY(-50%); }
.th-msg.mine .rx-msg-picker { right:auto; left:-32px; }
.th-msg .rx-msg-picker .rx-face { opacity:0; }
.th-msg:hover .rx-msg-picker .rx-face, .rx-msg-picker:focus-within .rx-face { opacity:.72; }
.th-msg .rx-msg-picker .rx-unfurl { left:auto; right:0; top:50%; transform:translateY(-50%) scaleX(.04); transform-origin:right center; }
.th-msg.mine .rx-msg-picker .rx-unfurl { left:0; right:auto; transform-origin:left center; }
.th-msg .rx-msg-picker.open .rx-unfurl { transform:translateY(-50%) scaleX(1); }
.th-msg .rx-msg-picker.open .rx-face { opacity:0; }
.th-msg .rx-confirm { flex-basis:100%; margin:7px 0 0; text-align:left; }
.th-msg.mine .rx-confirm { text-align:right; }
.rx-line { align-self:center; text-align:center; font-family:var(--serif); font-style:italic; font-size:13px; color:var(--muted-2);
margin:11px auto 0; animation:rxWhisperIn .45s var(--settle) both; }
.rx-line .th-party { font-weight:500; }
.rx-line .emoji { font-style:normal; font-size:13.5px; display:inline-block; vertical-align:-1px; animation:rxLand .55s var(--spring); }
.th-run .th-party { font-size:12px; font-weight:600; }
.th-msg .th-msg-title { flex:1 1 auto; min-width:0; }
/* The clock sits TOP-RIGHT of every bubble, always — absolutely anchored,
out of the flex flow entirely. In-flow it landed wherever the wrapped
title pushed it: "not at the top, the bottom or in the middle" (Sven).
The title reserves its lane so the first line never runs under it. */
.th-blk-time { position:absolute; top:10px; right:13px; font-family:var(--mono); font-size:9.5px; color:var(--muted-3); }
.th-msg .th-msg-title { padding-right:34px; }
.th-msg .row-err { flex-basis:100%; }
.th-provider-byline { flex:1 0 calc(100% + 26px); width:calc(100% + 26px); margin:7px -13px -8px;
padding:6px 12px 7px; border-top:1px solid var(--hair); display:flex; align-items:center; gap:6px;
font-family:var(--sans); font-size:10px; line-height:1.2; color:var(--muted-2); }
.th-provider-byline img { width:12px; height:12px; object-fit:contain; opacity:.82; }
.th-slack-thread-context { width:calc(100% - 32px); margin:12px 16px 4px; padding:12px 13px;
border:1px solid var(--hair); border-radius:var(--r-3); background:color-mix(in srgb,var(--raised) 82%,transparent);
font-family:var(--sans); color:var(--muted); }
.th-slack-thread-context .top { display:flex; align-items:center; gap:8px; color:var(--ink); font-size:12px; font-weight:600; }
.th-slack-thread-context img { width:14px; height:14px; object-fit:contain; }
.th-slack-thread-context .copy { margin-top:4px; font-size:11px; line-height:1.45; }
.th-slack-thread-context button { appearance:none; margin-top:7px; padding:0; border:0; background:transparent;
color:var(--accent); font:600 11px/1.4 var(--sans); cursor:pointer; }
.th-thread-link { appearance:none; display:flex; align-items:center; gap:7px; width:fit-content; margin:5px 16px 2px;
padding:3px 0; border:0; background:transparent; color:var(--accent); font:600 11px/1.35 var(--sans); cursor:pointer; }
.th-thread-link:hover { opacity:.76; }
.th-thread-link img { width:13px; height:13px; object-fit:contain; }
.th-thread-focus { display:flex; align-items:center; gap:8px; margin:10px 16px 2px; padding:8px 10px;
border-bottom:1px solid var(--hair); color:var(--muted); font:11px/1.4 var(--sans); }
.th-thread-focus img { width:13px; height:13px; object-fit:contain; }
.th-thread-focus strong { color:var(--ink); font-weight:600; }
.th-edited { margin-left:5px; font-family:var(--sans); font-size:9px; color:var(--muted-3); }
.th-msg.deleted .th-msg-title { color:var(--muted-2); font-style:italic; }
/* Inbox order ends the pane with a message and that last bubble wants air.
Chat order ends with the composer dock, which brings its own padding.
Naming the order is what :last-of-type used to say by accident, back
when the dock was the final sibling of the rows. */
#thRows.inbox-order .th-msg:last-of-type { margin-bottom:10px; }
.th-msg.text { cursor:default; user-select:text; }
.th-msg :is(.th-msg-title, .th-body) a { color:var(--accent); text-decoration:none; border-bottom:1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
/* Mentions keep their stable @handle in the message payload, but read as the
person's contact-book name. The outlined wash makes the reference an
unmistakable inline object without turning it into a button. */
.th-msg.text .th-mention {
display:inline-block; max-width:100%; margin:0 1px; padding:0 4px;
border:1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius:4px; background:color-mix(in srgb, var(--accent) 14%, transparent);
color:var(--accent); font-weight:600; line-height:1.35; white-space:nowrap;
vertical-align:baseline;
}
.agent-working-indicator { display:inline-flex; align-items:center; gap:3px; margin-left:7px; vertical-align:2px; }
.agent-working-dot { width:5px; height:5px; border-radius:50%; background:currentColor; opacity:.35;
animation:agentWorkingPulse 1.2s var(--settle) infinite; animation-play-state:var(--relay-loop-state); }
.agent-working-dot:nth-child(2) { animation-delay:.16s; }
.agent-working-dot:nth-child(3) { animation-delay:.32s; }
@keyframes agentWorkingPulse {
0%, 60%, 100% { opacity:.35; transform:translateY(0); }
30% { opacity:1; transform:translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
.agent-working-dot { animation:none; opacity:.55; }
}
.th-reply-ghost { position:absolute; right:11px; bottom:5px; appearance:none; border:0; background:transparent; padding:2px 4px;
font-family:var(--mono); font-size:9.5px; letter-spacing:.04em; color:var(--muted-3); cursor:pointer; opacity:0;
transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle); }
.th-msg:hover .th-reply-ghost { opacity:1; }
.th-reply-ghost:hover { color:var(--ink); }
.th-more { display:inline-block; margin-left:6px; color:var(--muted-3); font-family:var(--sans); font-size:13px; }
.th-preview { flex-basis:100%; font-family:var(--sans); font-size:11.5px; line-height:1.45; color:var(--muted-2); margin-top:2px;
overflow:hidden; display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; }
/* Every Relay can go to either installed agent. The newest Relay in a room
keeps this native footer open. Older Relays are governed by hover-intent
classes: raw :hover is deliberately not allowed to change geometry while
messages move underneath a stationary cursor during scroll. */
.th-msg.has-host-actions { min-width:min(300px, calc(100% - 32px)); }
.th-host-actions { flex:1 0 calc(100% + 26px); width:calc(100% + 26px); margin:8px -13px -8px;
display:grid; grid-template-rows:0fr; opacity:0; transform:translateY(-7px); pointer-events:none;
transition:grid-template-rows .18s var(--soft), opacity .16s var(--settle), transform .18s var(--soft); }
.th-host-actions-inner { min-height:0; overflow:hidden; border-top:1px solid var(--hair); border-radius:0 0 14px 14px; background:var(--wash); }
.th-msg.host-intent-open .th-host-actions,
.th-msg.host-focus-open .th-host-actions,
.th-msg.host-touch-open .th-host-actions,
.th-host-actions.persistent {
grid-template-rows:1fr; opacity:1; transform:none; pointer-events:auto;
transition-duration:.26s, .18s, .26s; }
.th-host-actions.persistent { transition:none; }
.th-host-disclosure { display:none; flex:1 0 100%; width:100%; min-height:32px; margin:5px 0 -3px; padding:5px 2px 2px;
appearance:none; border:0; border-top:1px solid var(--hair); background:transparent; color:var(--muted-2);
align-items:center; justify-content:space-between; gap:8px; font-family:var(--sans); font-size:10.5px; cursor:pointer; text-align:left; }
.th-host-disclosure .chev { font-size:14px; transition:transform .18s var(--settle); }
.th-msg.host-touch-open .th-host-disclosure .chev { transform:rotate(180deg); }
.th-host-action { --host-color:var(--brand-codex); appearance:none; width:100%; min-height:52px; padding:8px 14px;
border:0; background:linear-gradient(90deg, color-mix(in srgb, var(--host-color) 9%, transparent), transparent 58%);
display:grid; grid-template-columns:28px minmax(0,1fr) auto; align-items:center; gap:10px; cursor:pointer; text-align:left;
transition:background-color var(--t-fast) var(--settle), filter var(--t-fast) var(--settle); }
.th-host-action + .th-host-action { border-top:1px solid var(--hair); }
.th-host-action[data-host="claude"] { --host-color:var(--brand-anthropic); }
.th-host-action:hover { background-color:var(--wash-2); filter:brightness(1.04); }
.th-host-action:focus-visible { outline:2px solid var(--host-color); outline-offset:-3px; }
.th-host-logo { display:block; width:28px; height:28px; border-radius:7px; object-fit:cover;
box-shadow:0 0 0 1px color-mix(in srgb, var(--ink) 7%, transparent); }
.th-host-copy { min-width:0; display:flex; flex-direction:column; gap:1px; }
.th-host-label { overflow:hidden; color:var(--host-color); font-family:var(--sans); font-size:12px; font-weight:650;
text-overflow:ellipsis; white-space:nowrap; }
.th-host-context { overflow:hidden; color:var(--muted-2); font-family:var(--sans); font-size:10px;
text-overflow:ellipsis; white-space:nowrap; }
.th-host-arrow { color:var(--host-color); font-family:var(--sans); font-size:17px; line-height:1; }
.th-msg.opening .th-host-logo { animation:hostOpenBreathe .8s ease-in-out infinite alternate; animation-play-state:var(--relay-loop-state); }
@keyframes hostOpenBreathe { to { opacity:.42; transform:scale(.94); } }
@media (hover:none), (pointer:coarse) {
.th-host-disclosure { display:flex; }
}
@media (prefers-reduced-motion:reduce) {
.th-host-actions, .th-msg.host-intent-open .th-host-actions,
.th-msg.host-focus-open .th-host-actions, .th-msg.host-touch-open .th-host-actions {
transition:none !important; }
.th-msg.opening .th-host-logo { animation:none; }
.th-host-disclosure .chev { transition:none; }
}
/* The B-rule, said where hand-offs actually happen (the reader is off the
beaten path now — Sven: "havent implemented anything for that which i can
see"): under the unfolded strip, one muted line resolves the destination
BEFORE the tap — starts fresh on this conversation, or continues the
agent chat that already knows it. */
.th-body { flex-basis:100%; font-family:var(--sans); font-size:12.5px; line-height:1.6; color:var(--ink); margin-top:6px; overflow-wrap:anywhere; }
/* THE READER PAGE (David's round): tap a letter and the pill expands INTO
the old preview window's frame (720×760) — one surface, one motion, no
second window. On the page: centered kicker, centered headline, a short
rule, the body set LEFT inside a centered measure (display centers;
running text never does), the three relay squares as the colophon.
Nothing ornamental — the rule and the colophon are the only jewelry. */
/* A Relay names its two messages before either begins. This contents list
replaces the folder metaphor: the person can see at a glance that one
message is for them and the other is for whichever agent they use. */
.relay-contents { width:calc(100% - 88px); max-width:472px; margin:12px auto 0; position:relative; z-index:2; }
.relay-contents-label { display:block; margin:0 0 6px; font-family:var(--mono); font-size:9px; line-height:1.2;
letter-spacing:.12em; text-transform:uppercase; color:var(--muted-2); }
.relay-contents-list { display:block; overflow:hidden; border:1px solid var(--hair); border-radius:12px; background:var(--wash); }
.relay-contents-row { appearance:none; width:100%; min-height:34px; display:grid; grid-template-columns:4px minmax(0,1fr) auto;
align-items:center; gap:10px; padding:8px 12px; border:0; background:transparent; cursor:pointer; text-align:left;
transition:background var(--t-base) var(--settle); }
.relay-contents-row + .relay-contents-row { border-top:1px solid var(--hair); }
.relay-contents-row:hover { background:var(--wash-2); }
.relay-contents-row.on { background:var(--bg); cursor:default; }
.relay-contents-row.on:hover { background:var(--bg); }
@keyframes workTabIn { from { opacity:0; transform:translateX(-8px) scale(.96); } to { opacity:1; transform:none; } }
.relay-contents-row.work-new { animation:workTabIn .32s var(--settle) both; }
@keyframes workSourceOut { from { opacity:1; transform:translateY(0) scale(1); } to { opacity:0; transform:translateY(-7px) scale(.995); } }
@keyframes workDestinationIn { 0%,12% { opacity:0; transform:translateY(10px) scale(.995); } 100% { opacity:1; transform:none; } }
.work-surface.work-entering .work-transition-source { animation:workSourceOut .42s cubic-bezier(.22,1,.36,1) both; }
.work-surface.work-entering .work-scroll,
.work-surface.work-entering .work-footer { animation:workDestinationIn .42s cubic-bezier(.22,1,.36,1) both; }
.work-transition-source { position:absolute; inset:0 0 0; z-index:6; overflow:hidden; padding:14px 16px 120px; background:var(--bg); pointer-events:none; }
.work-transition-source .rd-col { width:100%; height:100%; overflow:hidden; }
@media (prefers-reduced-motion:reduce) { .relay-contents-row.work-new { animation:none; } }
.relay-contents-row .sq { width:4px; height:4px; flex:none; }
.relay-contents-row .sq.you { background:var(--brand-anthropic); }
.relay-contents-row .sq.ag { background:var(--agent-sq); }
.relay-contents-name { min-width:0; overflow:hidden; font-family:var(--sans); font-size:11.5px; line-height:1.25;
font-weight:600; color:var(--ink); text-overflow:ellipsis; white-space:nowrap; }
.relay-contents-state { font-family:var(--sans); font-size:10px; line-height:1.25; color:var(--muted-2); white-space:nowrap; }
.rd-kicker .ksq { display:inline-block; width:4px; height:4px; background:var(--agent-sq); margin-right:8px; vertical-align:1px; }
/* Which desktop apps each Relay offers. This is deliberately a pair of real
switches: people move between Claude Code and Codex independently, so a
radio-style Claude/Codex/Both picker made the common state harder to read. */
.sv-open-section { margin:24px 12px 0; }
.sv-open-title, .sv-quiet-title { padding:0 4px; font-family:var(--sans); font-size:15px; line-height:1.35; font-weight:600; color:var(--ink); }
.sv-open-intro { padding:0 4px; margin:4px 0 10px; font-family:var(--sans); font-size:12px; line-height:1.45; color:var(--muted-2); }
.sv-open-list { overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.sv-open-row { display:flex; align-items:center; gap:11px; min-height:54px; padding:10px 12px; }
.sv-open-row + .sv-open-row { border-top:1px solid var(--edge); }
.sv-open-logo { width:28px; height:28px; flex:0 0 28px; border-radius:7px; object-fit:contain; }
.sv-open-copy { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; }
.sv-open-name { font-family:var(--sans); font-size:14px; line-height:1.3; font-weight:600; color:var(--ink); }
.sv-open-why { margin-top:2px; font-family:var(--sans); font-size:10.5px; line-height:1.4; color:var(--muted-3); }
.sv-open-row.off { opacity:.48; }
.ap-list { margin-top:6px; }
.ap-row { display:flex; align-items:center; gap:10px; padding:9px 11px; }
.ap-sq { width:4px; height:4px; flex:none; background:transparent; transition:background var(--t-base) var(--settle); }
.ap-row.on .ap-sq { background:var(--agent-sq); }
.ap-name { flex:1 1 auto; font-size:13px; color:var(--muted); }
.ap-row.on .ap-name { color:var(--ink); font-weight:600; }
.ap-check { flex:none; color:var(--accent); opacity:0; transition:opacity var(--t-base) var(--settle); }
.ap-row.on .ap-check { opacity:1; }
.rd-agentcopy { max-width:520px; margin:0 auto; padding-bottom:22px; }
.rd-agentcopy p { font-family:var(--sans); font-size:13px; line-height:1.7; color:var(--ink); margin:14px 0 0; overflow-wrap:anywhere; }
@keyframes rdPaneIn { from { opacity:0; transform:translateY(5px); } }
.rd-col.swap { animation:rdPaneIn .35s var(--settle); }
.rd-col { max-width:560px; margin:0 auto; padding:10px 44px 0; }
.rd-col.run { max-width:none; width:100%; padding-left:0; padding-right:0; }
.rd-col.run .rd-kicker,
.rd-col.run .rd-headline,
.rd-col.run .rd-brief { width:calc(100% - 40px); max-width:48rem; margin-left:auto; margin-right:auto; text-align:left; }
.rd-col.run .rd-headline { margin-top:8px; font-family:var(--sans); font-size:16px; line-height:24px; font-weight:600; letter-spacing:0; }
.rd-col.run .rd-rule { display:none; }
.rd-col.run .rd-brief { margin-top:2px; }
.rd-kicker { text-align:center; font-family:var(--mono); font-size:10px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted-2); margin-top:4px; }
.rd-headline { text-align:center; font-family:var(--serif); font-size:26px; line-height:1.25; font-weight:500; letter-spacing:-.012em; color:var(--ink); margin-top:12px; }
.rd-headline.long { max-width:22em; margin-left:auto; margin-right:auto; font-size:21px; line-height:1.32; }
.rd-headline.very-long { font-size:18px; line-height:1.38; }
.rd-rule { width:44px; height:1px; background:var(--edge); margin:18px auto 8px; }
/* Letters keep their editorial measure. The live runner does not: Codex's
installed Electron surface uses --thread-content-max-width:48rem with
20px panel gutters, so the conversation uses the available screen instead
of being squeezed into the old 34em reader column. */
.rd-body, .sch-spec, .sch-choice, .rd-brief-body { max-width:34em; margin-left:auto; margin-right:auto; }
.request-terminal-status { width:100%; max-width:34em; margin:0 auto 4px; padding:0 16px;
font-family:var(--sans); font-size:12px; line-height:1.5; color:var(--muted); text-align:center; }
.request-terminal-status.inline { padding-left:0; padding-right:0; }
/* LEFT-SET FROM THE FIRST LINE. The column is centred in the frame, but its
text is not: with `width:fit-content` behaviour a short first line sat
centred and then snapped left as the run grew (David). A fixed width and an
explicit text-align mean the measure never moves under the reader. */
.rd-stream { width:calc(100% - 40px); max-width:48rem; margin-left:auto; margin-right:auto; text-align:left; }
.rd-stream .rd-act, .rd-stream .rd-said, .rd-stream .rd-body { text-align:left; }
.rd-body p { font-family:var(--serif); font-size:15.5px; line-height:1.75; color:var(--ink); margin:14px 0 0; overflow-wrap:anywhere; }
.rd-end { display:block; margin:22px auto 0; }
.rd-end rect:nth-child(1) { fill:var(--brand-anthropic); }
.rd-end rect:nth-child(2) { fill:var(--ink); }
.rd-end rect:nth-child(3) { fill:url(#relayCodexGradient); }
/* Reactions — the compact picker unfurls on an eligible conversation bubble. */
.rx-colo { position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:30px; margin:22px auto 0; }
.rx-colo .rd-end { margin:0; transition:opacity .3s var(--settle); }
.rx-face { appearance:none; background:none; border:0; cursor:pointer; padding:4px; border-radius:8px; color:var(--muted-3);
display:grid; place-items:center; opacity:.5; transition:opacity .25s var(--settle),color .2s,background .2s; }
.rx-face:hover { color:var(--muted); background:var(--wash); }
.rx-bar { display:flex; align-items:center; gap:1px; padding:6px 9px; background:linear-gradient(180deg,#2B2622,#1E1A16);
border-radius:999px; box-shadow:0 14px 34px -12px rgba(34,30,27,.55),0 2px 6px rgba(34,30,27,.3),inset 0 1px 0 rgba(255,255,255,.08); }
.rx-unfurl { position:absolute; left:50%; top:50%; z-index:20; transform:translate(-50%,-50%) scaleX(.04); opacity:0; pointer-events:none;
transition:transform .55s var(--spring),opacity .25s var(--settle),border-radius .4s var(--settle); }
.rx-colo.open .rx-unfurl { transform:translate(-50%,-50%); opacity:1; pointer-events:auto; }
.rx-colo.open .rd-end,.rx-colo.open .rx-face { opacity:0; pointer-events:none; }
.rx-em { appearance:none; background:none; border:0; cursor:pointer; font-size:19px; line-height:1; padding:4px 6px; border-radius:999px;
transition:transform .45s var(--spring),opacity .25s var(--settle); }
.rx-em:hover { transform:translateY(-4px) scale(1.32); }
.rx-em:active { transform:translateY(-2px) scale(1.1); }
.rx-em.more { display:none; }
.rx-more { appearance:none; background:none; cursor:pointer; width:25px; height:25px; margin-left:5px; flex:none; border:1px solid rgba(237,231,224,.28);
border-radius:50%; color:#B0A597; font-family:var(--sans); font-size:14px; line-height:1; display:grid; place-items:center; padding:0;
transition:color .2s,border-color .2s,transform .45s var(--spring); }
.rx-more:hover { color:#EDE7E0; border-color:rgba(237,231,224,.5); transform:scale(1.1); }
.rx-bar.grid { flex-wrap:wrap; width:240px; justify-content:center; border-radius:18px; padding:8px 10px; }
.rx-bar.grid .rx-em.more { display:inline-block; }
.rx-bar.grid .rx-more { display:none; }
.rx-colo.open .rx-em,.rx-colo.open .rx-more { animation:rxCitizen .5s var(--spring) both; }
@keyframes rxCitizen { from { opacity:0; transform:scale(.4) translateY(6px); } }
.rx-confirm { text-align:center; font-family:var(--mono); font-size:10px; color:var(--muted-3); margin-top:8px; animation:rxWhisperIn .45s var(--settle) both; }
.rx-confirm .emoji { font-size:12.5px; vertical-align:-1px; display:inline-block; animation:rxLand .55s var(--spring); }
.rx-undo { appearance:none; border:0; background:none; padding:0; color:inherit; cursor:pointer; text-decoration:underline; text-underline-offset:2px; font:inherit; }
@keyframes rxLand { from { transform:scale(1.7); } }
@keyframes rxWhisperIn { from { opacity:0; transform:translateY(3px); } }
.rd-dest { text-align:center; font-family:var(--sans); font-size:11px; color:var(--muted-3); margin-top:10px; }
/* The composer sits at the BOTTOM of the screen even when the page is
short (Code/Codex grammar): the reader is a column filling the
scrollport, and the foot takes the leftover room. */
/* :not(.hidden) is load-bearing: a bare #readerView{display:flex} out-ranks
.view.hidden{display:none} (ID beats classes) and pins the reader visible
under every other view — the stacked-views bug David screenshotted. */
#readerView:not(.hidden) { min-height:100%; display:flex; flex-direction:column; }
#readerBody { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
#readerBody .rd-foot { margin-top:auto; }
/* The composer is sized BY ITS CONTENT: it holds the 560 measure normally,
and widens to fit a long rail ("Open in Claude Cowork" + Start) up to the
page width — never truncating a chip, never orphaning a verb on line two. */
/* The composer centres on the SAME axis as the letter. It is sized by its
content, so auto margins alone left it hanging off-centre whenever an
ancestor carried uneven padding (David). Its dock is now the centring
container, which holds true at any width the rail grows to. */
.rd-col .ta-dock.inline { display:flex; justify-content:center; padding-left:0; padding-right:0; }
.rd-col .qr { margin:18px auto 0; width:fit-content; min-width:min(100%, 560px); max-width:100%; }
.rd-foot .rd-col.run .qr { width:calc(100% - 40px); min-width:0; max-width:48rem; }
.rd-col .reader-from { text-align:center; }
.rd-col .ta-dock.inline .ta-meta { justify-content:center; gap:14px; }
.rd-col .row-err { text-align:center; }
.rd-col > .task-claim-control { width:min(100%,560px); margin:12px auto 4px; }
/* The agent document's footer is the same open row the room paints under a
bubble — here it stands on its own, at the letter's measure. */
.rd-host-actions { margin:18px auto 0; width:min(100%, 560px); }
.rd-host-actions .th-host-actions { flex:none; width:100%; margin:0; }
.rd-host-actions .th-host-actions-inner { border:1px solid var(--hair); border-radius:14px; }
/* The action cluster — colophon, agent chip, Hand/dock, composer — PINS to
the reader's bottom edge on a frosted pane (David's rule): a long letter
must never push the actions below the fold. The pane backdrop-blurs the
text sliding beneath it, with a feathered top edge, so scrolling reveals
the rest of the letter out of the blur. On a short letter it simply rests
at the end of the page — sticky in flow, invisible chrome. */
.rd-foot { position:sticky; bottom:-8px; z-index:3; padding:30px 0 16px; /* -8px eats the scroller's bottom pad so no text slips under the pane */
background:color-mix(in srgb, var(--bg) 55%, transparent);
backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px);
-webkit-mask-image:linear-gradient(to top, #000 calc(100% - 30px), transparent);
mask-image:linear-gradient(to top, #000 calc(100% - 30px), transparent); }
/* The foot's column is not the letter's 560 measure: the composer must be
able to grow past it for a long rail. Its children stay centred. */
.rd-foot .rd-col { padding-top:0; padding-bottom:0; max-width:none; }
.rd-foot .rd-end { margin-top:0; }
/* Shared shelf: attachments belong to the Relay, not to either document.
It sits inside the deployed frosted footer, above the existing actions, so
a long For-agent document scrolls out from under the same 14px blur while
its files remain reachable on both folder faces. */
.rd-shelf { width:min(100%, 560px); margin:0 auto 18px; }
.rd-shelf-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 2px 8px;
color:var(--muted-3); font:10px/1.2 var(--mono); letter-spacing:.16em; text-transform:uppercase; }
.rd-shelf-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:9px; }
.rd-shelf-card { appearance:none; width:100%; min-width:0; padding:0; overflow:hidden; text-align:left; cursor:pointer;
color:var(--ink); background:color-mix(in srgb,var(--bg) 76%,transparent); border:1px solid var(--hair);
border-radius:13px; box-shadow:0 1px 0 color-mix(in srgb,var(--bg) 82%,transparent);
transition:transform .35s var(--spring),border-color .2s,background .2s; }
.rd-shelf-card:hover { transform:translateY(-1px); border-color:var(--muted-3); background:color-mix(in srgb,var(--bg) 88%,transparent); }
.rd-shelf-preview { position:relative; display:grid; place-items:center; height:64px; overflow:hidden; border-bottom:1px solid var(--hair);
color:var(--muted-3); background:color-mix(in srgb,var(--wash) 76%,transparent); }
.rd-shelf-preview img { width:100%; height:100%; object-fit:cover; }
.rd-shelf-preview svg { width:21px; height:21px; }
.rd-shelf-meta { display:flex; align-items:center; gap:8px; min-width:0; padding:9px 10px 10px; }
.rd-shelf-name { min-width:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:11.5px/1.25 var(--sans); }
.rd-shelf-size { flex:none; color:var(--muted-3); font:9.5px/1 var(--mono); }
@media (max-width:520px) { .rd-shelf-grid { grid-template-columns:1fr; } }
/* A page that FITS must not scroll: the pane relaxes into plain end-of-page
chrome — no frost, no fade, no stolen height. Assessed after each render
and re-assessed when the agent lane opens (which may tip it into overlay). */
#readerBody.rd-fit .rd-foot { position:static; bottom:auto; padding:0 0 12px; background:transparent;
backdrop-filter:none; -webkit-backdrop-filter:none;
-webkit-mask-image:none; mask-image:none; }
#readerBody.rd-fit .rd-foot .rd-end { margin-top:20px; }
/* The reader keeps its door while you read: bar stays put. */
.reader-bar { position:sticky; top:0; z-index:4; background:var(--bg); }
/* The agent lane: one quiet chip; revealed, it whispers — a margin note for
the recipient's agent, never part of the letter. */
.th-body p { margin:0 0 8px; }
.th-msg.open { background:var(--wash-2); }
.th-msg-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
/* One serif size everywhere a title appears — list rows, board rows, and
bubbles all say 15px (the 14.5 drift here was Sven's "inconsistency"). */
.th-msg-title { font-family:var(--serif); font-size:15px; line-height:1.35; color:var(--muted); display:inline; overflow-wrap:anywhere; }
.th-msg-title .kchip { display:inline-block; margin-right:8px; vertical-align:2px; }
.th-msg.unread .th-msg-title { color:var(--ink); font-weight:500; }
.th-msg.mine .th-msg-title { color:var(--ink-2, var(--ink)); }
/* The bubble moves as one rectangle; its contents do not mirror. Outbound
text keeps the same left-to-right layout and measure as inbound text while
the existing .mine geometry anchors the rectangle to the right. */
/* A run of consecutive messages from one sender reads as one block: the
newest row carries the name and clock, continuations sit under it with
no repeated header and no divider inside the run.
Twenty pixels is the room's invariant bubble-to-bubble gap. Ordinary
messages put a reply/receipt accessory row between bubbles; Tasks do not.
Compensate for that row instead of letting message species or direction
change the rhythm. Every bubble keeps its complete rounded silhouette. */
.th-msg.cont { margin-top:20px; }
.th-under + .th-msg.cont { margin-top:2px; }
/* Per-party name colors. Assigned by a stable hash of the party name, so a
person keeps the same color across every conversation and every restart. */
.th-party.p0 { color:#4a6b5f; } .th-party.p1 { color:#7a5540; } .th-party.p2 { color:#53607d; }
.th-party.p3 { color:#75485f; } .th-party.p4 { color:#456a76; } .th-party.p5 { color:#6b6142; }
.th-party.p6 { color:#7a4f4a; } .th-party.p7 { color:#4f6748; }
.th-party.me { color:var(--muted); }
/* Same eight hue families, lifted for the dark ground — the light values
sink into #221E1B. */
:root[data-theme="dark"] .th-party.p0 { color:#93bcab; } :root[data-theme="dark"] .th-party.p1 { color:#c9a184; }
:root[data-theme="dark"] .th-party.p2 { color:#9dabce; } :root[data-theme="dark"] .th-party.p3 { color:#c493ab; }
:root[data-theme="dark"] .th-party.p4 { color:#8cb8c6; } :root[data-theme="dark"] .th-party.p5 { color:#bcae7e; }
:root[data-theme="dark"] .th-party.p6 { color:#c9968e; } :root[data-theme="dark"] .th-party.p7 { color:#9cb891; }
/* ---- chat-first prototype: Relay rows / reader / Tasks board ---- */
.relay-row { display:flex; gap:11px; padding:13px 16px 0; cursor:pointer; }
.relay-row .rk-body { flex:1 1 auto; min-width:0; border-bottom:1px solid var(--hair); padding-bottom:13px; }
.relay-row:last-child .rk-body { border-bottom:0; }
.relay-row:hover { background:var(--wash); }
.relay-row .th-title { color:var(--muted); }
.relay-row.unread .th-title { color:var(--ink); font-weight:500; }
/* The LIST previews everything in one serif voice — uniformity is the old
list's calm (tried per-row sans for texts; alternating faces row to row
read as inconsistency, not information). Bubbles keep their voices. */
.avatar.sm { width:22px; height:22px; font-size:8.5px; }
.av-stack { flex:0 0 auto; display:flex; align-items:flex-start; padding-top:2px; width:32px; }
.av-stack .avatar { position:relative; }
.av-stack .avatar:first-child { z-index:1; box-shadow:0 0 0 2px var(--bg); }
.av-stack .avatar + .avatar { margin-left:-8px; }
/* The gist's sender speaks IN the gist's own face and size (WhatsApp's
"Name: message") — an 11px sans label glued to a 15px serif sentence was
the inline face-mixing that made rows read messy. Color is the only mark. */
.gist-who { color:var(--muted-3); }
/* the reader — one relay as a full page */
/* No border under the bar: the folder's own rule is the page's one line —
two stacked hairlines read as competing chrome (David). */
.reader-bar { display:flex; align-items:center; gap:9px; padding:12px 16px; }
.reader-name { font-family:var(--sans); font-size:13px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.reader-delete { appearance:none; width:25px; height:25px; margin-left:auto; flex:0 0 25px; display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--r-1); background:transparent; color:var(--muted-3); cursor:pointer; transition:color var(--t-fast) var(--settle), background var(--t-fast) var(--settle); }
.reader-delete:hover { color:var(--danger); background:var(--danger-soft); }
.reader-delete:disabled { opacity:.45; cursor:default; }
.reader-meta { padding:14px 16px 0; }
.reader-subject { font-family:var(--serif); font-size:17px; font-weight:500; line-height:1.3; letter-spacing:-.005em; color:var(--ink); }
.reader-from { display:flex; gap:7px; align-items:baseline; margin-top:6px; font-family:var(--sans); font-size:11.5px; color:var(--muted-2); }
.reader-from b { font-weight:600; }
/* THE RUN STREAM (canon: docs/RUN_VIEW_DESIGN_SYSTEM.md + proto/run-view-system.html).
One chronological stream: acts render inline in observed order and settle by
folding IN PLACE, never by moving. Nothing is pinned — the old bottom
act-square and the pinned whisper are both retired; the running act itself
IS the aliveness, carrying the only breathing square on the page. Labels are
Inter with a 600 verb; mono is reserved for the kicker and machine detail. */
/* The letter's markup. Paper law: no boxes stacked in the measure — a rule or
a wash, never both, and mono only where the text is genuinely machine. */
/* Both Relay documents use the same complete Markdown grammar. The old
implementation scoped every deliberate style to the human document, so
the agent document fell back to browser defaults: raw headings, clipped
pre blocks, and unstyled tables. Typography differs; document capability
does not. */
:is(.rd-body,.rd-agentcopy) .md-h { font-family:var(--sans); font-weight:650; letter-spacing:-.008em; margin:20px 0 7px; color:var(--ink); }
.rd-body .md-h { font-family:var(--serif); }
:is(.rd-body,.rd-agentcopy) .md-h1 { font-size:19px; } :is(.rd-body,.rd-agentcopy) .md-h2 { font-size:17px; } :is(.rd-body,.rd-agentcopy) .md-h3 { font-size:15px; }
:is(.rd-body,.rd-agentcopy) .md-h4, :is(.rd-body,.rd-agentcopy) .md-h5, :is(.rd-body,.rd-agentcopy) .md-h6 { font-size:13px; color:var(--muted-1, var(--ink)); }
:is(.rd-body,.rd-agentcopy) strong { font-weight:650; }
:is(.rd-body,.rd-agentcopy) del { opacity:.6; }
:is(.rd-body,.rd-agentcopy) a { color:inherit; text-decoration:underline; text-underline-offset:2px; text-decoration-color:var(--edge); cursor:pointer; }
:is(.rd-body,.rd-agentcopy) a:hover { text-decoration-color:currentColor; }
:is(.rd-body,.rd-agentcopy) .md-code { font-family:var(--mono); font-size:.86em; padding:1px 4px; border-radius:3px; background:var(--wash, rgba(127,127,127,.10)); }
:is(.rd-body,.rd-agentcopy) .md-codeblock { margin:12px 0; border:1px solid var(--hair); border-radius:8px; overflow:hidden; background:var(--wash, rgba(127,127,127,.08)); }
:is(.rd-body,.rd-agentcopy) .md-codebar { height:30px; display:flex; align-items:center; gap:8px; padding:0 9px 0 11px; border-bottom:1px solid var(--hair); font-family:var(--sans); font-size:10px; color:var(--muted-2); }
:is(.rd-body,.rd-agentcopy) .md-lang { font-family:var(--mono); text-transform:uppercase; letter-spacing:.08em; }
:is(.rd-body,.rd-agentcopy) .md-codebar .spacer { flex:1; }
:is(.rd-body,.rd-agentcopy) .md-codebar button { appearance:none; border:0; background:transparent; padding:3px 5px; border-radius:5px; color:inherit; font:inherit; cursor:pointer; }
:is(.rd-body,.rd-agentcopy) .md-codebar button:hover { color:var(--ink); background:var(--wash-2); }
:is(.rd-body,.rd-agentcopy) .md-pre { box-sizing:border-box; max-width:100%; font-family:var(--mono); font-size:11.5px; line-height:1.6; margin:0; padding:11px 12px 13px; max-height:300px; overflow:auto; white-space:pre; scrollbar-gutter:stable; }
:is(.rd-body,.rd-agentcopy) .md-codeblock.wrap .md-pre { white-space:pre-wrap; overflow-wrap:anywhere; }
:is(.rd-body,.rd-agentcopy) .md-pre code { font:inherit; background:none; padding:0; }
:is(.rd-body,.rd-agentcopy) .md-list { margin:8px 0 8px 2px; padding-left:20px; }
:is(.rd-body,.rd-agentcopy) .md-list li { margin:5px 0; }
:is(.rd-body,.rd-agentcopy) .md-list .md-list { margin:3px 0; }
:is(.rd-body,.rd-agentcopy) .md-task { display:inline-block; width:9px; height:9px; margin-right:7px; border:1px solid var(--edge); border-radius:2px; vertical-align:0; }
:is(.rd-body,.rd-agentcopy) .md-task.on { background:var(--ok, #4E8A5F); border-color:transparent; }
:is(.rd-body,.rd-agentcopy) .md-quote { margin:12px 0; padding:5px 0 5px 14px; border-left:2px solid var(--edge); color:var(--muted-1, var(--muted-2)); }
:is(.rd-body,.rd-agentcopy) .md-rule { height:0; border-top:1px solid var(--edge); margin:18px auto; width:64px; }
:is(.rd-body,.rd-agentcopy) .md-tablewrap { margin:12px 0; overflow-x:auto; }
:is(.rd-body,.rd-agentcopy) .md-table { border-collapse:collapse; font-family:var(--sans); font-size:12px; width:100%; }
:is(.rd-body,.rd-agentcopy) .md-table th, :is(.rd-body,.rd-agentcopy) .md-table td { text-align:left; padding:7px 12px 7px 0; border-bottom:1px solid var(--edge); vertical-align:top; }
:is(.rd-body,.rd-agentcopy) .md-table th { font-weight:650; color:var(--muted-2); font-size:10px; letter-spacing:.06em; text-transform:uppercase; }
:is(.rd-body,.rd-agentcopy) .md-img { max-width:100%; border-radius:6px; margin:8px 0; }
/* Settings: what Tasks may do. One value row per app — the "Open Relays
with" species (logo, name, current mode) — that unfolds the standard
open-actions tray inline. The tray is the explanation surface: modes are
each vendor's own names, read at the moment of choosing. */
.perm-vrow { appearance:none; border:0; background:transparent; width:100%; cursor:pointer;
font:inherit; color:inherit; text-align:left; transition:background-color var(--t-fast) var(--settle); }
.perm-vrow:hover { background:var(--wash); }
.perm-app.open > .perm-vrow { background:var(--wash-2); }
.perm-app + .perm-app { border-top:1px solid var(--edge); }
.perm-value { display:flex; align-items:center; gap:7px; font-family:var(--sans); font-size:13px; color:var(--muted); }
.perm-chev { flex:none; color:var(--muted-2); transition:transform var(--t-base) var(--settle); }
.perm-app.open .perm-chev { transform:rotate(90deg); }
.perm-tray { margin:0; border-radius:0; padding:4px 6px 6px; border-top:1px solid var(--hair); }
.perm-tray .ap-row { padding-left:45px; }
/* The read receipt: a whisper under your own message, never a badge. */
.th-msg .th-seen { display:block; margin-top:3px; font-family:var(--sans); font-size:10px;
letter-spacing:.01em; color:var(--muted-2); opacity:.7; }
.th-msg.mine .th-seen { text-align:right; }
/* THE CONTRACT (canon: proto/scheduled-requests-board.html .spec/.srow).
Structure comes from a hairline above each row and a fixed label column —
without them the spec ran into the letter as one undifferentiated block
(David: "no structure, everything's so tightly packed"). Labels are caps
but NOT the razor-thin face — that one is banned outside the composer. */
.sch-spec { max-width:34em; margin:26px auto 0; }
.sch-row { display:flex; gap:14px; padding:11px 0; border-top:1px solid var(--edge); align-items:baseline; }
.sch-l { flex:0 0 82px; font-family:var(--sans); font-size:9.5px; font-weight:600; letter-spacing:.09em;
text-transform:uppercase; color:var(--muted-2); }
.sch-v { flex:1 1 auto; min-width:0; font-family:var(--sans); font-size:12.5px; line-height:1.6; color:var(--ink-2, var(--ink)); }
.sch-mut { color:var(--muted-2); }
.sch-sq { display:inline-block; width:4px; height:4px; margin-right:6px; vertical-align:2px; }
/* The choice: a term of the contract, so it sits on the same hairline rhythm
as the shelves — its own row, its own breathing room, and an island sized
to its words rather than a slab across the measure. */
.sch-choice { max-width:34em; margin:0 auto; padding:16px 0 0; border-top:1px solid var(--edge); }
.sch-ask { font-family:var(--sans); font-size:9.5px; font-weight:600; letter-spacing:.09em;
text-transform:uppercase; color:var(--muted-2); margin-bottom:10px; }
.sch-island { position:relative; display:inline-grid; grid-auto-flow:column; grid-auto-columns:1fr; padding:3px;
border-radius:999px; background:var(--wash, rgba(127,127,127,.10)); isolation:isolate; }
/* EQUAL COLUMNS are load-bearing: the labels differ in width, so with
content-sized columns a 50% thumb aligns to neither and leaves more air on
one side than the other (David, who counted the pixels). 1fr each makes the
thumb's 50% exactly one option, and translateX(100%) land on the other. */
.sch-island .sch-thumb { position:absolute; z-index:0; top:3px; bottom:3px; left:3px; width:calc(50% - 3px);
border-radius:999px; background:var(--bg);
box-shadow:0 1px 2px rgba(0,0,0,.20), 0 0 0 1px var(--edge);
transition:transform .34s cubic-bezier(.32,1.24,.32,1); transform:translateX(0); }
.sch-island.auto .sch-thumb { transform:translateX(100%); }
.sch-island .sch-opt { position:relative; z-index:1; appearance:none; background:none; border:0; cursor:pointer;
padding:7px 16px; border-radius:999px; font-family:var(--sans); font-size:12px; font-weight:500;
color:var(--muted-2); transition:color .2s cubic-bezier(.35,0,.12,1); white-space:nowrap; }
.sch-island .sch-opt:nth-of-type(1) { color:var(--ink-2, var(--ink)); }
.sch-island.auto .sch-opt:nth-of-type(1) { color:var(--muted-2); }
.sch-island.auto .sch-opt:nth-of-type(2) { color:var(--ink-2, var(--ink)); }
.sch-note { margin-top:12px; font-family:var(--sans); font-size:11.5px; line-height:1.6; color:var(--muted-2); }
/* The cadence on a board row: a whisper in the row's control slot, never the
row's voice. It was inheriting the row's size and colliding with the title. */
.tb-when { flex:0 0 auto; max-width:11em; text-align:right; font-family:var(--sans); font-size:10.5px;
line-height:1.35; color:var(--muted-2); white-space:normal; }
/* THE CARGO ZONE (the cargo law, drawn properly).
A file belongs to the message above it, and belonging is said with
geometry — not with a box:
· it shares the bubble's LEFT EDGE (16px) and its 88% measure, because a
child may never out-measure its parent (the enclosure was wider than
the relay it hung off, David);
· it sits 5px under its own bubble and 14px clear of the next, so
proximity alone says whose it is;
· no border and no fill — the bubble is the object, the file is its
label. Two stacked rounded rectangles of different widths were two
objects competing;
· the size is a whisper right after the name, never flung to a
right-hand column with a table's worth of air between them. */
.th-cargo { display:flex; flex-wrap:wrap; align-items:baseline; gap:2px 16px; margin:5px 16px 14px; max-width:88%; width:fit-content; }
.th-cargo.mine { margin-left:auto; justify-content:flex-end; }
.th-cargo .td-att { flex:0 0 auto; display:inline-flex; align-items:baseline; gap:6px; min-width:0; max-width:100%;
margin:0; padding:0; border:0; border-radius:0; background:none; }
.th-cargo .td-att svg { align-self:center; opacity:.55; }
.th-cargo .td-att-name { font-family:var(--sans); font-size:11.5px; font-weight:500; color:var(--muted-1, var(--ink));
text-decoration:underline; text-underline-offset:2px; text-decoration-color:transparent; transition:text-decoration-color .16s ease; }
.th-cargo .td-att:hover .td-att-name { text-decoration-color:var(--edge); }
.th-cargo .td-att-meta { margin-left:0; font-family:var(--sans); font-size:10.5px; color:var(--muted-2); }
.th-cargo .td-att-meta::before { content:"·"; margin-right:5px; opacity:.7; }
.th-cargo .att-zone { margin:0; }
.th-cargo .att-grid { min-width:min(360px, 76vw); }
.th-cargo .att-cap .td-att-name { text-decoration:none; }
/* The stream's entrance, copied from the Codex app's own markdown renderer. */
@keyframes rdFadeIn { to { opacity:1; } }
@keyframes rdActivityIn { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:translateY(0); } }
.rd-fade { opacity:0; animation:rdFadeIn .15s cubic-bezier(.37,.55,.86,.88) forwards;
animation-delay:var(--fade-delay, 0s); }
@media (prefers-reduced-motion:reduce) { .rd-fade { opacity:1; animation:none; } .rd-activity-body .rd-act { animation-duration:0s; animation-delay:0s; transform:none; } .rd-act.live .act-icon, .rd-live-tail-dot { animation:none; opacity:1; } }
/* THE TASK RECEIPT. Not a relay: no avatar, no serif subject, no bubble. A
stamp word carries it, the way a receipt is carried by the word stamped on
it — so it can never be mistaken for something a person sent. */
.rl-receipt { display:flex; align-items:baseline; gap:10px; width:100%; text-align:left;
padding:9px 16px; background:none; border:0; border-left:2px solid var(--accent, #5B7BD0); cursor:pointer; }
.rl-receipt:hover { background:var(--wash); }
.rl-receipt.done { border-left-color:var(--ok, #4E8A5F); }
/* One column for the stamp, whatever the word. "STARTED" is longer than
"DONE", so an auto-width stamp shunted every title to a different left
edge and the list lost its spine (David). */
.rl-receipt .rc-stamp { flex:0 0 62px; font-family:var(--sans); font-size:9.5px; font-weight:600;
letter-spacing:.09em; text-transform:uppercase; color:var(--accent, #5B7BD0); }
.rl-receipt.done .rc-stamp { color:var(--ok, #4E8A5F); }
.rl-receipt .rc-body { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:1px; }
.rl-receipt .rc-title { font-family:var(--sans); font-size:12.5px; color:var(--ink-2, var(--ink));
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rl-receipt .rc-app { font-family:var(--sans); font-size:10.5px; color:var(--muted-2); }
.rl-receipt .rc-when { flex:0 0 auto; font-family:var(--sans); font-size:10px; color:var(--muted-2); }
/* Time alone creates conversation sections. Message species and internal
reply-chain ids never affect room geometry; after six quiet hours the new
chunk gets one dated hairline that labels the messages immediately below. */
.th-chunk-date { display:flex; align-items:center; gap:9px; width:100%; margin:24px 0 0;
color:var(--muted-2); font-family:var(--sans); font-size:10px; font-weight:600; line-height:1; }
.th-chunk-date.first { margin-top:8px; }
.th-chunk-date::before, .th-chunk-date::after { content:""; height:1px; background:var(--hair); }
.th-chunk-date::before { flex:0 0 16px; }
.th-chunk-date::after { flex:1 1 auto; }
.th-chunk-date span { flex:0 0 auto; white-space:nowrap; }
/* Under the bubble: what is ABOUT a message, not part of it. */
.th-under { display:flex; align-items:center; gap:10px; height:15px; margin:3px 16px 0; }
.th-under.mine { justify-content:flex-end; }
.th-under .th-seen { appearance:none; border:0; padding:0; background:transparent; font-family:var(--sans); font-size:10px; color:var(--muted-2); opacity:.75; }
/* A healthy send can cross several true states in a few hundred
milliseconds. Hold transient words for one quiet beat so the person sees
the first stable result, not transport chatter. Permanent failure is
deliberately not delayed. */
@keyframes th-status-settle { from { opacity:0; } to { opacity:.75; } }
.th-under .th-seen.th-settling { animation:th-status-settle 1ms 900ms both; }
.th-under button.th-seen { cursor:pointer; }
.th-under button.th-seen:hover { color:var(--ink); opacity:1; }
.th-message-action { appearance:none; border:0; padding:0; background:transparent; font-family:var(--sans); font-size:10px;
color:var(--muted-2); opacity:0; cursor:pointer; transition:opacity .15s ease,color .15s ease; }
.th-msg:hover + .th-under .th-message-action, .th-under:hover .th-message-action,
.th-message-action:focus-visible { opacity:.78; }
.th-message-action:hover { color:var(--ink); opacity:1; }
.th-message-action.danger, .th-message-action.confirm { color:var(--danger); }
.th-message-edit { display:grid; gap:8px; min-width:min(310px, 72vw); padding-top:2px; }
.th-message-edit textarea { width:100%; min-height:72px; max-height:220px; resize:vertical; border:1px solid var(--edge); border-radius:9px;
background:var(--bg); color:var(--ink); padding:9px 10px; font:14px/1.45 var(--sans); outline:none; }
.th-message-edit textarea:focus { border-color:var(--muted-2); box-shadow:0 0 0 2px color-mix(in srgb,var(--ink) 8%,transparent); }
.th-message-edit-actions { display:flex; justify-content:flex-end; gap:10px; }
.th-message-edit-actions button { appearance:none; border:0; background:transparent; padding:2px 0; color:var(--muted-2); font:10.5px var(--sans); cursor:pointer; }
.th-message-edit-actions button[data-message-edit-save] { color:var(--ink); font-weight:600; }
/* A message the server refused stays a whisper — it is not an alarm — but it
has to read as a problem, and it carries the only two acts left on it. */
.th-under .th-seen.warn { color:var(--danger); opacity:.95; }
.th-under button.th-seen.act { text-decoration:underline; text-underline-offset:2px; opacity:.95; }
.th-receipt-panel { width:min(280px, calc(100% - 32px)); margin:5px 16px 2px auto; padding:8px 10px;
border:1px solid var(--hair); border-radius:var(--r-2); background:var(--wash); font-family:var(--sans); }
.th-receipt-row { display:flex; justify-content:space-between; gap:16px; padding:3px 0; font-size:10.5px; color:var(--ink-2); }
.th-receipt-row + .th-receipt-row { border-top:1px solid var(--hair); }
.th-receipt-when { color:var(--muted-2); text-align:right; }
.th-under .th-reply-ghost { position:static; opacity:0; transition:opacity .15s ease; }
.th-msg:hover + .th-under .th-reply-ghost, .th-under:hover .th-reply-ghost { opacity:.75; }
/* A reply stays in the room's chronology. Its source is an inset preview,
never a side thread: tap it to return to the exact Relay it answers. */
/* The bubble clock owns the top-right lane. End the source card before it
instead of padding beneath it, so the clock never sits on the card edge. */
.th-reply-ref { flex:0 0 calc(100% - 42px); width:calc(100% - 42px); min-width:0; appearance:none; border:0; border-left:2px solid var(--accent);
border-radius:7px; background:color-mix(in srgb, var(--ink) 4%, transparent); padding:6px 9px; margin:0 42px 7px 0;
color:inherit; cursor:pointer; text-align:left; font-family:var(--sans); }
.th-reply-ref:hover { background:color-mix(in srgb, var(--ink) 7%, transparent); }
.th-reply-ref:disabled { cursor:default; opacity:.72; }
.th-reply-ref-who { display:block; color:var(--accent); font-size:9.5px; font-weight:600; line-height:1.3;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.th-reply-ref-copy { display:block; margin-top:2px; color:var(--muted-2); font-size:10.5px; line-height:1.35;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.th-msg.reply-source-flash { animation:replySourceFlash 1.2s var(--settle); }
@keyframes replySourceFlash {
0%,100% { box-shadow:none; }
18%,70% { box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent); background:var(--wash-2); }
}
/* Native Codex uses its 14px chat token at a 1.5 line-height for both
activity and assistant prose. The Work face inherits those measured
tokens instead of enlarging a transcript into a debug-looking report. */
.rd-said p { margin:0 0 11px; font-family:var(--sans); font-size:14px; line-height:21px; color:inherit; }
.rd-said p:last-child { margin-bottom:0; }
.rd-said strong { font-weight:640; }
.rd-said .md-code { font-family:var(--mono); font-size:.86em; padding:1px 4px; border-radius:3px; background:var(--wash, rgba(127,127,127,.10)); }
.rt-chip.static { cursor:default; opacity:.85; }
.rd-stream { margin-top:16px; margin-bottom:2px; display:flex; flex-direction:column; gap:16px; }
/* Consecutive native activity items are one group (4px); prose, user turns,
the completion seam and the final answer remain conversation items (16px). */
.rd-stream > .rd-act + .rd-act { margin-top:-12px; }
.rd-act { display:flex; gap:6px; align-items:center; min-width:0; font-family:var(--sans); font-size:14px; line-height:21px;
font-weight:445; color:color-mix(in oklab, var(--ink) 60%, transparent); }
.rd-act .act-icon { width:16px; height:16px; flex:0 0 16px; color:inherit; }
.rd-act.live .act-icon { animation:rdLivePulse 1.35s ease-in-out infinite; animation-play-state:var(--relay-loop-state); }
.rd-act .act-stack { display:flex; flex-direction:column; min-width:0; max-width:100%; }
.rd-act .act-copy { display:inline-flex; align-items:baseline; gap:4px; min-width:0; max-width:100%; }
.rd-act .averb { flex:none; font-weight:400; white-space:nowrap; }
.rd-act .aobj { min-width:0; font-weight:400; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rd-act .aobj.file { color:inherit; text-decoration:underline; text-decoration-style:dotted; text-decoration-thickness:.5px; text-underline-offset:2px; }
.rd-act .aobj.command { color:color-mix(in oklab, var(--ink) 40%, transparent); }
.rd-act.live { color:var(--ink); }
.rd-act-progress { min-width:0; max-width:min(760px, 78vw); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
color:color-mix(in oklab, var(--ink) 52%, transparent); font-family:var(--mono); font-size:11px; line-height:17px; }
.rd-live-tail { display:flex; align-items:center; gap:7px; min-height:21px; font-family:var(--sans); font-size:14px; line-height:21px;
color:color-mix(in oklab, var(--ink) 64%, transparent); }
.rd-live-tail-dot { width:6px; height:6px; flex:0 0 6px; border-radius:999px; background:var(--provider, var(--accent));
animation:rdLivePulse 1.35s ease-in-out infinite; animation-play-state:var(--relay-loop-state); }
@keyframes rdLivePulse { 0%,100% { opacity:.34; } 50% { opacity:1; } }
.rd-said { align-self:flex-start; width:100%; font-family:var(--sans); font-size:14px; line-height:21px; font-weight:445; color:var(--ink); overflow-wrap:anywhere; }
/* Codex conversation geometry: the agent and its work own the left edge;
every user turn is a compact bubble on the right. A follow-up is a real
message in the conversation, never replaced by “Reading the Task.” */
.rd-user { align-self:flex-end; width:fit-content; max-width:77%; padding:8px 12px;
border-radius:16px; background:var(--wash); font-family:var(--sans); font-size:14px; line-height:21px; font-weight:445;
color:var(--ink); overflow-wrap:anywhere; }
.rd-user p { margin:0 0 8px; font:inherit; color:inherit; }
.rd-user p:last-child { margin-bottom:0; }
.rd-user-files { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); justify-content:flex-end; gap:8px;
width:min(560px, 70vw); margin-bottom:7px; }
.rd-user-files.one { grid-template-columns:minmax(0, 1fr); }
.rd-user-files:last-child { margin-bottom:0; }
.rd-user-image-wrap { display:flex; flex-direction:column; min-width:0; margin:0; }
.rd-user-image-frame { display:grid; place-items:center; width:100%; aspect-ratio:16 / 10; overflow:hidden;
border:1px solid var(--hair); border-radius:12px; background:var(--wash); }
.rd-user-image-frame::before { content:""; width:5px; height:5px; background:var(--provider, var(--accent)); opacity:.52; }
.rd-user-image-wrap.ready .rd-user-image-frame { aspect-ratio:auto; background:transparent; }
.rd-user-image-wrap.ready .rd-user-image-frame::before { display:none; }
.rd-user-image { display:block; width:100%; height:auto; object-fit:contain; opacity:0; transform:scale(.98);
transition:opacity var(--t-slow) var(--settle), transform var(--t-slow) var(--spring, var(--soft)); }
.rd-user-image-wrap.ready .rd-user-image { opacity:1; transform:none; }
.rd-user-image-cap { padding:5px 2px 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
font-family:var(--mono); font-size:10.5px; line-height:15px; color:var(--muted-2); }
.rd-user-file { display:inline-flex; align-items:center; max-width:220px; padding:4px 8px; border-radius:9px; background:color-mix(in srgb, var(--ink) 7%, transparent); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.rd-prior-turn, .rd-current-turn { display:contents; }
.rd-activity { display:flex; flex-direction:column; gap:0; }
.rd-activity-toggle { position:relative; display:inline-flex; align-items:center; align-self:flex-start; gap:4px; min-width:0;
padding:0; border:1px solid transparent; border-radius:6px; background:transparent; color:color-mix(in srgb, var(--ink) 65%, transparent);
font-family:var(--sans); font-size:14px; line-height:21px; font-weight:445; cursor:pointer; text-align:left; }
.rd-activity-toggle:hover, .rd-activity-toggle:focus-visible { color:var(--ink); outline:none; }
.rd-activity-toggle .chev { width:12px; flex:none; color:color-mix(in oklab, var(--ink) 40%, transparent); opacity:0; transform:rotate(0deg); transition:transform .3s ease, opacity .15s ease; }
.rd-activity-toggle:hover .chev, .rd-activity-toggle:focus-visible .chev { opacity:1; }
.rd-activity-toggle[aria-expanded="true"] .chev { transform:rotate(90deg); opacity:1; }
.rd-activity-body { display:flex; flex-direction:column; gap:4px; padding:8px 0 4px 24px; }
.rd-activity-body.hidden { height:0; overflow:hidden; opacity:0; }
.rd-activity-body .rd-act { animation:rdActivityIn .22s cubic-bezier(.33,1,.68,1) both; }
.rd-empty { align-self:center; max-width:34em; font-family:var(--sans); font-size:12px; line-height:18px; text-align:center; color:var(--muted-2); }
.rd-activity + .worked-for-divider { margin-top:-12px; }
.worked-for-divider { height:1px; margin:0; padding:0; border-top:1px solid var(--hair); }
.rd-final { margin:0; font-family:var(--sans); font-size:14px; line-height:21px; font-weight:445; color:var(--ink); overflow-wrap:anywhere; }
.rd-final p { margin:0 0 11px; font-family:inherit; font-size:inherit; line-height:inherit; }
.rd-final p:last-child { margin-bottom:0; }
/* Installed Codex resolves text-size-chat to 14px × 1.5 = exactly 21px. */
.scroll.work-scroll-active { overflow-y:hidden; padding-bottom:0; }
#readerView.work-view, #readerBody.work-mode { height:100%; min-height:0; }
#readerBody.work-mode { overflow:hidden; }
.work-surface { position:relative; display:flex; flex:1 1 auto; min-height:0; width:100%; }
.work-scroll { --work-footer-height:0px; position:absolute; inset:0; overflow-x:hidden; overflow-y:auto;
overscroll-behavior:contain; overflow-anchor:none; scroll-padding-bottom:var(--work-footer-height);
padding:12px 20px calc(var(--work-footer-height) + 16px); scrollbar-gutter:stable both-edges; }
.work-scroll:focus { outline:none; }
.work-scroll-inner { width:100%; max-width:48rem; min-height:100%; margin:0 auto; display:flex; flex-direction:column; }
.work-context { display:flex; flex-direction:column; gap:2px; margin-bottom:18px; }
.work-context .rd-kicker { width:100%; margin:0; text-align:left; }
.work-context .rd-headline { width:100%; margin:8px 0 0; text-align:left; font-family:var(--sans); font-size:16px; line-height:24px; font-weight:600; letter-spacing:0; }
.work-conversation { display:flex; flex-direction:column; gap:24px; font-size:14px; line-height:21px; }
.work-starting { display:flex; align-items:center; gap:8px; min-height:21px; color:color-mix(in oklab,var(--ink) 64%,transparent); font:445 14px/21px var(--sans); }
.work-starting .rd-live-tail-dot { display:block; }
.work-connect { width:100%; max-width:48rem; margin:30px auto 0; }
.work-connect-title { color:var(--ink); font:650 18px/1.3 var(--sans); letter-spacing:-.01em; }
.work-connect-copy { max-width:36rem; margin-top:7px; color:var(--muted); font:400 13px/1.5 var(--sans); }
.work-connect-actions { display:grid; grid-template-columns:repeat(2,minmax(0,245px)); gap:10px; width:min(500px,100%); margin-top:20px; }
.work-connect-actions .th-host-action { min-width:0; border:1px solid var(--hair); border-radius:12px; }
.work-connect-actions .th-host-action + .th-host-action { border-top:1px solid var(--hair); }
.work-connect-actions .th-host-action:disabled { cursor:default; opacity:.55; }
.work-connect-error { min-height:18px; margin-top:10px; color:var(--danger); font:500 11px/1.45 var(--sans); }
.work-connect-dock .ta-note:disabled { color:var(--muted-3); opacity:1; }
@media (max-width:620px) { .work-connect-actions { grid-template-columns:1fr; width:min(280px,100%); } }
.work-turn { display:flex; flex-direction:column; gap:16px; min-width:0;
content-visibility:auto; contain-intrinsic-size:auto 240px; }
.work-agent, .work-live-units { display:flex; flex-direction:column; min-width:0; }
.work-agent { gap:16px; align-self:stretch; } .work-live-units { gap:4px; }
.work-turn .rd-user-wrap { align-self:flex-end; }
.work-turn .rd-final, .work-turn .rd-said { align-self:flex-start; width:100%; }
.work-response-spacer { flex:0 0 24px; min-height:24px; transition:min-height .22s cubic-bezier(.33,1,.68,1); }
.work-surface[data-work-active="true"] .work-response-spacer { min-height:clamp(72px,20vh,156px); }
.work-footer { position:absolute; left:0; right:0; bottom:0; z-index:4; padding:30px 20px 16px;
background:color-mix(in srgb,var(--bg) 62%,transparent); backdrop-filter:blur(14px); -webkit-backdrop-filter:blur(14px); }
.work-footer-inner { width:100%; max-width:48rem; margin:0 auto; }
.work-footer .rd-col.run { padding:0; } .work-footer .rd-col.run .qr { width:100%; max-width:none; margin-top:0; }
.work-conversation .rd-act, .work-conversation .rd-said, .work-conversation .rd-final,
.work-conversation .rd-user, .work-conversation .rd-activity-toggle { font-size:14px; line-height:21px; }
.work-conversation .rd-activity { gap:0; }
.work-conversation .rd-activity-toggle { gap:4px; border:0; border-radius:6px; color:color-mix(in oklab,var(--ink) 60%,transparent); }
.work-conversation .rd-activity-toggle:focus-visible { box-shadow:0 0 0 2px var(--accent-soft); }
.work-conversation .rd-activity-toggle .chev { opacity:0; transition:transform .3s ease,opacity .15s ease; }
.work-conversation .rd-activity-toggle:hover .chev, .work-conversation .rd-activity-toggle:focus-visible .chev,
.rd-activity[data-disclosure-state="opening"] .chev, .rd-activity[data-disclosure-state="expanded"] .chev { opacity:1; }
.rd-activity[data-disclosure-state="opening"] .chev, .rd-activity[data-disclosure-state="expanded"] .chev { transform:rotate(90deg); }
.work-conversation .rd-activity-body { display:block; height:0; overflow:hidden; opacity:0; padding:0 0 0 24px;
transition:height .3s cubic-bezier(.19,1,.22,1),opacity .3s cubic-bezier(.19,1,.22,1); }
.work-conversation .rd-activity-body-inner { display:flex; flex-direction:column; gap:4px; padding:8px 0 4px; }
.rd-activity[data-disclosure-state="opening"] .rd-activity-body,
.rd-activity[data-disclosure-state="expanded"] .rd-activity-body { opacity:1; overflow:visible; }
.rd-activity[data-disclosure-state="closing"] .rd-activity-body { opacity:0; overflow:hidden; }
.native-cadenced-shimmer { position:relative; color:color-mix(in srgb,var(--ink) 45%,transparent); }
.native-cadenced-shimmer-sweep { pointer-events:none; width:100%; position:absolute; inset:0 auto 0 0; overflow:hidden; transform:translate(-50%); -webkit-mask-image:linear-gradient(90deg,transparent 0%,#000 20% 30%,transparent 50% 100%); mask-image:linear-gradient(90deg,transparent 0%,#000 20% 30%,transparent 50% 100%); }
.native-cadenced-shimmer-highlight { color:#0009; width:100%; display:block; transform:translate(50%); }
.native-cadenced-shimmer.active .native-cadenced-shimmer-sweep,
.native-cadenced-shimmer.active .native-cadenced-shimmer-highlight { animation-duration:1s; animation-timing-function:steps(48,end); animation-iteration-count:1; }
.native-cadenced-shimmer.active .native-cadenced-shimmer-sweep { animation-name:nativeCadencedSweep; }
.native-cadenced-shimmer.active .native-cadenced-shimmer-highlight { animation-name:nativeCadencedHighlight; }
@keyframes nativeCadencedSweep { from { transform:translate(-50%); } to { transform:translate(125%); } }
@keyframes nativeCadencedHighlight { from { transform:translate(50%); } to { transform:translate(-125%); } }
.work-markdown .md-h { font-family:var(--sans); font-weight:650; margin:18px 0 7px; color:var(--ink); }
.work-markdown .md-code { font-family:var(--mono); font-size:.86em; padding:1px 6px; border-radius:6px; background:var(--wash); }
.rd-said .md-code { padding:1px 6px; border-radius:6px; }
.rd-user-wrap { display:flex; flex-direction:column; align-items:flex-end; max-width:77%; gap:4px; }
.rd-user-wrap .rd-user { align-self:auto; max-width:100%; }
.rd-user-actions { display:flex; justify-content:flex-end; gap:8px; min-height:21px; opacity:0; transition:opacity .13s ease; }
.rd-user-wrap:hover .rd-user-actions, .rd-user:focus-visible + .rd-user-actions, .rd-user-actions:focus-within { opacity:1; }
.rd-user-actions button { appearance:none; border:0; background:transparent; padding:3px 5px; color:var(--muted-2); font:500 11px/15px var(--sans); cursor:pointer; }
.rd-user-actions button:hover { color:var(--ink); }
.rd-generated-heading { color:var(--muted); font-size:14px; line-height:21px; margin-bottom:4px; }
.rd-semantic-group { color:color-mix(in oklab,var(--ink) 64%,transparent); font:445 14px/21px var(--sans); }
.rd-semantic-group .rd-activity-toggle { width:max-content; max-width:100%; }
.rd-semantic-group .rd-activity-body-inner { max-height:14rem; overflow-y:auto; overscroll-behavior:contain; }
.rd-blocking, .rd-error, .rd-status { font-size:14px; line-height:21px; color:var(--ink); }
.rd-error { display:flex; gap:8px; align-items:flex-start; color:var(--danger); }
.rd-error button { appearance:none; border:0; background:transparent; color:inherit; font:600 12px/21px var(--sans); cursor:pointer; }
.work-unit[role="button"] { cursor:pointer; border-radius:6px; outline:none; }
.work-unit[role="button"]:focus-visible { box-shadow:0 0 0 2px var(--accent-soft); }
.work-unit-detail { margin:7px 0 3px 22px; padding:8px 11px; max-height:260px; overflow:auto; border:1px solid var(--hair); border-radius:9px; background:var(--wash); color:var(--muted); font:11px/1.6 var(--mono); white-space:pre-wrap; user-select:text; }
.work-markdown .md-list { margin:8px 0 8px 2px; padding-left:20px; } .work-markdown .md-list li { margin:5px 0; }
.work-markdown .md-quote { margin:12px 0; padding:5px 0 5px 14px; border-left:2px solid var(--edge); color:var(--muted-2); }
@media (prefers-reduced-motion:reduce) {
.work-surface.work-entering .work-transition-source { display:none; animation:none; }
.work-surface.work-entering .work-scroll, .work-surface.work-entering .work-footer { opacity:1; transform:none; animation:none; }
.rd-activity-body { transition-duration:.3s !important; transition-timing-function:cubic-bezier(.19,1,.22,1) !important; }
.rd-activity-body .rd-act { animation:none; transform:none; }
.native-cadenced-shimmer.active .native-cadenced-shimmer-sweep,
.native-cadenced-shimmer.active .native-cadenced-shimmer-highlight { animation:none; }
.work-response-spacer { transition:none; }
}
.reader-actions { display:flex; gap:2px; margin:12px 16px 0; padding:2px; border-radius:var(--r-2); background:var(--wash); }
.reader-actions button { flex:1 1 0; appearance:none; border:0; background:transparent; cursor:pointer; text-align:center; padding:7px 4px; font-family:var(--sans); font-size:11.5px; border-radius:var(--r-1); color:var(--ink); }
.reader-actions button.pri { font-weight:600; color:var(--accent); }
.reader-actions button:hover { background:color-mix(in srgb, var(--ink) 5%, transparent); }
.reader-body { padding:14px 16px 18px; font-family:var(--sans); font-size:12.5px; line-height:1.62; color:var(--ink); overflow-wrap:anywhere; }
#readerBody > .row-err { padding:0 16px; }
.reader-body p { margin:0 0 10px; }
/* THE COMPOSER — one component everywhere (reader reply, agent dock,
conversation). The capsule IS the field: inner controls are naked and
the focus ring lives on the capsule (:focus-within), rounded like it —
nothing square can ever appear. It grows with your words, up to six
lines, easing from pill to panel; and it sits at the BOTTOM of its
surface, like every composer worth typing into. */
.qr { display:flex; gap:8px; align-items:flex-end; margin:6px 16px 16px; padding:6px 6px 6px 16px; border:1px solid var(--edge); border-radius:22px; background:var(--bg);
transition:border-color var(--t-base) var(--settle), box-shadow var(--t-base) var(--settle), border-radius var(--t-base) var(--settle); }
.qr:focus-within { border-color:var(--accent);
box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent), 0 6px 18px -12px color-mix(in srgb, var(--accent) 40%, transparent); }
.qr textarea { flex:1 1 auto; min-width:0; appearance:none; border:0; outline:none !important; box-shadow:none; background:transparent;
font-family:var(--sans); font-size:13px; line-height:1.5; color:var(--ink); resize:none; padding:5px 0; height:30px; max-height:126px; overflow-y:auto; scrollbar-width:none; }
.qr .th-rich-composer { flex:1 1 auto; min-width:0; appearance:none; border:0; outline:none !important; box-shadow:none; background:transparent;
font-family:var(--sans); font-size:13px; line-height:1.5; color:var(--ink); resize:none; padding:5px 0; height:30px; max-height:126px; overflow-y:auto; scrollbar-width:none; }
.qr textarea::-webkit-scrollbar { display:none; }
.qr .th-rich-composer::-webkit-scrollbar { display:none; }
.qr textarea::placeholder { color:var(--muted-3); }
.th-rich-composer { white-space:pre-wrap; overflow-wrap:anywhere; cursor:text; }
.th-rich-composer:empty::before { content:attr(data-placeholder); color:var(--muted-3); pointer-events:none; }
.th-rich-composer .th-composer-mention { display:inline-block; max-width:100%; margin:0 1px; padding:0 4px;
border:1px solid color-mix(in srgb, var(--accent) 30%, transparent); border-radius:4px;
background:color-mix(in srgb, var(--accent) 14%, transparent); color:var(--accent); font-weight:600;
line-height:1.35; white-space:nowrap; vertical-align:baseline; user-select:all; }
.th-rich-composer .th-composer-mention.agent { color:var(--ink); border-color:color-mix(in srgb, var(--agent-sq) 45%, transparent);
background:color-mix(in srgb, var(--agent-sq) 14%, transparent); }
/* the relays-list quick reply keeps its own bordered field — no capsule around it */
.qr.qr-bare, .qr.qr-bare:focus-within { border:0; padding:0; box-shadow:none; background:transparent; }
/* min-width:0 — an input's intrinsic size otherwise refuses to shrink and
shoves the button past the card edge in narrow contexts (Sven: the
clipped Approve on the board dock). */
.qr button { flex:0 0 auto; appearance:none; border:0; cursor:pointer; background:var(--accent); color:var(--on-accent, #fff); font-family:var(--sans); font-size:11.5px; font-weight:600; border-radius:999px; padding:6px 14px; }
/* (the pre-dock full-width .ta-approve rule died with the old request reader —
it collided with the dock's Approve and ballooned it to page width) */
.ta-notefield { display:block; width:calc(100% - 32px); margin:8px 16px 0; appearance:none; outline:0; border:1px solid var(--edge); border-radius:var(--r-2); background:transparent; padding:9px 14px; font-family:var(--sans); font-size:12px; color:var(--ink); resize:none; }
.ta-notefield::placeholder { color:var(--muted-3); }
.ta-dock { position:sticky; bottom:0; margin:10px 0 0; padding:2px 16px 8px; background:var(--bg); box-shadow:0 -12px 16px -10px var(--bg), 0 2px 0 0 var(--bg); z-index:2; }
.ta-meta { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:0 2px 7px; }
.ta-later { appearance:none; cursor:pointer; background:transparent; border:1px solid var(--edge); border-radius:999px; padding:4px 13px; font-family:var(--sans); font-size:11px; font-weight:600; color:var(--muted); }
.ta-later:hover { color:var(--ink); border-color:var(--muted-3); }
/* The dock capsule is a column: your words on top, the ROUTE RAIL beneath —
destination as a writable sentence (■ app · model · effort), Not now and
Start at its end. The rail replaces the old transparency line. */
.qr.col { margin:0; flex-direction:column; align-items:stretch; gap:4px; padding:10px 10px 8px 16px; border-radius:16px; }
.run-queue { display:flex; flex-direction:column; gap:2px; max-height:30vh; overflow-y:auto; margin:0 0 4px; }
.run-queued { display:flex; align-items:center; gap:7px; min-width:0; padding:4px 2px 5px; color:var(--muted); font-family:var(--sans); font-size:11.5px; line-height:16px; }
.run-queued-preview { width:24px; height:24px; flex:none; object-fit:cover; border:1px solid var(--hair); border-radius:5px; }
.run-queued-text { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.qr .run-queued button { background:transparent; color:var(--muted); padding:3px 6px; font-size:10.5px; font-weight:600; }
.qr .run-queued button:hover { color:var(--ink); background:var(--wash); }
.qr .run-queued .run-queued-now { display:flex; align-items:center; gap:3px; }
.qr .run-queued .run-queued-delete { font-size:14px; font-weight:400; line-height:1; }
.ta-rail { display:flex; align-items:center; gap:8px; min-width:0; flex-wrap:nowrap; }
/* The verbs travel together at the rail's end and NEVER wrap (David): a
long app name ("Open in Claude Cowork") WIDENS THE COMPOSER instead
(David): nothing truncates and nothing wraps — the capsule grows to fit
its own rail, up to the page's measure. */
.ta-acts { display:flex; align-items:center; gap:8px; flex:0 0 auto; }
/* the reply rail's whisper: who this goes to, wearing their disc's hue */
.rp-to { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:10px; letter-spacing:.04em; color:var(--muted-2); min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.rp-to .sq { width:4px; height:4px; flex:none; }
.rt-chips { display:flex; align-items:center; gap:6px; flex:0 0 auto; flex-wrap:nowrap; white-space:nowrap; }
/* rail chips are whispers, not buttons — scoped hard so the generic
.qr button fill can never reach them */
.qr .ta-rail .rt-chip { appearance:none; background:none; border:0; cursor:pointer; padding:3px 4px; border-radius:6px;
font-family:var(--mono); font-size:10px; font-weight:400; letter-spacing:.04em; color:var(--muted-2);
display:flex; align-items:center; gap:6px; white-space:nowrap; transition:color var(--t-fast) var(--settle), background var(--t-fast) var(--settle); }
.qr .ta-rail .rt-chip:hover { color:var(--ink); background:var(--wash); }
.qr .ta-rail .rt-chip .sq { width:4px; height:4px; background:var(--agent-sq); flex:none; }
.rt-dot { color:var(--muted-3); font-size:10px; }
.rt-spacer { flex:1 1 auto; }
.qr .ta-rail .ta-later { background:transparent; border:1px solid var(--edge); color:var(--muted); font-size:11px; padding:5px 13px; }
.qr .ta-rail .ta-later:hover { color:var(--ink); border-color:var(--muted-3); }
.qr .ta-rail .ta-approve { background:var(--accent); color:var(--on-accent,#fff); }
.request-safety, .result-review { margin:0 0 4px; padding:9px 10px; border:1px solid var(--hair); border-radius:11px; background:var(--wash); font-family:var(--sans); color:var(--muted); font-size:11px; line-height:1.45; }
.request-safety strong, .result-review strong { display:block; margin-bottom:3px; color:var(--ink); font-size:11.5px; }
.request-safety ul, .result-review ul { margin:5px 0 0; padding-left:17px; }
.request-safety .permission-line { margin-top:5px; color:var(--ink-soft); }
/* the route menu — the island's night material */
.rt-menu { position:fixed; z-index:99; min-width:150px; padding:5px; border-radius:12px;
background:linear-gradient(180deg,#2B2622,#1E1A16);
box-shadow:0 14px 34px -12px rgba(34,30,27,.55), 0 2px 6px rgba(34,30,27,.3), inset 0 1px 0 rgba(255,255,255,.08); }
.rt-item { appearance:none; border:0; background:none; cursor:pointer; width:100%; text-align:left;
display:flex; align-items:center; gap:9px; padding:7px 11px; border-radius:8px;
font-family:var(--sans); font-size:12px; color:#D9D0C6; }
.rt-item:hover { background:rgba(237,231,225,.08); }
.rt-item .sq { width:4px; height:4px; background:transparent; flex:none; }
.rt-item.on { color:#FFFDFA; font-weight:600; }
/* Unavailable surfaces: legible, unclickable, and they say why (David). */
.rt-item.off, .ap-row.off { opacity:.45; cursor:default; }
.rt-item.off:hover, .ap-row.off:hover { background:transparent; }
.rt-why, .ap-why { display:block; font-family:var(--sans); font-size:10.5px; color:var(--muted-3); margin-top:2px; }
.rt-item.on .sq { background:var(--agent-sq); }
.request-badge-inline { display:inline-block; font-family:var(--sans); font-size:10px; font-weight:700; letter-spacing:.04em; color:var(--warn); background:color-mix(in srgb, var(--warn) 10%, transparent); border-radius:5px; padding:2px 7px; vertical-align:2px; margin-right:7px; }
.tb-group { display:flex; align-items:baseline; gap:7px; padding:16px 16px 4px; font-family:var(--sans); font-size:11px; font-weight:600; color:var(--muted); }
.tb-group .n { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--muted-3); font-weight:400; }
/* Inbox zero is earned, not another empty list. The three glass tiles are the
Relay mark itself, lifted into a quiet one-shot celebration; completed work
stays behind the same calm disclosure whether or not live work remains. */
.task-zero {
position:relative; min-height:332px; padding:82px 20px 48px;
display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; overflow:hidden;
transition:min-height .52s var(--soft), padding .52s var(--soft);
}
.task-zero.compact { min-height:164px; padding:28px 20px 22px; }
.tz-glow {
position:absolute; left:50%; top:44%; width:250px; height:165px;
transform:translate(-50%,-50%); border-radius:50%; pointer-events:none;
background:radial-gradient(ellipse, color-mix(in srgb, var(--brand-anthropic) 10%, transparent), color-mix(in srgb, var(--brand-codex) 4%, transparent) 48%, transparent 72%);
filter:blur(10px); opacity:.9;
transition:opacity .4s var(--settle), transform .52s var(--soft);
}
.task-zero.compact .tz-glow { opacity:.42; transform:translate(-50%,-50%) scale(.58); }
.tz-mark { position:relative; z-index:1; display:flex; align-items:center; gap:17px; height:52px; margin-bottom:20px; transition:gap .52s var(--soft), height .52s var(--soft), margin .52s var(--soft); }
.tz-tile {
display:block; width:36px; height:36px; border-radius:9px;
border:1px solid color-mix(in srgb, var(--paper-edge) 55%, transparent);
box-shadow:inset 0 1px 0 color-mix(in srgb, var(--paper-edge) 65%, transparent), 0 12px 24px -15px currentColor;
transition:width .52s var(--soft), height .52s var(--soft), border-radius .52s var(--soft), transform .52s var(--soft), box-shadow .4s var(--settle);
}
.tz-tile.peach { color:var(--brand-anthropic); background:linear-gradient(145deg, color-mix(in srgb, var(--brand-anthropic) 84%, white), color-mix(in srgb, var(--brand-anthropic) 82%, #5d2e22)); transform:translateY(5px) rotate(-12deg); }
.tz-tile.cream { color:var(--ink); background:linear-gradient(145deg, color-mix(in srgb, var(--ink) 86%, white), color-mix(in srgb, var(--ink) 72%, var(--bg))); transform:translateY(-1px); }
.tz-tile.violet { color:var(--brand-codex); background:linear-gradient(145deg, color-mix(in srgb, var(--brand-codex) 82%, white), color-mix(in srgb, var(--brand-codex) 84%, #282277)); transform:translateY(5px) rotate(12deg); }
.task-zero.celebrate .tz-tile { animation:taskZeroTileIn .58s var(--soft) both; }
.task-zero.celebrate .tz-tile.cream { animation-delay:.045s; }
.task-zero.celebrate .tz-tile.violet { animation-delay:.09s; }
@keyframes taskZeroTileIn { from { opacity:0; transform:translateY(15px) scale(.76) rotate(0); } }
.task-zero.compact .tz-mark { gap:8px; height:24px; margin-bottom:9px; }
.task-zero.compact .tz-tile { width:18px; height:18px; border-radius:5px; box-shadow:inset 0 1px 0 color-mix(in srgb, var(--paper-edge) 45%, transparent); }
.task-zero.compact .tz-tile.peach { transform:translateY(2px) rotate(-8deg); }
.task-zero.compact .tz-tile.cream { transform:none; }
.task-zero.compact .tz-tile.violet { transform:translateY(2px) rotate(8deg); }
.tz-title { position:relative; z-index:1; margin:0; font-family:var(--serif); font-size:29px; font-weight:500; line-height:1.06; letter-spacing:-.02em; color:var(--ink); transition:font-size .52s var(--soft); }
.tz-sub { position:relative; z-index:1; margin-top:11px; font-family:var(--sans); font-size:12.5px; color:var(--muted); transition:font-size .52s var(--soft), margin .52s var(--soft); }
.task-zero.compact .tz-title { font-size:21px; }
.task-zero.compact .tz-sub { margin-top:5px; font-size:11px; }
.tb-completed-toggle {
appearance:none; width:calc(100% - 32px); margin:0 16px; padding:14px 0;
border:0; border-top:1px solid var(--hair); border-bottom:1px solid var(--hair);
background:transparent; color:var(--muted); cursor:pointer;
display:flex; align-items:center; justify-content:space-between;
font-family:var(--sans); font-size:11.5px; font-weight:600;
transition:color var(--t-fast) var(--settle), background var(--t-fast) var(--settle);
}
.tb-completed-toggle:hover { color:var(--ink); background:var(--wash); }
.tb-completed-meta { display:flex; align-items:center; gap:10px; color:var(--muted-3); font-family:var(--mono); font-size:10px; font-weight:400; }
.tb-completed-toggle svg { transition:transform .42s var(--soft); }
.tb-completed-toggle[aria-expanded="true"] svg { transform:rotate(180deg); }
.tb-completed-reveal { display:grid; grid-template-rows:0fr; opacity:0; transform:translateY(-6px); transition:grid-template-rows .5s var(--soft), opacity .28s var(--settle), transform .5s var(--soft); }
.tb-completed-reveal.open { grid-template-rows:1fr; opacity:1; transform:none; }
/* Payload refreshes rebuild the Tasks board. An already-open disclosure must
land fully open instead of restarting its reveal transition on every poll. */
.tb-completed-reveal.preexpanded { transition:none; }
.tb-completed-clip { min-height:0; overflow:hidden; }
.tb-row { display:grid; grid-template-columns:auto minmax(0,1fr); gap:0 11px; padding:13px 16px 0; cursor:pointer; }
.tb-row .tb-main { flex:1 1 auto; min-width:0; border-bottom:1px solid var(--hair); padding-bottom:13px; }
.tb-row:last-of-type .tb-main { border-bottom:0; }
.tb-row:hover { background:var(--wash); }
.tb-main { flex:1 1 auto; min-width:0; }
/* Board rows reuse .th-title — same face, same read/unread emphasis as every
relay row (hardcoded ink made requests read uniformly bold; Sven flagged it). */
.tb-row .th-title { margin-top:0; color:var(--muted); }
.tb-row.unread .th-title { color:var(--ink); font-weight:500; }
.tb-sub { font-family:var(--sans); font-size:10px; color:var(--muted-2); margin-top:3px; }
.tb-row > .task-claim-control { grid-column:1 / -1; margin:0 0 13px; }
.tb-actions { flex:0 0 auto; display:flex; align-items:center; gap:3px; margin-left:8px; }
/* Task verbs should feel human, not terminal-like: warm Inter, unboxed, and
visible enough to make a waiting Task immediately actionable. */
.tb-go { flex:0 0 auto; appearance:none; cursor:pointer; background:none; border:0; font-family:var(--sans); font-size:10.5px; font-weight:600; letter-spacing:0; color:var(--accent); padding:3px 5px; border-radius:6px; transition:color var(--t-fast) var(--settle), background var(--t-fast) var(--settle); }
.tb-go:hover { color:var(--ink); background:var(--wash); }
.tb-delete { appearance:none; width:21px; height:21px; flex:0 0 21px; display:flex; align-items:center; justify-content:center; border:0; border-radius:6px; background:transparent; color:var(--muted-3); cursor:pointer; opacity:.7; transition:opacity var(--t-fast) var(--settle), color var(--t-fast) var(--settle), background var(--t-fast) var(--settle); }
.tb-row:hover .tb-delete, .tb-delete:focus-visible { opacity:1; }
.tb-delete:hover { color:var(--danger); background:var(--danger-soft); }
.tb-delete:disabled { opacity:.35; cursor:default; }
.tb-working { display:inline-flex; align-items:center; color:var(--accent); font-family:var(--sans); font-size:10.5px; font-weight:500; white-space:nowrap; }
.tb-working-orb { position:relative; width:6px; height:6px; flex:0 0 6px; margin-right:7px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 11%, transparent); animation:taskWorkingBreathe 1.8s cubic-bezier(.4,0,.2,1) infinite; animation-play-state:var(--relay-loop-state); }
.tb-working-orb::after { content:""; position:absolute; left:1px; top:1px; width:2px; height:2px; border-radius:50%; background:color-mix(in srgb, white 70%, var(--accent)); opacity:.7; }
/* Keep the live cue on Chromium's compositor. Animating box-shadow repaints
the transparent always-on-top window and can monopolize Electron's main /
renderer IPC path even after the card folds. */
@keyframes taskWorkingBreathe { 0%,100% { opacity:.62; transform:scale(.88); } 50% { opacity:1; transform:scale(1); } }
@media (prefers-reduced-motion:reduce) {
.tb-working-orb, .task-zero.celebrate .tz-tile { animation:none; opacity:1; }
.task-zero, .tz-glow, .tz-mark, .tz-tile, .tz-title, .tz-sub, .tb-completed-reveal, .tb-completed-toggle svg { transition-duration:.001s; }
}
/* The room composer owns an opaque dock, not just an opaque capsule. Chat
history may scroll behind this layer, but it must never remain legible in
the gutter or below the field (the same solid-foot contract as Codex and
Claude Code). Inbox-order mode keeps the composer in normal flow. */
.th-composer-dock { flex:none; padding:14px 16px 0; background:var(--bg); }
.th-composer-dock.chat-shaped { position:sticky; bottom:0; z-index:2; padding-bottom:16px;
box-shadow:0 -12px 16px -10px var(--bg), 0 2px 0 0 var(--bg); }
.th-composer-dock .qr.th-qr { position:static; margin:0; padding-bottom:6px; background:var(--bg); box-shadow:none; z-index:auto; }
.th-composer-dock .qr.th-qr { position:relative; }
.th-mention-menu { position:absolute; left:10px; right:10px; bottom:100%; margin-bottom:7px; z-index:8; overflow:hidden;
border:1px solid var(--edge); border-radius:12px; background:var(--bg); box-shadow:0 12px 30px color-mix(in srgb, #000 18%, transparent); }
.th-mention-menu.hidden { display:none; }
.th-mention-item { width:100%; display:flex; align-items:center; gap:9px; padding:9px 11px !important; border-radius:0 !important;
background:transparent !important; color:var(--ink) !important; text-align:left; font-size:12px !important; }
.th-mention-item.on { background:var(--wash) !important; }
.th-mention-dot { width:7px; height:7px; flex:none; border-radius:2px; background:var(--muted-3); }
.th-mention-item.agent .th-mention-dot { background:var(--agent-sq); }
.th-mention-copy { display:flex; flex-direction:column; min-width:0; }
.th-mention-name { font-weight:650; }
.th-mention-note { color:var(--muted); font-size:10.5px; }
/* Chat-agent defaults stay compact without forcing four controls onto one
line. Identity owns the first line; model and thinking share a labelled,
responsive grid below it. */
.sv-agent-defaults .sv-open-row { display:block; min-height:0; padding:11px 12px 12px; }
.sv-agent-identity { display:flex; align-items:center; gap:11px; min-width:0; }
.sv-agent-fields { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:8px; margin:10px 0 0 39px; }
.sv-agent-field { display:block; min-width:0; }
.sv-agent-field-label { display:block; margin:0 0 4px 1px; color:var(--muted-2); font:10px/1.25 var(--sans); }
.sv-agent-select { width:100%; min-width:0; height:34px; padding:0 8px; border:1px solid var(--edge); border-radius:9px;
background:var(--bg); color:var(--ink); font:11.5px var(--sans); cursor:pointer; }
.sv-agent-select:focus-visible { outline:none; box-shadow:var(--focus-ring); }
/* The chosen Relay is attached to the existing composer as its top face.
This is one composer with context, not a Slack-style child conversation. */
.qr.th-qr.replying { gap:0; }
.th-reply-target { position:relative; min-width:0; margin:-10px -10px 7px -16px; padding:9px 36px 9px 14px;
border-bottom:1px solid var(--edge); border-radius:15px 15px 0 0; background:var(--wash); }
.th-reply-target-who { color:var(--accent); font-family:var(--sans); font-size:10px; font-weight:600; line-height:1.35;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.th-reply-target-copy { margin-top:2px; color:var(--muted-2); font-family:var(--sans); font-size:10.5px; line-height:1.4;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.th-reply-target.missing .th-reply-target-who, .th-reply-target.missing .th-reply-target-copy { color:var(--muted-2); font-style:italic; }
.qr .th-reply-target-close { position:absolute; top:50%; right:9px; width:22px; height:22px; transform:translateY(-50%);
display:inline-flex; align-items:center; justify-content:center; appearance:none; border:0; border-radius:6px;
background:transparent; color:var(--muted-2); padding:0; font-size:15px; font-weight:400; cursor:pointer; }
.qr .th-reply-target-close:hover { background:color-mix(in srgb, var(--ink) 6%, transparent); color:var(--ink); }
/* Messaging-app philosophy: the composer lives at the BOTTOM of the card
even when the conversation is short — content stacks up from the
composer, spacing managed above (David). :not(.hidden) is load-bearing
(the ID-vs-.hidden law). */
#threadsView:not(.hidden) { min-height:100%; display:flex; flex-direction:column; }
#threadsView > #thDetail { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
#thDetail > .th-detail-head { flex:none; }
#thDetail > #thHistory { flex:1 1 auto; display:flex; flex-direction:column; justify-content:flex-end; }
#thHistory > * { flex:none; }
/* THE DOCK IS NEVER INSIDE THE HISTORY. #thHistory holds exactly two
children — the rows and the composer — so repainting the conversation
cannot tear down the textarea the caret is sitting in. #thRows repeats
the column so every row keeps the flex-item geometry it had when the
rows were direct children (block flow would collapse their margins). */
#thRows { display:flex; flex-direction:column; }
#thRows > * { flex:none; }
/* Expanded chat (v1): the conversation borrows the reader's frame; content
keeps a centered measure. The devotional pass is queued. */
.th-expand { appearance:none; background:transparent; border:1px solid var(--hair); border-radius:var(--r-pill); cursor:pointer; color:var(--muted); font-family:var(--sans); font-size:11px; font-weight:600; padding:4px 11px; display:flex; align-items:center; gap:6px; flex:0 0 auto; transition:color var(--t-fast) var(--settle), border-color var(--t-fast) var(--settle); }
.th-expand .xg { font-size:13px; line-height:1; }
.th-expand:hover { color:var(--ink); border-color:var(--muted-3); }
/* THE SPLIT (David's law): expanded Chat is Slack-shaped — a rail of
conversations on the left, the room taking the rest. The rail replaces
the list while a room is open at the big frame. */
#threadsView.chat-max:not(.hidden) {
flex-direction:row; height:100%; min-height:0; overflow:hidden;
}
.chat-rail { display:none; }
#threadsView.chat-max .chat-rail { display:flex; flex-direction:column; width:182px; height:100%; min-height:0; flex:0 0 auto;
border-right:1px solid var(--hair); overflow-y:auto; overflow-x:hidden; padding-top:4px;
/* This rail is rebuilt as unread state changes. Chromium's scroll
anchoring otherwise follows the bottom Your people row and can leave
every conversation above the viewport after a room opens. */
overflow-anchor:none; }
#threadsView.chat-max #thDetail { flex:1 1 auto; min-width:0; height:100%; margin:0; max-width:none; overflow-y:auto; }
.rl-h { font-family:var(--sans); font-size:10px; font-weight:600; letter-spacing:.02em; color:var(--muted-2); padding:10px 14px 5px; }
.rl-row { display:flex; align-items:center; gap:8px; padding:6px 14px; cursor:pointer; border:0; background:transparent;
font-family:var(--sans); font-size:12px; color:var(--ink-2); min-width:0; width:100%; text-align:left;
transition:background-color var(--t-fast) var(--settle); }
.rl-row:hover { background:var(--wash); }
.rl-row.on { background:var(--accent-soft); color:var(--accent); font-weight:600; }
.rl-row .avatar { width:18px; height:18px; font-size:7px; flex:0 0 auto; margin-top:0; }
.rl-row .nm { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
.rl-row .ct { margin-left:auto; flex:0 0 auto; font-size:10px; font-weight:600; background:var(--accent); color:var(--on-accent); border-radius:999px; padding:2px 6px; }
.chat-rail .people-row { margin-top:auto; padding:11px 14px; }
.th-msg.text .th-msg-title { font-family:var(--sans); font-size:13px; line-height:1.5; color:var(--ink-2, var(--ink)); white-space:pre-wrap; }
.th-msg.text.unread .th-msg-title { color:var(--ink); font-weight:600; }
.runs-in { margin:8px 16px 0; font-family:var(--sans); font-size:10.5px; color:var(--muted-2); }
.runs-in button { appearance:none; -webkit-appearance:none; border:0; background:transparent; padding:0; margin:0; cursor:pointer; font-family:var(--sans); font-size:10.5px; font-weight:600; color:var(--accent); }
.runs-in.sub { margin-top:3px; color:var(--muted-3); }
/* Inline dock: the same component unfolded inside a bubble or board row —
no sticky page-bottom chrome, full row width in the bubble's flex flow. */
.ta-dock.inline { position:static; box-shadow:none; background:transparent; padding:2px 0 4px; margin:4px 0 0; flex-basis:100%; }
.ta-dock.inline .qr { margin:8px 0 0; background:var(--bg); }
.pp-row { display:flex; align-items:center; gap:8px; padding:6px 16px; }
.pp-label { flex:1 1 auto; font-family:var(--sans); font-size:12.5px; color:var(--ink); }
/* Sent-row read receipt chip: "read" sits in a light-green enclosure, "unread" stays quiet. */
.sent-chip {
flex:0 0 auto; display:inline-flex; align-items:center; align-self:center;
font-size:10px; font-weight:600; letter-spacing:.03em; line-height:1;
padding:3px 7px; border-radius:999px;
}
.sent-chip.read { background:var(--ok-soft); color:var(--ok); border:1px solid color-mix(in srgb, var(--ok) 28%, transparent); }
.sent-chip.unread { background:transparent; color:var(--muted-2); border:1px solid var(--edge); }
/* ---- Settings tab ---- */
/* The account block is flat content on the sheet, like rows: serif identity,
sans metadata, a tinted avatar disc. Depth only ever comes from tint and
the reused .open-actions light-built panel — never from borders. */
.sv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
.sv-title { font-family:var(--serif); font-size:15px; font-weight:500; color:var(--ink); }
/* First element in the pane now (no header above), so it owns the top inset. */
.sv-account { display:flex; align-items:center; gap:12px; padding:16px 16px 0; }
.sv-avatar {
flex:0 0 auto; width:38px; height:38px; border-radius:50%;
background:var(--accent-soft); color:var(--accent);
font-size:13px; font-weight:600; letter-spacing:.02em;
display:flex; align-items:center; justify-content:center;
}
.sv-id { min-width:0; display:flex; flex-direction:column; }
.sv-name { font-family:var(--serif); font-size:15px; font-weight:500; color:var(--ink); line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sv-email { font-size:12px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sv-device { font-size:11px; color:var(--muted-2); margin-top:2px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sv-copy { padding:2px 16px 0; font-size:12px; line-height:1.5; color:var(--muted); }
.sv-actions { margin:14px 12px 0; }
/* armed sign-out: the same flat menu row, flipped to the danger ink */
.oa-item.sv-signout-armed { color:var(--danger); }
.oa-item.sv-signout-armed:hover { background:var(--danger-soft); }
.oa-item.sv-signout-armed:active { background:color-mix(in srgb, var(--danger) 13%, transparent); }
/* Switching accounts is one short task. Give the code its own obvious card
and one rounded cell per character so it cannot be mistaken for a generic field. */
.sv-pair { display:flex; flex-direction:column; gap:10px; margin:12px 16px 0; padding:14px; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.sv-pair-title { font-size:15px; font-weight:650; color:var(--ink); }
.sv-pair-copy { margin-top:-4px; font-size:11.5px; line-height:1.4; color:var(--muted); }
.sv-code-cells { display:grid; grid-template-columns:repeat(8,minmax(0,1fr)); gap:5px; }
.sv-code-cell {
width:100%; min-width:0; height:42px; appearance:none; padding:0;
border:1px solid var(--edge); border-radius:10px;
font-family:var(--mono); font-size:17px; font-weight:650; text-align:center; text-transform:uppercase;
color:var(--ink); background:var(--bg); outline:none;
transition:border-color var(--t-fast) var(--settle), box-shadow var(--t-fast) var(--settle);
}
.sv-code-cell:focus { border-color:var(--edge-accent); box-shadow:var(--focus-ring); }
.sv-connect {
width:100%; appearance:none; border:0; background:var(--accent); color:var(--on-accent);
font-family:var(--sans); font-size:13px; font-weight:600; padding:10px 15px; border-radius:var(--r-2);
cursor:pointer; transition:opacity var(--t-base) var(--settle);
}
.sv-connect:hover { opacity:.9; }
.sv-connect:disabled { opacity:.4; cursor:default; }
.sv-hint { padding:8px 16px 0; font-size:11px; line-height:1.45; color:var(--muted-2); }
.sv-err { margin-top:8px; padding:0 16px; }
.sv-note { padding:10px 16px 0; font-size:12px; color:var(--ok); line-height:1.4; }
.sv-version { padding:20px 16px 12px; font-family:var(--mono); font-size:10px; color:var(--muted-3); }
.sv-slack-section { margin:24px 12px 0; }
.sv-slack-card { overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); transition:box-shadow var(--t-base) var(--settle); }
.sv-slack-card.is-confirming { box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--edge) 78%,var(--muted-3)), 0 8px 22px color-mix(in srgb, var(--ink) 7%, transparent); }
.sv-slack-row { display:flex; align-items:center; gap:11px; min-height:62px; padding:12px; }
.sv-slack-row + .sv-slack-row { border-top:1px solid var(--edge); }
.sv-slack-logo { flex:0 0 auto; width:34px; height:34px; object-fit:contain; }
.sv-slack-copy { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; }
.sv-slack-name { font:600 13px/1.25 var(--sans); color:var(--ink); }
.sv-slack-note { margin-top:3px; font:11px/1.4 var(--sans); color:var(--muted-2); }
.sv-slack-status { flex:0 0 auto; font:600 10px/1 var(--sans); color:var(--ok); }
.sv-slack-action { flex:0 0 auto; appearance:none; border:1px solid var(--edge); border-radius:9px; padding:7px 10px; background:var(--bg); color:var(--ink); font:600 11px/1 var(--sans); cursor:pointer; }
.sv-slack-action:hover { border-color:var(--muted-3); }
.sv-slack-action:disabled { opacity:.45; cursor:default; }
.sv-slack-foot { padding:10px 12px; border-top:1px solid var(--edge); background:color-mix(in srgb,var(--wash) 62%,transparent); font:10.5px/1.45 var(--sans); color:var(--muted-2); }
.sv-slack-dialog-copy { margin-top:9px; color:var(--muted); font:12px/1.5 var(--sans); }
/* The Slack tab's disconnected face borrows the onboarding chapter's
hierarchy, but it is ordinary product chrome now: no step count, finish
action or "Not now" escape. The only action is to connect Slack. */
.slack-connect { padding:28px 16px 24px; }
.slack-connect-kicker { margin:0; color:var(--muted); font:600 10px/1.2 var(--mono); letter-spacing:.14em; text-transform:uppercase; }
.slack-connect-title { margin:9px 0 0; max-width:290px; color:var(--ink); font:400 28px/1.08 var(--serif); letter-spacing:-.018em; }
.slack-connect-copy { margin:12px 0 0; max-width:300px; color:var(--muted); font:400 13px/1.52 var(--sans); }
.slack-connect-card { margin-top:21px; overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.slack-connect-row { min-height:66px; padding:12px; display:flex; align-items:center; gap:11px; }
.slack-connect-row + .slack-connect-row { border-top:1px solid var(--edge); }
.slack-connect-icon { width:38px; height:38px; flex:0 0 auto; display:grid; place-items:center; border-radius:11px; background:var(--wash); }
.slack-connect-icon img { width:28px; height:28px; object-fit:contain; }
.slack-connect-avatar { width:38px; height:38px; flex:0 0 auto; display:grid; place-items:center; border-radius:50%; background:var(--wash-2); color:var(--ink-soft); font:650 12px/1 var(--sans); }
.slack-connect-row-copy { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; }
.slack-connect-row-name { color:var(--ink); font:600 13px/1.25 var(--sans); }
.slack-connect-row-note { margin-top:3px; color:var(--muted-2); font:400 10.5px/1.38 var(--sans); }
.slack-connect-action { margin-top:18px; }
.slack-connect-foot { margin:14px 10px 0; color:var(--muted-3); font:400 10.5px/1.45 var(--sans); text-align:center; }
.slack-connect .row-err { margin-top:11px; padding:0; }
.sv-slack-confirm { display:grid; grid-template-rows:0fr; border-top:1px solid transparent; background:color-mix(in srgb,var(--wash) 38%,var(--raised)); opacity:0; transition:grid-template-rows 280ms var(--settle), border-color var(--t-base) var(--settle), opacity var(--t-base) var(--settle); }
.sv-slack-card.is-confirming .sv-slack-confirm { grid-template-rows:1fr; border-top-color:var(--edge); opacity:1; }
.sv-slack-confirm-clip { min-height:0; overflow:hidden; visibility:hidden; transition:visibility 0s linear 280ms; }
.sv-slack-card.is-confirming .sv-slack-confirm-clip { visibility:visible; transition-delay:0s; }
.sv-slack-confirm-content { display:flex; flex-direction:column; gap:12px; padding:12px; transform:translateY(-6px); transition:transform 280ms var(--settle); }
.sv-slack-card.is-confirming .sv-slack-confirm-content { transform:translateY(0); }
.sv-slack-confirm-title { color:var(--ink); font:600 12.5px/1.3 var(--sans); }
.sv-slack-confirm-copy { max-width:310px; margin-top:4px; color:var(--muted-2); font:11px/1.45 var(--sans); }
.sv-slack-confirm-actions { display:flex; justify-content:flex-end; gap:7px; }
.sv-slack-confirm-danger { border-color:color-mix(in srgb,var(--danger) 82%,var(--edge)); background:var(--danger); color:white; }
.sv-slack-confirm-danger:hover { border-color:var(--danger); background:color-mix(in srgb,var(--danger) 90%,black); }
@media (prefers-reduced-motion:reduce) {
.sv-slack-card, .sv-slack-confirm, .sv-slack-confirm-clip, .sv-slack-confirm-content { transition-duration:0.01ms; }
}
.sv-update { display:flex; flex-direction:column; gap:8px; align-items:flex-start; }
.sv-setup-link { appearance:none; border:0; padding:0; background:transparent; color:var(--ink); font:inherit; cursor:pointer; text-decoration:underline; text-decoration-color:color-mix(in srgb, var(--ink) 38%, transparent); text-underline-offset:2px; }
.sv-update-btn { width:auto; padding:7px 12px; border-radius:var(--r-2); font-family:var(--sans); font-size:12px; }
.sv-device-section { margin:24px 12px 0; }
.sv-device-list { overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.sv-device-row { display:flex; align-items:center; gap:10px; min-height:58px; padding:11px 12px; }
.sv-device-row + .sv-device-row { border-top:1px solid var(--edge); }
.sv-device-shield { flex:0 0 auto; width:28px; height:28px; border-radius:50%; display:grid; place-items:center; background:var(--accent-soft); color:var(--accent); font-size:14px; }
.sv-device-copy { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; }
.sv-device-name { font-size:13px; font-weight:600; color:var(--ink); }
.sv-device-note { margin-top:3px; font-size:11px; line-height:1.35; color:var(--muted-2); }
.sv-device-approve { appearance:none; border:1px solid color-mix(in srgb, var(--accent) 32%, var(--edge)); border-radius:999px; padding:6px 10px; background:var(--accent-soft); color:var(--accent); font:600 11px/1 var(--sans); cursor:pointer; }
.sv-device-approve:disabled { opacity:.45; cursor:default; }
.sv-provider-section { margin:24px 12px 0; }
.sv-provider-list { overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.sv-provider { padding:12px; }
.sv-provider + .sv-provider { border-top:1px solid var(--edge); }
.sv-provider-list.sv-agent-provider-list { background:var(--raised); }
.sv-agent-provider-list .sv-provider { padding:0; }
.sv-agent-provider-list .sv-provider + .sv-provider { border-top:1px solid var(--edge); }
.sv-agent-provider-toggle {
appearance:none; width:100%; min-height:64px; padding:12px; border:0; background:transparent; color:inherit;
display:grid; grid-template-columns:28px minmax(0,1fr) auto 14px; align-items:center; gap:10px;
font-family:var(--sans); text-align:left; cursor:pointer; transition:background-color var(--t-fast) var(--settle);
}
.sv-agent-provider-toggle:hover { background:var(--wash); }
.sv-agent-provider-logo { width:28px; height:28px; border-radius:7px; object-fit:contain; }
.sv-agent-provider-summary { min-width:0; display:flex; flex-direction:column; }
.sv-agent-provider-count { margin-top:3px; color:var(--muted-2); font-size:11px; line-height:1.2; }
.sv-agent-provider-ready { display:inline-flex; align-items:center; gap:5px; color:var(--ok); font:600 10px/1 var(--sans); white-space:nowrap; }
.sv-agent-provider-ready::before { content:""; width:5px; height:5px; border-radius:50%; background:currentColor; }
.sv-agent-provider-state { color:var(--muted-2); font:600 10px/1 var(--sans); white-space:nowrap; }
.sv-agent-provider-state.available { color:var(--accent); }
.sv-agent-provider-state.busy { color:var(--accent); }
.sv-agent-provider-chev { color:var(--muted-3); transition:transform var(--t-base) var(--settle); }
.sv-agent-provider-toggle[aria-expanded="true"] .sv-agent-provider-chev { transform:rotate(180deg); }
.sv-agent-provider-detail { display:grid; grid-template-rows:0fr; transition:grid-template-rows var(--t-base) var(--soft); }
.sv-agent-provider-detail.open { grid-template-rows:1fr; }
.sv-agent-provider-detail-inner { min-height:0; overflow:hidden; }
.sv-agent-provider-detail-body { margin:0 12px 13px 50px; padding-top:11px; border-top:1px solid var(--hair); }
.sv-agent-provider-note { margin-bottom:11px; color:var(--muted-2); font-size:10.5px; line-height:1.4; }
.sv-agent-provider-note.success { color:var(--ok); }
.sv-agent-provider-note.error { color:var(--danger); }
.sv-agent-provider-group + .sv-agent-provider-group { margin-top:12px; }
.sv-agent-provider-group-head { display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:7px; color:var(--muted-2); font:600 10px/1.25 var(--sans); }
.sv-agent-provider-integrations { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px 12px; margin:0; padding:0; list-style:none; }
.sv-agent-provider-integrations li { min-width:0; overflow:hidden; color:var(--muted); font:500 10px/1.25 var(--sans); text-overflow:ellipsis; white-space:nowrap; }
.sv-agent-provider-integrations li::before { content:"·"; margin-right:5px; color:var(--muted-3); }
.sv-agent-provider-actions { display:flex; gap:7px; justify-content:flex-end; margin-top:12px; }
.sv-provider-name { font-size:13px; font-weight:600; color:var(--ink); }
.sv-provider-btn { appearance:none; border:1px solid var(--edge); background:transparent; color:var(--muted); border-radius:999px; padding:5px 10px; font:600 11px/1 var(--sans); cursor:pointer; }
.sv-provider-btn.primary { border-color:color-mix(in srgb, var(--accent) 35%, var(--edge)); background:var(--accent-soft); color:var(--accent); }
.sv-provider-btn:hover { color:var(--ink); border-color:var(--edge-strong); }
.sv-provider-btn:disabled { opacity:.4; cursor:default; }
@media (prefers-reduced-motion:reduce) { .sv-agent-provider-detail, .sv-agent-provider-chev { transition:none; } }
/* ---- Quiet mode: the two durable on/off preferences ----
A real switch, not the Enable/Disable label-flip the provider rows use: these
are settings someone leaves ON for weeks, and a switch reads its own state at a
glance. Same enclosure as .sv-provider-list so the pane stays one material. */
.sv-quiet-section { margin:24px 12px 0; }
.sv-quiet-title { margin-bottom:10px; }
.sv-quiet-list { overflow:hidden; border-radius:var(--r-3); background:var(--raised); box-shadow:inset 0 0 0 1px var(--edge); }
.sv-quiet-row { display:flex; align-items:flex-start; gap:12px; padding:12px; }
.sv-quiet-row + .sv-quiet-row { border-top:1px solid var(--edge); }
.sv-quiet-copy { min-width:0; flex:1 1 auto; display:flex; flex-direction:column; }
.sv-quiet-name { font-size:13px; font-weight:600; color:var(--ink); }
.sv-quiet-note { margin-top:3px; font-size:11px; line-height:1.4; color:var(--muted-2); }
.sv-switch {
appearance:none; -webkit-appearance:none; border:0; cursor:pointer; flex:0 0 auto;
position:relative; width:34px; height:20px; margin-top:1px; padding:0; border-radius:999px;
background:color-mix(in srgb, var(--ink) 14%, transparent);
transition:background-color var(--t-base) var(--settle);
}
.sv-switch::after {
content:""; position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%;
background:var(--bg); box-shadow:0 1px 2px rgba(0,0,0,.25);
transition:transform var(--t-base) var(--settle);
}
.sv-switch[aria-checked="true"] { background:var(--accent); }
.sv-switch[aria-checked="true"]::after { transform:translateX(14px); }
.sv-switch:focus-visible { outline:none; box-shadow:var(--focus-ring); }
.sv-switch:disabled { opacity:.4; cursor:default; }
@media (prefers-reduced-motion:reduce) { .sv-switch, .sv-switch::after { transition:none; } }
/* signed-out call-to-action on the Relays empty state — quiet filled pill (th-count language) */
.sv-signin-banner {
appearance:none; border:0; cursor:pointer; margin-top:2px;
font-family:var(--sans); font-size:12px; font-weight:600; letter-spacing:.01em;
color:var(--accent); background:color-mix(in srgb, var(--accent) 9%, transparent);
box-shadow:inset 0 0 0 0.5px color-mix(in srgb, var(--accent) 14%, transparent);
border-radius:999px; padding:7px 15px;
transition:background-color var(--t-base) var(--settle);
}
.sv-signin-banner:hover { background:color-mix(in srgb, var(--accent) 15%, transparent); }
/* ---- first run -------------------------------------------------------
The agent has already installed the app. Account setup stays inside this
card; provider-owned sign-in and connector pages may leave for the browser.
A final skippable chat setup page follows account connection, then the
ordinary inbox paints Shane's real Welcome relay. */
.card.signup .tabs, .card.signup .scroll, .card.signup .minimize-btn, .card.signup .count { display:none; }
/* First-run content is taller than the folded pill. If it remains in layout,
the centered body overflows upward across the lockup and can intercept the
very click that should reopen Relay. The compact face owns the whole 44px
hit area; reveal signup again as the card expands. */
.card.collapsed .signup-view { display:none; }
.signup-view { flex:1 1 auto; min-height:0; padding:14px 22px 24px; display:flex; flex-direction:column; }
.su-body { flex:1 1 auto; min-height:0; display:flex; flex-direction:column; justify-content:center; }
.su-step { color:var(--muted); font-size:10px; font-weight:600; letter-spacing:.14em; text-transform:uppercase; }
.su-title { margin:7px 0 0; font-family:var(--serif); font-size:27px; font-weight:400; line-height:1.08; letter-spacing:-.015em; color:var(--ink); }
.su-copy { margin:10px 0 0; max-width:285px; color:var(--muted); font-size:13px; line-height:1.52; }
.su-form { margin-top:22px; display:grid; gap:11px; }
.su-label { display:grid; gap:6px; color:var(--ink); font-size:12px; font-weight:600; }
.su-input {
appearance:none; width:100%; min-height:42px; border:1px solid var(--edge); border-radius:var(--r-2);
padding:0 12px; color:var(--ink); background:var(--bg); font:400 14px/1 var(--sans); outline:none;
}
.su-input::placeholder { color:var(--muted-3); }
.su-input:focus { border-color:var(--edge-accent); box-shadow:var(--focus-ring); }
.su-code { font-family:var(--mono); font-size:20px; font-weight:500; letter-spacing:.28em; text-align:center; }
.su-primary, .su-secondary, .su-link {
appearance:none; border:0; cursor:pointer; min-height:42px; border-radius:var(--r-2);
padding:0 14px; font:600 13px/1 var(--sans);
}
.su-primary { color:var(--on-accent); background:var(--accent); }
.su-primary:hover { filter:brightness(.96); }
.su-primary:disabled { cursor:default; opacity:.5; }
.su-secondary { border:1px solid var(--edge); color:var(--ink); background:transparent; }
.su-secondary:hover { background:var(--wash); }
.su-link { min-height:32px; padding:0; color:var(--muted); background:transparent; text-decoration:underline; text-decoration-color:var(--edge); text-underline-offset:3px; }
.su-divider { display:flex; align-items:center; gap:10px; color:var(--muted-3); font-size:10px; text-transform:uppercase; letter-spacing:.12em; }
.su-divider::before, .su-divider::after { content:""; flex:1 1 auto; height:1px; background:var(--hair); }
.su-google { display:flex; align-items:center; justify-content:center; gap:8px; }
.su-google svg { flex:none; }
.su-account { margin-top:21px; padding:14px; border:1px solid var(--hair); border-radius:var(--r-3); background:var(--wash); }
.su-account-name { display:block; font-size:13px; font-weight:600; color:var(--ink); }
.su-account-email, .su-device { display:block; margin-top:3px; font-size:11px; line-height:1.4; color:var(--muted); overflow-wrap:anywhere; }
.su-device { margin-top:10px; padding-top:10px; border-top:1px solid var(--hair); }
.su-chat-list { margin-top:17px; display:grid; gap:8px; }
.su-chat-option {
appearance:none; width:100%; min-height:56px; padding:10px 12px; border:1px solid var(--edge); border-radius:var(--r-3);
display:flex; align-items:center; gap:10px; color:var(--ink); background:var(--bg); text-align:left; cursor:pointer;
}
.su-chat-option:hover { background:var(--wash); }
.su-chat-option:disabled { cursor:default; opacity:.5; }
.su-chat-copy { flex:1 1 auto; min-width:0; display:grid; gap:3px; }
.su-chat-name { font-size:13px; font-weight:600; }
.su-chat-detail { color:var(--muted); font-size:11px; line-height:1.35; }
.su-chat-action { flex:none; color:var(--accent); font-size:11px; font-weight:600; }
.su-agent-note { margin-top:11px; padding:10px 12px; border-radius:var(--r-2); color:var(--muted); background:var(--wash); font-size:11px; line-height:1.42; }
.su-agent-note strong { display:block; margin-bottom:2px; color:var(--ink); font-weight:600; }
.su-chat-footer { margin-top:13px; }
.su-checks { margin:20px 0 0; display:grid; gap:9px; color:var(--muted); font-size:12px; }
.su-check { display:flex; align-items:center; gap:8px; }
.su-check-mark { color:var(--accent); font-weight:700; }
.su-check-active .su-check-mark { width:13px; height:13px; border:1.5px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; animation-play-state:var(--relay-loop-state); }
.su-note { margin-top:12px; min-height:17px; color:var(--muted); font-size:11px; line-height:1.45; }
.su-error { color:var(--danger); }
.su-spinner { width:24px; height:24px; margin-bottom:14px; border:2px solid var(--hair); border-top-color:var(--accent); border-radius:50%; animation:suSpin .8s linear infinite; animation-play-state:var(--relay-loop-state); }
@keyframes suSpin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion:reduce) { .su-spinner { animation:none; border-top-color:var(--hair); } }
@media (prefers-reduced-motion:reduce) { .su-check-active .su-check-mark { animation:none; border-top-color:var(--hair); } }
</style>
</head>
<body>
<svg width="0" height="0" viewBox="0 0 0 0" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="relayCodexGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C1C1FA"/>
<stop offset="16%" stop-color="#C0B1F9"/>
<stop offset="38%" stop-color="#7790F7"/>
<stop offset="58%" stop-color="#5F74F6"/>
<stop offset="82%" stop-color="#342CF5"/>
<stop offset="100%" stop-color="#3229F5"/>
</linearGradient>
</defs>
</svg>
<div class="card" id="card">
<div class="card-surface">
<div class="lockup" id="lockup">
<svg class="mark" width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/>
<rect x="6" y="6" width="4" height="4"/>
<rect x="12" y="6" width="4" height="4"/>
</svg>
<span class="word">relay</span>
<span class="spacer"></span>
<button class="winbtn theme-toggle" id="themeToggle" type="button" aria-label="Switch between light and dark mode" title="Light / dark">
<svg class="moon" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg>
<svg class="sun" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
</button>
<button class="winbtn minimize-btn" id="minimizeBtn" type="button" aria-label="Minimize" title="Minimize">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1.2 5h7.6"/></svg>
</button>
<span class="count zero" id="count">0</span>
<button class="close-x" id="closeX" type="button" aria-label="Hide Relay" title="Hide Relay - reopen from the Relay icon">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1 1l8 8M9 1L1 9"/></svg>
</button>
</div>
<section class="signup-view gone" id="signupView" aria-live="polite">
<div class="su-body" id="signupBody"></div>
</section>
<nav class="tabs" aria-label="Relay views">
<button class="tab active" type="button" data-view="relays">
Relays <span class="tab-badge gone" id="relaysBadge">0</span>
</button>
<button class="tab" type="button" data-view="tasks">Tasks <span class="tab-badge amber gone" id="tasksBadge">0</span></button>
<button class="tab" type="button" data-view="slack">
Slack <span class="tab-badge gone" id="slackBadge">0</span>
</button>
<button class="tab" type="button" data-view="contacts">People</button>
<button class="tab" type="button" data-view="settings">Settings</button>
<button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
</nav>
<div class="scroll" id="scroll">
<!-- Relays view -->
<section class="view" id="relaysView">
<div id="relaysList"></div>
<div id="relaysEmpty" class="empty gone">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No relays yet</div>
<div class="t2">Questions, results, and notices will land here.</div>
<button class="sv-signin-banner gone" id="signInBanner" type="button">Sign in to Relay</button>
</div>
</section>
<!-- Slack view: the same room index, projected only from exact active
Slack integration bindings. Relay-only contacts never enter it. -->
<section class="view hidden" id="slackView">
<div id="slackList"></div>
<div id="slackEmpty" class="empty gone">
<img src="slackMark.png" alt="" width="22" height="22" />
<div class="t1">No Slack conversations yet</div>
<div class="t2">Linked Slack channels and messages will appear here.</div>
</div>
</section>
<!-- Chat view: every conversation — groups and people — with the
"Your people" row (contacts management, a pushed pane) at the end. -->
<section class="view hidden" id="chatView">
<div class="chat-head">
<button class="th-expand" type="button" id="chatExpandBtn" aria-label="Expand chat"><span class="xg">⤢</span><span class="xl">Expand</span></button>
</div>
<div id="chatList"></div>
<div id="chatEmpty" class="empty gone">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No conversations yet</div>
<div class="t2">Channels and people you talk to will gather here.</div>
</div>
</section>
<!-- Threads view: conversations grouped by thread, swapping to one thread's messages -->
<section class="view hidden" id="threadsView">
<div id="threadsList"></div>
<div id="threadsEmpty" class="empty gone">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No conversations yet</div>
<div class="t2">Messages with people and channels appear here.</div>
</div>
<div id="chatRail" class="chat-rail"></div>
<div id="thDetail" class="gone">
<div class="th-detail-head th-bar-sticky">
<button class="th-back" type="button" id="thBack" aria-label="Back">
<svg width="8" height="13" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7 1L1 7l6 6"/></svg>
</button>
<span class="th-detail-name" id="thDetailName"></span>
<span class="th-detail-meta" id="thDetailMeta"></span>
<button class="th-info hidden" type="button" id="thGroupInfo">See info</button>
<button class="th-info th-slack-toggle hidden" type="button" id="thSlackVisibility" aria-pressed="false"><img src="slackMark.png" alt="" /><span id="thSlackVisibilityLabel">Show Slack</span></button>
<button class="th-expand" type="button" id="thExpand" aria-label="Expand the conversation"><span class="xg">⤢</span><span class="xl">Expand</span></button>
</div>
<div id="thHistory"></div>
</div>
</section>
<div class="group-info-backdrop hidden" id="groupInfoBackdrop" data-stop="1">
<section class="group-info-sheet" role="dialog" aria-modal="true" aria-label="Channel information">
<button type="button" class="group-info-close" id="groupInfoClose" aria-label="Close channel info">×</button>
<div id="groupInfoDetail"></div>
</section>
</div>
<!-- Sent view -->
<section class="view hidden" id="sentView">
<div id="sentList"></div>
<div id="sentEmpty" class="empty gone">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No sent relays yet</div>
<div class="t2">Relays you send will appear here with their delivery state.</div>
</div>
</section>
<!-- Task detail view (in-pill mirror of the web request page, live-polled) -->
<section class="view hidden" id="requestDetailView"></section>
<!-- Tasks view: the board behind the Task reader (waiting / running / parked) -->
<section class="view hidden" id="tasksView">
<div id="tasksList"></div>
<div class="empty gone" id="tasksEmpty">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No Tasks yet</div>
<div class="t2">When someone's agent sets up work for yours, it lands here for your approval.</div>
</div>
</section>
<!-- Reader view: one relay as a full page (message: reply; request: approve) -->
<section class="view hidden" id="readerView">
<div id="readerBody"></div>
</section>
<!-- Contacts view -->
<section class="view hidden" id="contactsView">
<div class="cv-head">
<!-- One island, two lists. Groups used to sit below every contact, so a
long book hid them completely; stacking them the other way would just
bury people instead. A segment switches the pane and keeps both a
single tap away, with counts so neither looks empty when it isn't. -->
<div class="cv-seg" role="tablist" aria-label="People and channels">
<button class="cv-seg-btn active" type="button" role="tab" data-pane="people" id="cvSegPeople">People <span class="cv-seg-n" id="cvSegPeopleN"></span></button>
<button class="cv-seg-btn" type="button" role="tab" data-pane="groups" id="cvSegGroups">Channels <span class="cv-seg-n" id="cvSegGroupsN"></span></button>
</div>
<button class="cv-add" type="button" id="cvAdd" aria-label="Add person">+ Add</button>
<button class="cv-add gone" type="button" id="cvgNew" aria-label="Add channel">+ Add</button>
</div>
<form class="cv-form hidden" id="cvForm">
<div class="cv-row2">
<input class="cv-input" id="cvFirst" placeholder="First name" autocomplete="off" spellcheck="false" />
<input class="cv-input" id="cvLast" placeholder="Surname" autocomplete="off" spellcheck="false" />
</div>
<div class="cv-flabel">
<span class="l">Emails</span>
<span class="h">One or more — the identity</span>
</div>
<div class="cv-emails" id="cvEmails">
<input class="cv-emails-input" id="cvEmail" type="email" placeholder="name@company.com" autocomplete="off" spellcheck="false" />
</div>
<div class="cv-suggest hidden" id="cvSuggest"></div>
<div class="cv-hint" id="cvHint"></div>
<div class="cv-error" id="cvError"></div>
<div class="cv-form-actions">
<button class="cv-delete gone" type="button" id="cvDelete">Delete</button>
<button class="cv-cancel" type="button" id="cvCancel">Cancel</button>
<button class="cv-save" type="submit" id="cvSave">Save</button>
</div>
</form>
<!-- Contact groups: saved rosters. A relay to a group fans out to every
member under ONE shared thread; replies reach everyone. -->
<div class="cvg" id="cvGroups">
<form class="cvg-newform hidden" id="cvgNewForm">
<input class="cv-input" id="cvgNewName" placeholder="Channel name" autocomplete="off" spellcheck="false" />
<button class="cv-save" type="submit">Create</button>
<button class="cv-cancel" type="button" id="cvgNewCancel">Cancel</button>
</form>
<div class="cvg-error" id="cvgError"></div>
<section class="cvg-detail gone" id="cvgDetail" aria-live="polite"></section>
<div id="cvgList"></div>
<div class="cv-empty" id="cvgEmpty">No channels yet. A channel relays to everyone in it as one conversation.</div>
</div>
<div class="cvg-error" id="cvPeopleError"></div>
<div class="cv-list" id="cvList"></div>
<div id="contactsEmpty" class="empty gone">
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
</svg>
<div class="t1">No contacts yet</div>
<div class="t2">Add someone, or your agent saves them as you relay.</div>
</div>
</section>
<!-- Settings view (account card + sign-out / switch-account; rendered by JS) -->
<section class="view hidden" id="settingsView"></section>
</div>
</div>
</div>
</div>
<script src="./work-ui.js"></script>
<script src="./hover-intent.js"></script>
<script src="./read-receipts.cjs"></script>
<script>
// Theme, two layers (Sven, round 5): the pill FOLLOWS the OS appearance by
// default; the moon/sun button is the override — an explicit pick persists
// and the system stops mattering from then on.
// Dark is Relay's default face (David, 2026-08-13). The sun button is the
// override — an explicit pick persists; without one the pill is dark
// regardless of the OS appearance.
try {
// Dark became the product default in 0.1.158. A profile that stored "light"
// BEFORE that (the old system-follow wrote one on every OS change) would
// keep overriding it forever — David's pill came back light. One-time
// migration: drop a pre-default preference, then honour explicit picks.
if (!localStorage.getItem("relayThemeDefaultDark")) {
localStorage.removeItem("relayTheme");
localStorage.setItem("relayThemeDefaultDark", "1");
}
const stored = localStorage.getItem("relayTheme");
const dark = stored ? stored === "dark" : true;
if (dark) document.documentElement.dataset.theme = "dark";
} catch { document.documentElement.dataset.theme = "dark"; }
// Main mirrors the resolved sheet so the preview window opens on the same one.
window.addEventListener("DOMContentLoaded", () => {
try { window.relay.setTheme && window.relay.setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light"); } catch {}
});
// Dark is the default regardless of OS appearance; only the explicit
// toggle changes the sheet, so no system-appearance listener remains.
const cardEl = document.getElementById("card");
const lockupEl = document.getElementById("lockup");
const closeXEl = document.getElementById("closeX");
const countEl = document.getElementById("count");
const tabEls = [...document.querySelectorAll(".tab")];
const scrollEl = document.getElementById("scroll");
const relaysViewEl = document.getElementById("relaysView");
const relaysListEl = document.getElementById("relaysList");
const relaysEmptyEl = document.getElementById("relaysEmpty");
const relaysBadgeEl = document.getElementById("relaysBadge");
const slackViewEl = document.getElementById("slackView");
const slackListEl = document.getElementById("slackList");
const slackEmptyEl = document.getElementById("slackEmpty");
const slackBadgeEl = document.getElementById("slackBadge");
const chatViewEl = document.getElementById("chatView");
const chatListEl = document.getElementById("chatList");
const chatEmptyEl = document.getElementById("chatEmpty");
const markAllReadEl = document.getElementById("markAllRead");
const sentViewEl = document.getElementById("sentView");
const tasksViewEl = document.getElementById("tasksView");
const tasksListEl = document.getElementById("tasksList");
const tasksEmptyEl = document.getElementById("tasksEmpty");
const tasksBadgeEl = document.getElementById("tasksBadge");
const readerViewEl = document.getElementById("readerView");
const readerBodyEl = document.getElementById("readerBody");
const sentListEl = document.getElementById("sentList");
const sentEmptyEl = document.getElementById("sentEmpty");
const requestDetailViewEl = document.getElementById("requestDetailView");
const contactsViewEl = document.getElementById("contactsView");
const cvAddEl = document.getElementById("cvAdd");
const cvListEl = document.getElementById("cvList");
const contactsEmptyEl = document.getElementById("contactsEmpty");
const cvFormEl = document.getElementById("cvForm");
const cvFirstEl = document.getElementById("cvFirst");
const cvLastEl = document.getElementById("cvLast");
const cvEmailEl = document.getElementById("cvEmail"); // the draft <input> inside the chip field
const cvEmailsEl = document.getElementById("cvEmails"); // the bordered chip container (click -> focus)
const cvSuggestEl = document.getElementById("cvSuggest"); // domain autocomplete dropdown
const cvHintEl = document.getElementById("cvHint"); // "a relay goes to whichever address…" hint
const cvErrorEl = document.getElementById("cvError");
// The people LIST's own error slot. #cvError lives inside the card, which is
// shut on the path that opens a room, so a refusal there would be invisible.
const cvPeopleErrorEl = document.getElementById("cvPeopleError");
let cvOpeningKey = null; // the contact whose room is being resolved right now
let cvOpenGeneration = 0; // every later contact/navigation invalidates an older resolve
const cvSaveEl = document.getElementById("cvSave");
const cvCancelEl = document.getElementById("cvCancel");
const cvDeleteEl = document.getElementById("cvDelete");
const threadsViewEl = document.getElementById("threadsView");
const threadsListEl = document.getElementById("threadsList");
const threadsEmptyEl = document.getElementById("threadsEmpty");
const thDetailEl = document.getElementById("thDetail");
const thBackEl = document.getElementById("thBack");
const thDetailNameEl = document.getElementById("thDetailName");
const thDetailMetaEl = document.getElementById("thDetailMeta");
const thGroupInfoEl = document.getElementById("thGroupInfo");
const thSlackVisibilityEl = document.getElementById("thSlackVisibility");
const thSlackVisibilityLabelEl = document.getElementById("thSlackVisibilityLabel");
function setThreadHeader(name, _count) {
// The header's right side belongs to the EXPAND affordance, not a relay
// count — nobody cares how many relays a chat holds (David). The second
// argument is accepted and ignored so main's call sites keep working.
thDetailNameEl.textContent = name || "Conversation";
thDetailMetaEl.textContent = "";
const room = threadDetailId ? chatRoomForThread(threadDetailId) : null;
// See info is a GROUP door and lives in the EXPANDED frame only (Sven): at
// pill width it crushed the room name and wrapped; and isGroup alone can
// be INFERRED from legacy name-variant rows, putting it on what reads as a
// 1:1 — where the roster sheet could only error. Named groups, split only.
thGroupInfoEl.classList.toggle("hidden",
!(chatExpanded && room && room.isGroup && (room.groupName || room.groupId)));
syncSlackVisibilityButton(room);
}
const thHistoryEl = document.getElementById("thHistory");
const relayHostHoverIntent = window.RelayHoverIntent.installRelayHoverIntent({ document, window });
const settingsViewEl = document.getElementById("settingsView");
const signInBannerEl = document.getElementById("signInBanner");
const signupViewEl = document.getElementById("signupView");
const signupBodyEl = document.getElementById("signupBody");
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const EXPANDED = { w: 344, h: 524 };
// Tap a letter and the PILL EXPANDS INTO the reader — the old preview
// window's exact size, but one surface and one motion instead of a second
// window beside the pill (David's round). Same spring as fold/expand.
const READER = { w: 720, h: 760 };
const PILL = { w: 244, h: 44 }; // wide enough for mark + word + "N unread" + ✕
const PEEK = { w: 400, h: 118 };
// ---------- sound (warmed away from the first interaction) ----------
let actx = null, tinkBuf = null;
function initAudio() {
if (actx) return;
try { actx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: "interactive" }); } catch { return; }
Promise.resolve(window.relay.soundBytes("Tink"))
.then((ab) => (ab ? actx.decodeAudioData(ab) : null))
.then((b) => { if (b) tinkBuf = b; })
.catch(() => {});
}
function synthTink() {
if (!actx) return;
const t = actx.currentTime;
const o = actx.createOscillator(); o.type = "triangle";
o.frequency.setValueAtTime(2100, t); o.frequency.exponentialRampToValueAtTime(1300, t + 0.05);
const g = actx.createGain();
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.5, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
o.connect(g).connect(actx.destination); o.start(t); o.stop(t + 0.13);
}
// Settings → "Play sounds". Seeded false and updated from every inbox
// payload, so the very first arrival after launch is already correct; the toggle
// also sets it optimistically so the next sound is silent without a round trip.
let soundsMuted = false;
function playTink() {
if (REDUCED) return;
if (soundsMuted) return;
// A test overlay must never make noise on the developer's machine — the
// sound is indistinguishable from the real pill's and lands mid-work.
if (window.relay && window.relay.isTestOverlay) return;
// AudioContext/device startup can synchronously wake Chromium's audio
// service. Never put that cold-start work on the same pointer event as a
// card morph; prime it while idle and sacrifice one sound if the service is
// still suspended. The next interaction gets both smooth motion and sound.
if (!actx) { primeAudio(); return; }
if (actx.state === "suspended") { void actx.resume(); return; }
if (tinkBuf) {
const src = actx.createBufferSource(); src.buffer = tinkBuf;
const g = actx.createGain(); g.gain.value = 0.6;
src.connect(g).connect(actx.destination); src.start();
} else { synthTink(); }
}
let audioPrimeQueued = false;
function primeAudio() {
if (soundsMuted || actx || audioPrimeQueued) return;
audioPrimeQueued = true;
const prime = () => {
audioPrimeQueued = false;
if (readerMorphSnapshot || readerMorphInFlight || roomViewTransition || raf) {
setTimeout(primeAudio, 600);
return;
}
if (!soundsMuted && !actx) initAudio();
};
if (window.requestIdleCallback) window.requestIdleCallback(prime, { timeout: 1200 });
else setTimeout(prime, 400);
}
window.addEventListener("mousedown", () => {
window.relay.engage?.();
}, { capture: true });
// ---------- the spring (stiffness 170, damping 22, mass 1) ----------
const K = 170, C = 22, M = 1;
const W = { v: EXPANDED.w, vel: 0, t: EXPANDED.w };
const H = { v: EXPANDED.h, vel: 0, t: EXPANDED.h };
let raf = null, lastT = 0;
// Main sizes the native window to this card. A morph has two explicit phases:
// prepare grows the native surface before the first destination paint; settled
// trims it only after the renderer's spring actually finishes. A monotonic id
// makes rapid collapse/expand retargeting reject stale completions.
let interactive = false;
let lastSentCardSize = "";
let cardMotionId = 0;
// A renderer reload starts a fresh ordering domain. Without this session id,
// main could mistake the new renderer's low motion ids for stale messages.
const cardMotionSessionId = crypto.randomUUID();
// The renderer owns one visual state for the whole morph. In particular, the
// card becomes the compact face as soon as collapse starts; waiting for the
// native shrink acknowledgement left a pill-sized frame carrying the expanded
// theme/minimize controls, followed by a second paint containing the unread
// count. Main independently preserves the native top-right anchor at settled
// size, so no renderer face swap belongs at that boundary.
function publishCardSize(w, h, { phase = "prepare", motionId = cardMotionId } = {}) {
const key = motionId + ":" + phase + ":" + Math.round(w) + "x" + Math.round(h);
if (key === lastSentCardSize) return;
lastSentCardSize = key;
if (window.relay.cardSize) return window.relay.cardSize(Math.round(w), Math.round(h), {
phase,
motionId,
motionSessionId: cardMotionSessionId,
});
}
function commitSettledCardSize(w, h, motionId) {
const receipt = publishCardSize(w, h, { phase:"settled", motionId });
// Settlement is geometry-only now. Consume a teardown rejection without
// allowing the acknowledgement to schedule a second visual-state paint.
if (receipt && typeof receipt.catch === "function") receipt.catch(() => {});
return receipt;
}
function step(s, dt) {
const before = s.v - s.t;
const a = (-K * (s.v - s.t) - C * s.vel) / M;
s.vel += a * dt; s.v += s.vel * dt;
// Window geometry should feel springy but never wobble past its target.
// The previous under-damped crossing was sub-pixel in the card but rounded
// to a whole native pixel, so BrowserWindow visibly resized twice at rest.
const after = s.v - s.t;
if (before && (!after || Math.sign(before) !== Math.sign(after))) {
s.v = s.t; s.vel = 0; return false;
}
if (Math.abs(s.v - s.t) < 0.15 && Math.abs(s.vel) < 0.15) { s.v = s.t; s.vel = 0; return false; }
return true;
}
function frame(now) {
let dt = (now - lastT) / 1000; lastT = now;
if (!(dt > 0)) dt = 0.016;
dt = Math.min(dt, 0.032);
let moving = false;
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i++) { const a = step(W, h), b = step(H, h); moving = moving || a || b; }
cardEl.style.width = W.v.toFixed(2) + "px";
cardEl.style.height = H.v.toFixed(2) + "px";
if (moving) raf = requestAnimationFrame(frame);
else {
raf = null;
cardEl.style.willChange = "";
commitSettledCardSize(W.t, H.t, cardMotionId);
}
}
function springTo(w, h) {
W.t = w; H.t = h;
const motionId = ++cardMotionId;
// Publish the destination synchronously so growth is available before paint
// and a delayed animation frame cannot leave a stale native window size.
// Shrink is only committed by the settled message below.
publishCardSize(w, h, { phase:"prepare", motionId });
// The scroller's width is FIXED (containment: the height spring must not
// reflow the rows every frame) — so it must be re-fixed to each new
// TARGET, once per morph. Content lays out at the destination size
// immediately and the spring reveals it; without this, a grown card keeps
// its content in a 344px column and the new width is dead space. The
// folded pill hides .scroll entirely, so never shrink it below EXPANDED.
scrollEl.style.width = Math.max(w, EXPANDED.w) + "px";
if (REDUCED) {
W.v = w; H.v = h;
cardEl.style.width = w + "px";
cardEl.style.height = h + "px";
commitSettledCardSize(w, h, motionId);
return;
}
// will-change on width/height buys nothing (they are layout, not composited)
// and costs a layer promotion; containment on .scroll is what makes this cheap.
if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(frame); }
}
// ---------- collapse / expand / peek ----------
let collapsed = false;
// Folding the transparent window hides its content visually, but opacity and
// pointer-events do not stop animations, frame callbacks, or run polling.
// This is the single lifecycle contract for all continuous renderer work;
// finite fold/open transitions deliberately remain independent.
const continuousMotionListeners = new Set();
let surfaceRenderDeferred = false;
let surfacePaintCount = 0;
function rendererSurfaceActive() {
return document.visibilityState === "visible"
&& !collapsed
&& !cardEl.classList.contains("offstage")
&& !cardEl.classList.contains("bye");
}
function syncContinuousMotion() {
const active = rendererSurfaceActive();
const next = active ? "running" : "paused";
const changed = cardEl.dataset.continuousMotion !== next;
cardEl.dataset.continuousMotion = next;
if (changed) for (const listener of continuousMotionListeners) listener(active);
return active;
}
function reconcileSurfaceActivity() {
const active = syncContinuousMotion();
if (active) {
flushDeferredSurfaceRender();
if (slackConnectionRefreshDeferred) refreshSlackConnection({ preserveWaiting:true });
} else syncRunMirrors();
}
new MutationObserver(reconcileSurfaceActivity).observe(cardEl, { attributes:true, attributeFilter:["class"] });
document.addEventListener("visibilitychange", reconcileSurfaceActivity);
let readerOpenNow = false; // the reader holds the card at READER size
let readerMorphInFlight = false;
let readerMorphSnapshot = null;
let readerMorphDestination = null;
let readerMorphMotion = "morph";
let readerMorphRaf = null;
let readerMorphTimer = null;
let readerMorphDeferredPayload = null;
function syncCardSize(readerOpen) {
readerOpenNow = Boolean(readerOpen);
if (collapsed || peeking || ghost || readerMorphInFlight) return;
const t = readerOpenNow ? READER : EXPANDED;
if (W.t !== t.w || H.t !== t.h) springTo(t.w, t.h);
}
function cleanReaderMorph() {
if (readerMorphRaf) { cancelAnimationFrame(readerMorphRaf); readerMorphRaf = null; }
if (readerMorphTimer) { clearTimeout(readerMorphTimer); readerMorphTimer = null; }
if (readerMorphSnapshot) { readerMorphSnapshot.remove(); readerMorphSnapshot = null; }
if (readerMorphDestination) {
readerMorphDestination.classList.remove("reader-morph-destination");
readerMorphDestination = null;
}
scrollEl.classList.remove("reader-morph", "reader-morph-go", "reader-morph-push", "reader-morph-back");
readerMorphMotion = "morph";
readerMorphInFlight = false;
// State can change while the two frozen faces are moving (most commonly
// the first-open read receipt). Reconcile only the newest payload after the
// visual transaction is over; rebuilding the live destination mid-crossfade
// is exactly the flash this coordinator exists to prevent.
const deferredPayload = readerMorphDeferredPayload;
readerMorphDeferredPayload = null;
if (deferredPayload) queueMicrotask(() => onPayload(deferredPayload));
}
function prepareReaderMorph(sourceView, { motion = "morph" } = {}) {
cleanReaderMorph();
if (REDUCED || collapsed || peeking || ghost) return false;
const source = {
relays: relaysViewEl,
chat: chatViewEl,
threads: threadsViewEl,
sent: sentViewEl,
tasks: tasksViewEl,
requests: tasksViewEl, // compatibility alias for pre-Task view state
contacts: contactsViewEl,
}[sourceView];
if (!source || source.classList.contains("hidden")) return false;
const sourceScroll = scrollEl.scrollTop;
const snapshot = source.cloneNode(true);
// A visual snapshot is never another live view. Preserve structural ids as
// inert data for its CSS, then remove the actual ids so selectors and event
// code can only ever resolve the live destination.
const snapshotIdNodes = [snapshot, ...snapshot.querySelectorAll("[id]")];
for (const node of snapshotIdNodes) {
if (!node.id) continue;
node.dataset.readerMorphId = node.id;
node.removeAttribute("id");
}
snapshot.classList.remove("hidden");
snapshot.classList.add("reader-morph-snapshot");
snapshot.setAttribute("aria-hidden", "true");
snapshot.style.top = `${-sourceScroll}px`;
snapshot.style.width = `${Math.round(cardEl.getBoundingClientRect().width)}px`;
scrollEl.appendChild(snapshot);
// cloneNode deliberately resets element scroll offsets. Copy every nested
// scroller after attachment/layout so expanded Chat freezes the exact room
// and rail positions the human clicked, not their scroll origins.
const sourceNodes = [source, ...source.querySelectorAll("*")];
const snapshotNodes = [snapshot, ...snapshot.querySelectorAll("*")];
for (let index = 0; index < sourceNodes.length; index += 1) {
const live = sourceNodes[index];
const frozen = snapshotNodes[index];
if (!frozen) break;
if (live.scrollTop) frozen.scrollTop = live.scrollTop;
if (live.scrollLeft) frozen.scrollLeft = live.scrollLeft;
}
readerMorphMotion = motion === "forward" || motion === "back" ? motion : "morph";
scrollEl.classList.add("reader-morph");
scrollEl.classList.toggle("reader-morph-push", readerMorphMotion !== "morph");
scrollEl.classList.toggle("reader-morph-back", readerMorphMotion === "back");
readerMorphSnapshot = snapshot;
readerMorphInFlight = true;
return true;
}
function startReaderMorph(destinationView = "reader") {
readerMorphDestination = {
relays: relaysViewEl,
chat: chatViewEl,
threads: threadsViewEl,
sent: sentViewEl,
tasks: tasksViewEl,
requests: tasksViewEl, // compatibility alias for pre-Task view state
reader: readerViewEl,
contacts: contactsViewEl,
settings: settingsViewEl,
}[destinationView] || null;
if (!readerMorphSnapshot || !readerMorphDestination || activeView !== destinationView) {
cleanReaderMorph();
return;
}
// Navigation and this call share one event task. Hide the already-built
// destination synchronously before the browser gets any paint opportunity;
// waiting for rAF exposed one blended source/destination frame.
readerMorphDestination.classList.add("reader-morph-destination");
readerMorphRaf = requestAnimationFrame(async () => {
readerMorphRaf = null;
if (!readerMorphSnapshot || activeView !== destinationView) { cleanReaderMorph(); return; }
// Grow the native window while the source face is frozen so the destination
// can animate without being clipped. Older main processes resolve this
// barrier immediately, so the call remains backward-compatible.
if (window.relay.prepareCardSize) {
const destinationSize = destinationView === "reader" || (destinationView === "threads" && chatExpanded)
? READER
: EXPANDED;
try { await window.relay.prepareCardSize(destinationSize.w, destinationSize.h); } catch {}
}
if (!readerMorphSnapshot || activeView !== destinationView) { cleanReaderMorph(); return; }
scrollEl.classList.add("reader-morph-go");
readerMorphInFlight = false;
syncCardSize(destinationView === "reader" || (destinationView === "threads" && chatExpanded));
// Frozen/background frames do not reliably dispatch animationend. The
// settled DOM is still deterministic when the app becomes active again.
readerMorphTimer = setTimeout(cleanReaderMorph, 480);
});
}
let roomViewTransition = null;
let roomViewTransitionToken = 0;
let roomViewTransitionDeferredPayload = null;
const roomViewTransitionDeferredWork = new Set();
function afterRoomViewTransition(update) {
if (typeof update !== "function") return;
if (roomViewTransition) {
roomViewTransitionDeferredWork.add(update);
return;
}
queueMicrotask(update);
}
function cleanRoomViewTransition(token = roomViewTransitionToken) {
if (token !== roomViewTransitionToken) return;
roomViewTransition = null;
document.documentElement.classList.remove("room-view-transition");
delete document.documentElement.dataset.roomMotion;
scrollEl.style.viewTransitionName = "";
// Keep the captured destination immutable until Chromium retires both
// pseudo-elements. Polling/read-receipt updates collapse to the newest
// payload and reconcile immediately after the visual transaction.
const deferredPayload = roomViewTransitionDeferredPayload;
roomViewTransitionDeferredPayload = null;
if (deferredPayload) queueMicrotask(() => onPayload(deferredPayload));
const deferredWork = [...roomViewTransitionDeferredWork];
roomViewTransitionDeferredWork.clear();
for (const update of deferredWork) queueMicrotask(update);
}
function supersedeRoomViewTransition() {
if (!roomViewTransition) return;
const transition = roomViewTransition;
// Invalidate the old update callback before asking Chromium to skip. The
// platform still runs an update callback for a skipped transition; without
// this token advance it can write the old room destination after a newer
// tab navigation has already committed.
const token = ++roomViewTransitionToken;
try { transition.skipTransition(); } catch {}
cleanRoomViewTransition(token);
}
function startRoomViewTransition(update, { motion = "forward" } = {}) {
if (typeof update !== "function") return false;
// View Transitions give compact room navigation real pixel snapshots. If
// the API is unavailable, instant navigation is preferable to reviving the
// DOM-clone push whose scroll/sticky layers can tear independently.
if (REDUCED || collapsed || peeking || ghost || typeof document.startViewTransition !== "function") {
update();
return false;
}
if (roomViewTransition) {
supersedeRoomViewTransition();
}
const token = ++roomViewTransitionToken;
const root = document.documentElement;
root.classList.add("room-view-transition");
root.dataset.roomMotion = motion === "back" ? "back" : "forward";
// Name the stable viewport, not either live child view. Chromium captures
// the exact old and new pixels of this box, including its current scroll
// position, then animates the two flat images as one plane apiece.
scrollEl.style.viewTransitionName = "relay-room";
let updated = false;
try {
const transition = document.startViewTransition(() => {
if (token !== roomViewTransitionToken) return;
updated = true;
update();
});
roomViewTransition = transition;
// skipTransition() is a normal supersession path when the human picks a
// tab before this room motion settles. Chromium rejects these auxiliary
// promises on a skipped transition; consume that expected cancellation
// so it never becomes an unhandled renderer error.
transition.ready?.catch(() => {});
transition.updateCallbackDone?.catch(() => {});
transition.finished.then(
() => cleanRoomViewTransition(token),
() => cleanRoomViewTransition(token),
);
return true;
} catch {
cleanRoomViewTransition(token);
if (!updated) update();
return false;
}
}
let peeking = false, peekTimer = null;
let notificationDurationMs = 7000;
let activeNotificationIds = [];
let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
// Has the FULL card been deliberately put in front of the reader — tray open, or
// expanding a pill or a banner? Boot leaves the card un-collapsed and unparked
// before anything has been shown, so `!collapsed` alone cannot answer this, and
// an arrival at launch would otherwise be treated as one landing over an inbox
// the reader already had open. Parking the card offstage takes it back.
let presented = false;
function fullCardIsOnStage() {
return presented && !collapsed && !peeking && !ghost
&& !cardEl.classList.contains("offstage") && !cardEl.classList.contains("bye");
}
function clearPeek() {
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
peeking = false;
cardEl.classList.remove("peek", "notifying");
}
function setCollapsed(v) {
clearPeek();
if (v === collapsed) return;
collapsed = v;
if (!collapsed) presented = true; // expanded on purpose: the reader is looking at it
refreshCountLabel();
// One face, one transition: the count and compact controls track the same
// state from the first shrinking frame through native-window settlement.
cardEl.classList.toggle("collapsed", collapsed);
syncContinuousMotion();
const openSize = readerOpenNow ? READER : EXPANDED;
springTo(collapsed ? PILL.w : openSize.w, collapsed ? PILL.h : openSize.h);
playTink();
// List rebuilds are deferred while folded; catch up now that the card is visible.
if (!collapsed) {
flushPendingRelaysRender();
flushDeferredSurfaceRender();
} else {
syncRunMirrors();
}
}
// Park the card invisibly at expanded size while the OS window is hidden, so the
// next appearance (tray reopen / ghost banner) starts from a clean slate.
function snapSize(w, h) {
W.v = W.t = w; H.v = H.t = h;
if (raf) { cancelAnimationFrame(raf); raf = null; cardEl.style.willChange = ""; }
cardEl.style.width = w + "px";
cardEl.style.height = h + "px";
commitSettledCardSize(w, h, ++cardMotionId);
}
function resetOffstage() {
ghost = false;
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
clearPeek();
collapsed = false;
presented = false; // parked: the next arrival banners again rather than landing silently
refreshCountLabel();
cardEl.classList.add("offstage");
cardEl.classList.remove("collapsed", "ghost", "bye");
syncContinuousMotion();
syncRunMirrors();
snapSize(EXPANDED.w, EXPANDED.h);
}
// ✕ — the card leaves gracefully, then the main process hides the window and keeps
// it hidden (persisted) until the status-area Relay mark is clicked.
// The exit is cancellable: if the user reopens (tray click / new ghost banner) while
// the bye animation is in flight, the pending timeout must NOT fire — it would
// dismiss the pill they just reopened and park the card offstage under a visible
// window (a click that "does nothing").
let byeTimer = null;
function cancelBye() {
if (byeTimer) { clearTimeout(byeTimer); byeTimer = null; }
cardEl.classList.remove("bye");
}
// Attention protocol: a card leaves the pending queue only when main sees
// evidence a human was present. The renderer reports what it observed.
let notifInteracted = false;
let notifSticky = false;
function sendAttentionDone(dwelled) {
if (!activeNotificationIds.length) return;
const ids = activeNotificationIds;
activeNotificationIds = [];
notifSticky = false;
// A settled arrival is no longer notifying. The banner path already dropped
// this marker on its way out, but the on-stage path settles immediately and
// used to leave it set for the life of the process — so anything watching it
// saw a card that was permanently mid-notification.
cardEl.classList.remove("notifying");
if (window.relay.attentionDone) {
window.relay.attentionDone({ ids, dwelled: Boolean(dwelled), interacted: notifInteracted });
}
notifInteracted = false;
}
// Fold the notification back into the pill (shared by the dwell timer and
// the sticky-card touch path).
function foldToPill() {
peeking = false;
cardEl.classList.remove("peek", "notifying");
collapsed = true;
cardEl.classList.add("collapsed");
syncContinuousMotion();
syncRunMirrors();
refreshCountLabel();
springTo(PILL.w, PILL.h);
renderAll();
}
// Any real input on the card is interaction evidence.
for (const evt of ["pointerdown", "pointermove", "wheel", "keydown"]) {
document.addEventListener(evt, () => {
if (!activeNotificationIds.length) return;
notifInteracted = true;
// Sticky cards (repeatedly missed) latch open and confirm on first touch,
// then fold: the touch IS the acknowledgement.
if (notifSticky && evt !== "pointermove") {
sendAttentionDone(true);
foldToPill();
}
}, { capture: true, passive: true });
}
function dismissOverlay() {
if (cardEl.classList.contains("bye")) return;
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
const wasGhost = ghost;
// The card was visibly presented and the person deliberately dismissed it.
// That is stronger evidence than a passive dwell, so this notification id
// is retired. Re-queuing on ✕ was the remaining source of the same banner
// appearing two or three times after the 0.1.183 dwell fix.
if (!wasGhost) sendAttentionDone(true);
cardEl.classList.add("bye");
setInteractive(false);
byeTimer = setTimeout(() => {
byeTimer = null;
if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
else window.relay.dismiss();
resetOffstage();
}, wasGhost ? 380 : 300);
}
// A relay arrived while dismissed: the same designed banner, but as a free-standing
// notification. It emerges at banner size (no pill morph — there is no pill), and
// when done it fades away instead of folding back into anything.
function ghostArrival(row) {
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
cancelBye(); // a banner arriving mid-exit must not be torn down by the old exit
clearPeek();
collapsed = false;
refreshCountLabel();
cardEl.classList.remove("collapsed", "offstage", "bye");
ghost = true; peeking = true;
cardEl.classList.add("peek", "ghost");
activeView = "relays";
commitNavigation({ outerScrollTop: 0 });
// Size to the rendered first row (no pill morph — the banner just appears at size).
snapSize(PEEK.w, peekHeight());
playTink();
// Leave after ~7s — but never rug-pull someone mid-reply or with the pointer on
// the banner; check again shortly instead (macOS banners hold while hovered too).
ghostTimer = setTimeout(function tick() {
ghostTimer = null;
if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
}, notificationDurationMs);
}
// Status-area Relay mark clicked: the card comes back FULLY OPEN (never the pill).
function trayOpen() {
const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
// Opening from the tray is deliberate engagement with whatever is on stage.
notifInteracted = notifInteracted || activeNotificationIds.length > 0;
sendAttentionDone(true);
cancelBye(); // a reopen mid-exit must survive: the pending dismiss would hide us again
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
ghost = false;
clearPeek();
collapsed = false;
presented = true; // the tray mark opens the card FULLY: that is a presentation
cardEl.classList.remove("collapsed");
if (wasOffstage) {
// fresh entrance: snap to expanded and replay the appear animation
cardEl.classList.remove("ghost", "bye", "offstage");
snapSize(EXPANDED.w, EXPANDED.h);
cardEl.style.animation = "none";
void cardEl.offsetWidth;
cardEl.style.animation = "";
}
// A tray-open over a banner must replace the unread-only banner DOM before
// the first full frame. renderAll commits content then syncCardSize starts
// the morph; the old two-frame defer visibly swapped lists mid-animation.
renderAll();
playTink();
}
// The banner must show its WHOLE first row — a clipped preview reads as broken.
// Measure after render; QR question rows are tall, so allow room before capping.
// The rows are not the whole story: the lockup above them and the scroller's own
// bottom padding below them are part of the banner. Measure both instead of assuming
// (the old hardcoded 44 + rows + 6 undershot by 3px, which left a scrollbar sliver on
// a banner that had nothing to scroll). Not scrollEl.scrollHeight — the view stretches
// to fill the scroller, so that just reports the card's CURRENT height back.
function peekHeight() {
// Notifications now render the same Relay rows as the inbox. The old
// `.row` selector survived the earlier redesign, so every banner fell
// back to the 118px minimum and clipped multi-relay stacks. Measuring both
// species keeps compatibility with legacy quick-reply rows while fitting
// the native accessory window to the whole visible stack.
const stack = [...relaysListEl.querySelectorAll(".relay-row, .row")];
if (!stack.length) return PEEK.h;
const rowsHeight = stack.reduce((sum, row) => sum + row.getBoundingClientRect().height, 0);
const tabs = document.querySelector(".tabs");
const head = lockupEl.getBoundingClientRect().height + (tabs ? tabs.getBoundingClientRect().height : 0);
const pad = parseFloat(getComputedStyle(scrollEl).paddingBottom) || 0;
return Math.min(Math.ceil(head + rowsHeight + pad), EXPANDED.h);
}
// Size the banner to what it is showing. Measured twice on purpose: the first pass can
// land before the serif has finished loading, and fallback metrics wrap the title a few
// pixels differently — enough for the last line to sit under the fade. A frame later
// (and once the font is ready) we re-measure and correct.
function sizePeek() {
if (!peeking) return;
springTo(PEEK.w, peekHeight());
requestAnimationFrame(() => { if (peeking) springTo(PEEK.w, peekHeight()); });
}
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { if (peeking) springTo(PEEK.w, peekHeight()); });
}
// New relays always enter the notification stack, including from a fully expanded
// card. The main process has already pushed the new payload, so render/sizing uses
// the latest unread rows rather than the previous inbox.
function notifyArrival(rows, meta) {
// An arrival while the card is already open in front of the reader must not
// pull it back down to banner height. The attention queue presents pending
// unread one batch at a time, and without this guard the second batch yanks
// a card the reader is part-way through; the relay lands in the list instead
// and attention settles where it is. (Sven's find on the restyle branch — the
// bug is real whichever presentation the arrival path uses.)
// `presented` is what separates a card the reader OPENED from a card that is
// merely un-collapsed because nothing has happened to it yet. The DOM boots
// expanded and unparked, so without it the very first arrival after launch
// looked exactly like an arrival over an open inbox: a backlog waiting at
// login never bannered, never folded back to the pill, and was marked
// confirmed-seen before the reader had looked at anything.
const onStageFull = fullCardIsOnStage();
if (peekTimer) clearTimeout(peekTimer);
cancelBye();
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
ghost = false;
refreshCountLabel();
activeNotificationIds = (Array.isArray(rows) ? rows : [rows]).filter(Boolean).map((row) => row.id).filter(Boolean);
notifSticky = Boolean(meta && meta.sticky);
// Before any peek state is taken: the on-stage card keeps the chrome it has,
// so `peeking`/`collapsed` must not be moved for a banner that never appears.
if (onStageFull) {
cardEl.classList.add("notifying"); // state-only marker; the chrome stays put
notifInteracted = true; // an open inbox is deliberate engagement already
renderAll();
playTink();
sendAttentionDone(true);
return;
}
peeking = true;
collapsed = false;
notifInteracted = false;
cardEl.classList.remove("collapsed", "offstage", "ghost", "bye");
// A notification is a BANNER, not the app. `peek` collapses the tab rail and the
// card is sized to the rows it actually shows — the notification list is unread-only
// (see renderRelays), so at full expanded height a single new relay sat above ~380px
// of empty card, which reads as a broken window rather than a notification.
// `notifying` is the state-only marker (no visual CSS) the tests and main watch.
cardEl.classList.add("peek", "notifying");
activeView = "relays";
commitNavigation({ outerScrollTop: 0 });
// Size AFTER the render above: peekHeight measures the rows now in the DOM.
sizePeek();
playTink();
// The banner folds itself back to the pill once it has been seen (main's
// shipped behaviour, and David's law: show once, fold, never re-parade) —
// but never out from under an engaged user: a pointer on the card or a
// reply being typed defers the fold instead of rug-pulling it.
peekTimer = setTimeout(function tick() {
peekTimer = null;
if (interactive || quickReplyIsActive()) { notifInteracted = true; peekTimer = setTimeout(tick, 4000); return; }
if (notifSticky) return; // repeatedly-missed relays latch until touched
foldToPill();
sendAttentionDone(true);
}, notificationDurationMs);
}
function openFull() {
if (ghost) {
// Engaging with a ghost banner brings the pill back for real. Keep the .ghost
// class (removing it while visible would restart the base entrance animation);
// it only influences which exit animation would play later.
ghost = false;
if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
window.relay.undismiss();
}
// Expanding to full is deliberate engagement with the on-stage card.
notifInteracted = notifInteracted || activeNotificationIds.length > 0;
sendAttentionDone(true);
clearPeek();
collapsed = false;
presented = true;
refreshCountLabel();
cardEl.classList.remove("collapsed");
// `peeking` is already false, so this paints the full destination list
// before applyView retargets the frame. No banner-to-list swap mid-spring.
renderAll();
playTink();
}
// ---------- the ✕ ----------
// Swallow the mousedown so the lockup's drag/tap-fold never engages.
closeXEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
closeXEl.addEventListener("click", (e) => {
e.stopPropagation();
if (closeXEl.disabled) return;
dismissOverlay();
});
// Theme and minimize — the other two window buttons, same drag-swallowing rule.
const themeToggleEl = document.getElementById("themeToggle");
const minimizeBtnEl = document.getElementById("minimizeBtn");
themeToggleEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
minimizeBtnEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
themeToggleEl.addEventListener("click", (e) => {
e.stopPropagation();
const toDark = document.documentElement.dataset.theme !== "dark";
if (toDark) document.documentElement.dataset.theme = "dark";
else delete document.documentElement.dataset.theme;
try { localStorage.setItem("relayTheme", toDark ? "dark" : "light"); } catch {}
try { window.relay.setTheme && window.relay.setTheme(toDark ? "dark" : "light"); } catch {}
});
minimizeBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
if (!collapsed) requestToggle();
});
// ---------- drag the lockup to move, tap it to fold ----------
let dragging = false, offX = 0, offY = 0, downSX = 0, downSY = 0, moved = 0;
lockupEl.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
dragging = true; moved = 0; offX = e.clientX; offY = e.clientY; downSX = e.screenX; downSY = e.screenY;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (dragging) {
moved = Math.max(moved, Math.abs(e.screenX - downSX) + Math.abs(e.screenY - downSY));
if (moved > 3) window.relay.setPos(e.screenX - offX, e.screenY - offY);
}
});
// Every tap counts — none are ever discarded. Dropping taps (a cooldown, or an
// "ignore while the spring animates" guard) produces the very complaint it was
// meant to fix: a deliberate second click silently does nothing. Instead the tap
// updates a logical TARGET immediately, and the animation is reconciled to that
// target once per frame. A replayed backlog of N queued taps therefore settles on
// the correct parity with a single animation instead of ping-ponging through N.
// Counted, not stored as a target: between bursts the state is always re-derived
// from the live `collapsed`, so it can never drift out of sync with the paths that
// fold/expand the card on their own (tray open, notification fold, offstage reset).
// Applied SYNCHRONOUSLY on the tap. The old path deferred to requestAnimationFrame
// to coalesce bursts, but a renderer busy laying out the list could deliver that
// frame late — the click appeared to do nothing, which is precisely the complaint
// it was meant to prevent. Coalescing is no longer needed: springTo only RETARGETS
// the running spring, so a burst of taps moves the target rather than queuing
// animations, and parity stays correct because each tap toggles exactly once.
function requestToggle() {
setCollapsed(!collapsed);
}
window.addEventListener("mouseup", () => {
if (!dragging) return;
const wasTap = moved <= 3;
dragging = false;
if (!wasTap) return;
if (peeking) { openFull(); return; }
requestToggle();
});
// ---------- ordinary window hover ----------
function setInteractive(v) { interactive = Boolean(v); }
cardEl.addEventListener("mouseenter", () => setInteractive(true));
cardEl.addEventListener("mouseleave", () => setInteractive(false));
// ---------- shared helpers ----------
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); }
function agentMentionSpans(value) {
const text = String(value || "");
const safe = text
.replace(/```[\s\S]*?```/g, (match) => " ".repeat(match.length))
.replace(/`[^`\n]*`/g, (match) => " ".repeat(match.length))
.replace(/https?:\/\/\S+/gi, (match) => " ".repeat(match.length))
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, (match) => " ".repeat(match.length));
const spans = [];
const seen = new Set();
const matcher = /(^|[^A-Za-z0-9._%+-])@(Claude|Codex|my_claude|my_codex)\b/gi;
for (const match of safe.matchAll(matcher)) {
const provider = String(match[2]).toLowerCase().includes("claude") ? "claude" : "codex";
if (seen.has(provider)) continue;
const start = Number(match.index || 0) + String(match[1] || "").length;
const token = text.slice(start, start + String(match[2]).length + 1);
spans.push({ provider, start, end:start + token.length, token });
seen.add(provider);
}
return spans;
}
function timeAgo(iso) {
const t = Date.parse(iso); if (!t) return "";
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 60) return "now";
if (s < 3600) return Math.floor(s / 60) + "m";
if (s < 86400) return Math.floor(s / 3600) + "h";
if (s < 604800) return Math.floor(s / 86400) + "d";
return Math.floor(s / 604800) + "w";
}
function formatChatDate(iso, nowValue = Date.now()) {
const date = new Date(iso);
const now = new Date(nowValue);
if (!Number.isFinite(date.getTime()) || !Number.isFinite(now.getTime())) return "";
// Compare local calendar days through UTC day numbers. This keeps
// Today/Yesterday correct across daylight-saving transitions.
const calendarDay = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / 86400000;
const daysAgo = calendarDay(now) - calendarDay(date);
if (daysAgo === 0) return "Today";
if (daysAgo === 1) return "Yesterday";
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const dateWithoutYear = `${date.getDate()} ${months[date.getMonth()]}`;
return date.getFullYear() === now.getFullYear()
? dateWithoutYear
: `${dateWithoutYear} ${date.getFullYear()}`;
}
function chatChunkDateLabel(iso, previousIso, nowValue = Date.now()) {
const current = Date.parse(iso);
if (!Number.isFinite(current)) return "";
if (previousIso) {
const previous = Date.parse(previousIso);
if (Number.isFinite(previous) && Math.abs(current - previous) <= 6 * 3600e3) return "";
}
return formatChatDate(iso, nowValue);
}
// One person or saved group is one chronological conversation. Sender and
// direction define a visual run; message species, provider, and opaque wire
// thread ids never split it. Only a dated chunk reintroduces the sender, so
// the first visible message below "Today" never appears anonymous.
function continuesSenderRun(previous, current, chunkDateLabel) {
return Boolean(
previous
&& !chunkDateLabel
&& previous.party === current.party
&& previous.direction === current.direction,
);
}
// Derive a one-line summary from the first meaningful line of markdown body. Strips
// heading hashes, list bullets, blockquotes, emphasis, and the relay forwarding prefix,
// then truncates so the single-line serif subject + fade mask stay clean.
function firstLine(s, max) {
const cap = max || 90;
const lines = String(s || "").replace(/\r/g, "").split("\n");
for (let raw of lines) {
let line = raw
.replace(/^\s*🔁\s*From\s+.+?:\s*/i, "")
.replace(/^\s*🔁\s*/, "")
.replace(/^\s{0,3}#{1,6}\s+/, "") // headings
.replace(/^\s{0,3}>\s?/, "") // blockquote
.replace(/^\s{0,3}[-*+]\s+/, "") // bullet
.replace(/^\s{0,3}\d+\.\s+/, "") // ordered list
.replace(/[*_`]+/g, "") // emphasis / code ticks
.trim();
if (line) {
if (line.length > cap) line = line.slice(0, cap - 1).trimEnd() + "…";
return line;
}
}
return "";
}
// A short body preview for the line beneath the subject — the next meaningful slice of
// the body, skipping whatever already became the subject.
function bodyPreview(body, subject, max) {
const cap = max || 110;
const flat = String(body || "")
.replace(/\r/g, "")
.replace(/^\s*🔁\s*From\s+.+?:\s*/i, "")
.replace(/^\s*🔁\s*/, "")
.replace(/[#>*_`]+/g, "")
.replace(/\s+/g, " ")
.trim();
if (!flat) return "";
const sub = String(subject || "").replace(/…$/, "").trim();
let rest = flat;
if (sub && flat.toLowerCase().startsWith(sub.toLowerCase())) {
rest = flat.slice(sub.length).replace(/^[\s.:;,—-]+/, "").trim();
}
if (!rest) return "";
if (rest.length > cap) rest = rest.slice(0, cap - 1).trimEnd() + "…";
return rest;
}
// Topic rows have one compact message-gist line under the conversation
// identity. Old agents were instructed to write titles up to 140 characters,
// so shorten them for this LIST surface at a natural word boundary. The
// stored title is untouched and the reader continues to show it in full.
function relayListGist(value, max) {
const cap = max || 90;
const clean = String(value || "").replace(/\s+/g, " ").trim();
if (clean.length <= cap) return clean;
const available = clean.slice(0, Math.max(1, cap - 1));
const boundary = available.lastIndexOf(" ");
const cut = boundary >= Math.floor(cap * 0.6) ? boundary : available.length;
return available.slice(0, cut).replace(/[,:;\-–—]+$/u, "").trimEnd() + "…";
}
// map enums -> humane copy (never show a raw enum)
const RELAY_KIND_LABEL = {
task_request: "Task",
share_approval: "Approval needed",
task_completed: "Task completed",
task_cancelled: "Task cancelled",
task_failed: "Task failed",
connector_reauth: "Reconnect needed",
plain_relay: "Relay",
};
function relayKindLabel(kind) { return RELAY_KIND_LABEL[kind] || "Relay"; }
// ---- attachment chips (file icon + name + size) for approvals and messages ----
function fileFamilyOf(name, type) {
const t = String(type || "").toLowerCase();
const n = String(name || "").toLowerCase();
if (t.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|heic|bmp|tiff?)$/.test(n)) return "image";
if (t === "application/pdf" || /\.pdf$/.test(n)) return "pdf";
if (t.includes("spreadsheet") || t.includes("csv") || t.includes("ms-excel") || /\.(xlsx?|csv|tsv|numbers)$/.test(n)) return "sheet";
if (t.includes("zip") || t.includes("tar") || t.includes("compressed") || /\.(zip|tar|gz|tgz|7z|rar|dmg)$/.test(n)) return "archive";
if (t.startsWith("audio/") || /\.(mp3|wav|m4a|aac|flac|ogg)$/.test(n)) return "audio";
if (t.startsWith("video/") || /\.(mp4|mov|mkv|webm|avi)$/.test(n)) return "video";
if (t.includes("json") || t.includes("xml") || t.includes("javascript") || /\.(json|jsonl|ya?ml|xml|html?|css|[jt]sx?|py|rb|go|rs|sh|sql)$/.test(n)) return "code";
if (t.includes("wordprocessing") || t.includes("msword") || t.startsWith("text/") || /\.(docx?|rtf|odt|pages|txt|md)$/.test(n)) return "doc";
return "file";
}
function fileIconSvg(family) {
const s = 'fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"';
const body = {
image: `<rect x="2.6" y="3.4" width="10.8" height="9.2" rx="1.5" ${s}/><circle cx="5.9" cy="6.5" r=".9" fill="currentColor"/><path d="M4 11.3l2.9-2.7 2 1.8 2-2.2 2.5 2.7" ${s}/>`,
pdf: `<path d="M4 2.2h5.3l3 3.4v8.2H4z" ${s}/><path d="M9.3 2.2v3.4h3" ${s}/><path d="M6 10.6h4.2" ${s}/>`,
sheet: `<rect x="2.8" y="2.8" width="10.4" height="10.4" rx="1.3" ${s}/><path d="M2.8 6.4h10.4M2.8 9.6h10.4M6.6 2.8v10.4" ${s}/>`,
archive: `<rect x="3" y="5" width="10" height="8" rx="1.1" ${s}/><path d="M2.5 3h11v2h-11zM6.7 7.2h2.6" ${s}/>`,
audio: `<path d="M6.4 12.2V4.5l5.6-1.3v7.6" ${s}/><circle cx="4.9" cy="12.2" r="1.5" ${s}/><circle cx="10.5" cy="10.8" r="1.5" ${s}/>`,
video: `<rect x="2.6" y="3.6" width="10.8" height="8.8" rx="1.5" ${s}/><path d="M6.7 6.2l3.4 2.2-3.4 2.2z" fill="currentColor"/>`,
code: `<path d="M5.6 5.2L2.9 8l2.7 2.8M10.4 5.2L13.1 8l-2.7 2.8" ${s}/>`,
doc: `<path d="M4 2.2h5.3l3 3.4v8.2H4z" ${s}/><path d="M9.3 2.2v3.4h3M5.9 8.3h4.2M5.9 10.6h4.2" ${s}/>`,
file: `<path d="M4 2.2h5.3l3 3.4v8.2H4z" ${s}/><path d="M9.3 2.2v3.4h3" ${s}/>`,
};
return `<svg width="15" height="15" viewBox="0 0 16 16">${body[family] || body.file}</svg>`;
}
function fmtBytes(bytes) {
const n = Number(bytes || 0);
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
return n > 0 ? `${n} B` : "";
}
// Handles both attachment shapes: task-message files ({filename, sizeBytes})
// and relay attachments ({name, bytes}). With a relayId the chip becomes a
// button that opens the file via main (local copy, or download-then-open);
// without one it stays the plain readout.
function attachmentChips(attachments, { relayId = "" } = {}) {
const list = Array.isArray(attachments) ? attachments : [];
return list.map((a) => {
const name = a.filename || a.name || "file";
const size = fmtBytes(a.sizeBytes != null ? a.sizeBytes : a.bytes);
const inner = `
${fileIconSvg(fileFamilyOf(name, a.contentType))}
<span class="td-att-name">${esc(name)}</span>
${size ? `<span class="td-att-meta">${esc(size)}</span>` : ""}`;
if (relayId && a.id) {
return `
<button class="td-att td-att-open" type="button" data-att-relay="${esc(relayId)}" data-att-id="${esc(a.id)}" data-stop="1" title="Open ${esc(name)}" aria-label="Open attachment ${esc(name)}">${inner}</button>`;
}
return `
<div class="td-att">${inner}</div>`;
}).join("");
}
function attachmentPlates(attachments, { relayId = "" } = {}) {
const list = Array.isArray(attachments) ? attachments : [];
const images = list.filter((attachment) => {
const type = String(attachment?.contentType || "").toLowerCase();
const name = String(attachment?.filename || attachment?.name || "").toLowerCase();
return /^image\/(?:png|jpeg|gif|webp)$/.test(type) || /\.(?:png|jpe?g|gif|webp)$/.test(name);
});
const files = list.filter((attachment) => !images.includes(attachment));
if (!images.length) return attachmentChips(files, { relayId });
const plates = images.map((attachment) => {
const name = attachment?.filename || attachment?.name || "Image";
const size = fmtBytes(attachment?.sizeBytes != null ? attachment.sizeBytes : attachment?.bytes);
if (!relayId || !attachment?.id) return attachmentChips([attachment]);
return `<button class="att-fig td-att-open" type="button" data-att-relay="${esc(relayId)}" data-att-id="${esc(attachment.id)}" data-att-preview="1" data-stop="1" title="Open ${esc(name)}" aria-label="Open image ${esc(name)}">
<span class="att-plate" aria-hidden="true"></span>
<span class="att-cap"><span class="td-att-name">${esc(name)}</span>${size ? `<span class="td-att-meta">${esc(size)}</span>` : ""}</span>
</button>`;
}).join("");
return `<div class="att-zone"><div class="att-grid${images.length === 1 ? " one" : ""}">${plates}</div>${files.length ? `<div class="att-files">${attachmentChips(files, { relayId })}</div>` : ""}</div>`;
}
function relaySharedShelf(relay) {
const list = Array.isArray(relay?.attachments) ? relay.attachments : [];
if (!list.length) return "";
const noun = list.length === 1 ? "file" : "files";
const cards = list.map((attachment) => {
const name = attachment?.filename || attachment?.name || "File";
const size = fmtBytes(attachment?.sizeBytes != null ? attachment.sizeBytes : attachment?.bytes);
const family = fileFamilyOf(name, attachment?.contentType);
const image = family === "image";
const htmlPreview = /^text\/html(?:;|$)/i.test(String(attachment?.contentType || "")) || /\.html?$/i.test(String(name));
return `<button class="rd-shelf-card td-att-open" type="button"
data-att-relay="${esc(relay.id)}" data-att-id="${esc(attachment?.id || "")}"${image || htmlPreview ? ' data-att-preview="1"' : ""}
data-stop="1" title="Open ${esc(name)}" aria-label="Open attachment ${esc(name)}">
<span class="rd-shelf-preview att-plate" aria-hidden="true">${fileIconSvg(family)}</span>
<span class="rd-shelf-meta"><span class="rd-shelf-name td-att-name">${esc(name)}</span>${size ? `<span class="rd-shelf-size">${esc(size)}</span>` : ""}</span>
</button>`;
}).join("");
return `<section class="rd-shelf" aria-label="Attachments">
<div class="rd-shelf-head"><span>Attached to this Relay</span><span>${list.length} ${noun}</span></div>
<div class="rd-shelf-grid">${cards}</div>
</section>`;
}
function safeAttachmentImageUrl(result) {
const mimeType = /^image\/(?:png|jpeg|gif|webp)$/i.test(String(result?.mimeType || "")) ? String(result.mimeType) : "";
const body = String(result?.dataBase64 || "");
return result?.ok && mimeType && /^[A-Za-z0-9+/=]+$/.test(body) ? `data:${mimeType};base64,${body}` : "";
}
const attachmentPreviewCache = new Map();
function attachmentPreview(relayId, attachmentId) {
const key = `${relayId}:${attachmentId}`;
if (attachmentPreviewCache.has(key)) return attachmentPreviewCache.get(key);
const pending = window.relay.previewAttachment(relayId, attachmentId).then((result) => {
if (!result?.ok) attachmentPreviewCache.delete(key);
return result;
}, (error) => {
attachmentPreviewCache.delete(key);
throw error;
});
attachmentPreviewCache.set(key, pending);
while (attachmentPreviewCache.size > 8) attachmentPreviewCache.delete(attachmentPreviewCache.keys().next().value);
return pending;
}
function hydrateAttachmentPlates(root) {
if (!window.relay.previewAttachment) return;
for (const figure of root.querySelectorAll("[data-att-preview]")) {
if (figure.dataset.loading === "1" || figure.classList.contains("ready")) continue;
figure.dataset.loading = "1";
attachmentPreview(
figure.getAttribute("data-att-relay"),
figure.getAttribute("data-att-id"),
).then((result) => {
if (!figure.isConnected) return;
const src = safeAttachmentImageUrl(result);
if (!src) { figure.dataset.loading = ""; return; }
const img = document.createElement("img");
img.alt = figure.querySelector(".td-att-name")?.textContent || "Image";
img.addEventListener("load", () => figure.classList.add("ready"), { once:true });
img.src = src;
figure.querySelector(".att-plate")?.append(img);
}).catch(() => { if (figure.isConnected) figure.dataset.loading = ""; });
}
}
// One delegated wiring pass per rebuilt container: chip click -> main opens
// the file. Never toggles the row (data-stop) and reports failures into the
// same .row-err slot every other row action uses.
function wireAttachmentChips(root) {
hydrateAttachmentPlates(root);
for (const chip of root.querySelectorAll("[data-att-relay]")) {
chip.addEventListener("click", async (event) => {
event.stopPropagation();
if (chip.disabled) return;
chip.disabled = true;
const relayId = chip.getAttribute("data-att-relay");
showRowError(relayId, "");
try {
const res = await window.relay.openAttachment(relayId, chip.getAttribute("data-att-id"));
if (!res || res.ok === false) showRowError(relayId, (res && res.error) || "Could not open this attachment.");
} catch {
showRowError(relayId, "Could not open this attachment.");
} finally {
chip.disabled = false;
}
});
}
}
// Attachments for one relay row, straight from the current payload.
function relayAttachmentsForId(id) {
const row = (payload.relays || []).find((r) => r.id === id);
return row && Array.isArray(row.attachments) ? row.attachments : [];
}
// Tasks/share approvals are hidden from the pill until that product surface is ready.
const RELAY_HIDDEN_KINDS = new Set(["task_request", "task", "share_approval"]); // requests live on their own board
function isRelayListKind(row) {
// A typed completion remains a normal two-document Relay in correspondence
// even though the same envelope is also the Task's canonical result.
return !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind);
}
// The serif subject line per kind, using REAL fields:
// - result / task_completed: the one kind with a real title -> title is the subject.
// - plain message / notice: no real title — derive the subject from the first meaningful
// line of forHuman (never promote a fake title and throw the body away).
function relaySubject(row) {
const kind = row.relayNotificationKind;
const body = row.forHuman || row.body || "";
const title = String(row.title || row.displayTitle || "").trim();
if (kind === "plain_relay" || kind === "task_completed" || kind === "result") {
return title || firstLine(body, 90) || relayKindLabel(kind);
}
// plain message / notice: subject is the first meaningful body line
return firstLine(body, 90) || title || relayKindLabel(kind);
}
// Always the real person, never the literal brand word "Relay". notifications.js stages a
// real senderName (the message's RequestMessage.senderLabel, the other person, or "Your agent"
// for the viewer's own agent questions). If an older staged row still carries the bare brand
// word "Relay" (e.g. main.cjs's senderName||"Relay" fallback), treat it as unknown and show
// the neutral "Someone" rather than presenting "Relay" as a person.
/* A Relay account is named by whatever the person signed up with, which is
usually their email prefix ("svenozwellmann"). The contact book is where
their actual name lives, so it wins whenever it has one. Falling back to
the prefix rather than the whole address keeps a row from being eaten by
"someone@somecompany.example.com". */
function contactNameForEmail(email) {
const key = String(email || "").trim().toLowerCase();
if (!key) return "";
for (const c of contactsList) {
const emails = contactEmails(c).map((e) => String(e).trim().toLowerCase());
if (!emails.includes(key)) continue;
const name = String(c.name || "").trim();
// A contact auto-created from an inbound relay is named by its email
// until someone edits it; that is not an upgrade over what we already have.
if (name && !emails.includes(name.toLowerCase())) return name;
return "";
}
return "";
}
function emailPrefix(email) {
const at = String(email || "").indexOf("@");
return at > 0 ? String(email).slice(0, at) : String(email || "");
}
/* The display name for a person, in preference order: their contact-book
name, then whatever the account calls itself, then the email prefix. */
function personName(name, email) {
const fromBook = contactNameForEmail(email);
if (fromBook) return fromBook;
const raw = String(name || "").trim();
if (raw && raw !== "Relay" && raw.indexOf("@") === -1) return raw;
const prefix = emailPrefix(email || raw);
return prefix || "Someone";
}
function normalizedMentionToken(value) {
return String(value || "").replace(/^@/, "").trim().toLowerCase();
}
function mentionTokenForContact(contact) {
const explicit = String((contact && contact.handle) || "").replace(/^@/, "").trim();
if (explicit) return explicit;
const name = String((contact && contact.name) || "").trim();
if (!name || name.includes("@")) return "";
return name.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "");
}
function mentionContact(handle) {
const key = normalizedMentionToken(handle);
if (!key) return null;
return contactsList.find((contact) =>
normalizedMentionToken(mentionTokenForContact(contact)) === key,
) || null;
}
function mentionDisplayName(handle) {
const contact = mentionContact(handle);
if (!contact) return "";
const name = String(contact.name || "").trim();
return name && name.indexOf("@") === -1 ? name : "";
}
function mentionComposerParts(text, options) {
const source = String(text || "");
const byToken = new Map((options || []).map((option) => [String(option.token || "").toLowerCase(), option]));
const parts = [];
const token = /@[A-Za-z0-9][A-Za-z0-9._-]*/g;
let cursor = 0;
let match;
while ((match = token.exec(source))) {
const previous = match.index > 0 ? source[match.index - 1] : "";
const option = /[A-Za-z0-9._%+-]/.test(previous)
? null
: byToken.get(match[0].slice(1).toLowerCase());
if (!option) continue;
if (match.index > cursor) parts.push({ text:source.slice(cursor, match.index) });
parts.push({ token:String(option.token), label:String(option.name || `@${option.token}`), agent:Boolean(option.agent) });
cursor = match.index + match[0].length;
}
if (cursor < source.length) parts.push({ text:source.slice(cursor) });
return parts.length ? parts : [{ text:source }];
}
function composerNodeValue(node) {
if (!node) return "";
if (node.nodeType === Node.TEXT_NODE) return node.data || "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
if (node.hasAttribute("data-mention-token")) return `@${node.getAttribute("data-mention-token") || ""}`;
if (node.tagName === "BR") return "\n";
let value = "";
for (const child of node.childNodes) value += composerNodeValue(child);
return value;
}
function composerValue(field) {
let value = "";
for (const child of field?.childNodes || []) value += composerNodeValue(child);
return value;
}
function composerNodeLength(node) { return composerNodeValue(node).length; }
function composerCaretOffset(field) {
const selection = window.getSelection?.();
if (!selection || !selection.rangeCount || !field.contains(selection.anchorNode)) return composerValue(field).length;
const target = selection.anchorNode;
const targetOffset = selection.anchorOffset;
let total = 0;
let found = false;
const visit = (node) => {
if (found) return;
if (node === target) {
if (node.nodeType === Node.TEXT_NODE) total += Math.min(targetOffset, (node.data || "").length);
else for (let index = 0; index < Math.min(targetOffset, node.childNodes.length); index += 1) total += composerNodeLength(node.childNodes[index]);
found = true;
return;
}
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute("data-mention-token")) {
total += composerNodeLength(node);
return;
}
for (const child of node.childNodes || []) visit(child);
};
visit(field);
return found ? total : composerValue(field).length;
}
function composerSetCaretOffset(field, start, end = start) {
const range = document.createRange();
const locate = (wanted) => {
let remaining = Math.max(0, wanted);
let result = { node:field, offset:field.childNodes.length };
const visit = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
const length = (node.data || "").length;
if (remaining <= length) { result = { node, offset:remaining }; return true; }
remaining -= length;
return false;
}
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute("data-mention-token")) {
const length = composerNodeLength(node);
const parent = node.parentNode || field;
const index = [...parent.childNodes].indexOf(node);
if (remaining <= length) { result = { node:parent, offset:index + (remaining > length / 2 ? 1 : 0) }; return true; }
remaining -= length;
return false;
}
for (const child of node.childNodes || []) if (visit(child)) return true;
return false;
};
visit(field);
return result;
};
const from = locate(start);
const to = locate(end);
range.setStart(from.node, from.offset);
range.setEnd(to.node, to.offset);
const selection = window.getSelection?.();
selection?.removeAllRanges();
selection?.addRange(range);
}
function setComposerValue(field, value) {
const fragment = document.createDocumentFragment();
for (const part of mentionComposerParts(value, field.relayMentionOptions || [])) {
if (Object.prototype.hasOwnProperty.call(part, "text")) {
if (part.text) fragment.appendChild(document.createTextNode(part.text));
} else {
const mention = document.createElement("span");
mention.className = `th-composer-mention${part.agent ? " agent" : ""}`;
mention.setAttribute("data-mention-token", part.token);
mention.setAttribute("contenteditable", "false");
mention.setAttribute("aria-label", `Mentioned ${part.label.replace(/^@/, "")}`);
mention.textContent = part.label;
fragment.appendChild(mention);
}
}
field.replaceChildren(fragment);
}
function insertComposerText(field, text) {
const selection = window.getSelection?.();
if (!selection || !selection.rangeCount || !field.contains(selection.anchorNode)) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(String(text || ""));
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
field.dispatchEvent(new InputEvent("input", { bubbles:true, inputType:"insertText", data:String(text || "") }));
}
function prepareMentionComposer(field, options, initialValue) {
field.relayMentionOptions = options || [];
if (field.dataset.richMentionComposer) return;
field.dataset.richMentionComposer = "1";
Object.defineProperty(field, "value", {
configurable:true,
get:() => composerValue(field),
set:(value) => setComposerValue(field, value),
});
Object.defineProperty(field, "selectionStart", { configurable:true, get:() => composerCaretOffset(field) });
field.setSelectionRange = (start, end = start) => composerSetCaretOffset(field, start, end);
field.addEventListener("beforeinput", (event) => {
if (event.inputType !== "insertParagraph" && event.inputType !== "insertLineBreak") return;
event.preventDefault();
insertComposerText(field, "\n");
});
field.addEventListener("input", () => {
if (field.childNodes.length === 1 && field.firstChild?.nodeName === "BR") field.replaceChildren();
});
field.addEventListener("paste", (event) => {
if (clipboardFiles(event).length) return;
event.preventDefault();
insertComposerText(field, event.clipboardData?.getData("text/plain") || "");
});
field.value = initialValue || "";
}
// Escape once while turning bare URLs into live anchors and known @handles
// into named mention chips. Unknown handles remain literal text; the wire
// value is never rewritten, so routing stays stable when a contact is renamed.
function linkify(text) {
const source = String(text || "");
const token = /https?:\/\/[^\s<]+|@[A-Za-z0-9][A-Za-z0-9._-]*/g;
let html = "";
let cursor = 0;
let match;
while ((match = token.exec(source))) {
const value = match[0];
const index = match.index;
html += esc(source.slice(cursor, index));
if (value.startsWith("http")) {
const safe = esc(value);
html += `<a href="${safe}" data-stop="1" target="_blank" rel="noreferrer noopener">${safe}</a>`;
} else {
// Do not turn the domain half of an email address into a mention.
const previous = index > 0 ? source[index - 1] : "";
const displayName = /[A-Za-z0-9._%+-]/.test(previous) ? "" : mentionDisplayName(value);
html += displayName
? `<span class="th-mention" aria-label="Mentioned ${esc(displayName)}">@${esc(displayName)}</span>`
: esc(value);
}
cursor = index + value.length;
}
return html + esc(source.slice(cursor));
}
function relaySender(row) {
return personName(row.senderName || row.fromName || row.personName, row.senderEmail);
}
// Exhaustive over the RequestState enum (protocol.ts: draft, resolving, inviting, active,
// completing, completed, cancelled, failed). Every value has a human label so a request row
// never shows a raw enum.
const REQUEST_STATE_LABEL = {
draft: "Draft", resolving: "Setting up", inviting: "Awaiting replies",
active: "Active", completing: "Wrapping up", completed: "Completed",
cancelled: "Cancelled", failed: "Failed",
};
function requestStateLabel(state) { return REQUEST_STATE_LABEL[state] || "Task"; }
function requestStateClass(state) {
// The reduced palette only distinguishes "live" (prussian) from everything else (gray).
if (state === "active" || state === "completing") return "s-active";
return "";
}
// Find the viewer's participant row when they have an OUTSTANDING invitation on this
// request (invitationState "created" or "delivered"). Match on account email, then userId.
function viewerPendingParticipant(request) {
const ps = Array.isArray(request.participants) ? request.participants : [];
if (!ps.length) return null;
const acct = (payload && payload.account) || {};
const myEmail = String(acct.email || "").trim().toLowerCase();
const myUserId = acct.userId || acct.id || null;
for (const p of ps) {
const st = p.invitationState;
if (st !== "created" && st !== "delivered") continue;
const pEmail = String(p.email || "").trim().toLowerCase();
if (myEmail && pEmail && pEmail === myEmail) return p;
if (myUserId && p.userId && p.userId === myUserId) return p;
}
return null;
}
function participantSummary(request) {
const ps = Array.isArray(request.participants) ? request.participants : [];
if (!ps.length) return "No participants yet";
let accepted = 0, waiting = 0, declined = 0;
for (const p of ps) {
const st = p.invitationState;
if (st === "accepted") accepted++;
else if (st === "rejected" || st === "timed_out" || st === "withdrawn") declined++;
else waiting++;
}
const parts = [];
if (accepted) parts.push(accepted + " accepted");
if (waiting) parts.push(waiting + " waiting");
if (declined) parts.push(declined + " declined");
return parts.length ? parts.join(" · ") : ps.length + " invited";
}
function requestRoleLabel(role) { return role === "creator" ? "You created" : "You joined"; }
// ---------- state ----------
let activeView = "relays";
let payload = { account: { paired: false }, ui: { canDismiss: true, reopenSurface: "the Relay icon", setupTutorialPending:false }, features: { requests:false, slack:false, peopleMentions:true, agentMentions:false, cowork:false, messageMutations:false, environment:"production" }, relays: [], sent: [], requests: [], contacts: [], chats: [], slackChats: [] };
const canonicalChatDetails = new Map();
const canonicalChatFingerprints = new Map();
const canonicalChatDetailProjectionKeys = new Map();
const canonicalChatHydrations = new Map();
const canonicalChatLatestStartedGeneration = new Map();
const canonicalChatLatestAppliedGeneration = new Map();
let canonicalChatRequestGeneration = 0;
const liveCanonicalArrivalIds = new Set();
const canonicalReadGeneration = new Map();
const canonicalReadFailures = new Map();
// Visibility is deliberately session-only and scoped to both chat and
// surface. Slack defaults open on its own tab and closed inside Relays;
// toggling one room never changes any other room or the other surface.
const slackVisibilityOverrides = new Map();
let activeCanonicalChatRefresh = null;
const ACTIVE_CANONICAL_CHAT_POLL_MS = 1000;
for (const eventName of ["pointerdown", "keydown", "wheel"]) {
window.addEventListener(eventName, () => window.relay.chatReadActivity?.(), { capture:true, passive:true });
}
function canonicalChatFingerprint(chat) {
return JSON.stringify((chat?.items || []).map((item) => [
item.relayId, item.updatedAt, item.deletedAt, item.editedAt,
item.threadId, item.inReplyToRelayId, item.state,
(item.attachments || []).map((file) => [file.id, file.bytes]),
]));
}
function conversationSurface(source = threadsSource) {
return source === "slack" ? "slack" : "relay";
}
function isSlackIntegratedRoom(room) {
return Boolean(room && (
room.integration?.provider === "slack"
|| room.channel?.slack
|| room.provider === "slack"
));
}
function slackVisibilityKey(room, source = threadsSource) {
const chatId = String((room && room.chatId) || threadDetailId || "");
return `${conversationSurface(source)}:${chatId}`;
}
function slackMessagesVisible(room, source = threadsSource) {
if (payload.features?.slack !== true) return false;
if (!isSlackIntegratedRoom(room)) return false;
const key = slackVisibilityKey(room, source);
return slackVisibilityOverrides.has(key)
? slackVisibilityOverrides.get(key) === true
: conversationSurface(source) === "slack";
}
function canonicalChatHydrationKey(room, source = threadsSource, includeSlack = slackMessagesVisible(room, source)) {
if (!room?.chatId) return "";
return `${conversationSurface(source)}:${String(room.chatId)}:${includeSlack ? "with-slack" : "relay-only"}`;
}
function canonicalChatDetailIsCurrent(room, source = threadsSource, { includeSlack = slackMessagesVisible(room, source) } = {}) {
if (!room?.chatId) return false;
const id = String(room.chatId);
const detail = canonicalChatDetails.get(id);
if (!detail) return false;
if (canonicalChatDetailProjectionKeys.get(id) !== canonicalChatHydrationKey(room, source, includeSlack)) return false;
return String(detail.updatedAt || "") === String(room.summaryUpdatedAt || "")
&& Number(detail.messageCount || 0) === Number(room.messageCount || 0)
&& String(detail.lastMessage?.relayId || "") === String(room.summaryLastMessageId || "");
}
function requestCanonicalChatDetail(room, source = threadsSource, { includeSlack = slackMessagesVisible(room, source) } = {}) {
if (!room?.chatId || !window.relay.canonicalChat) return Promise.resolve(null);
const key = canonicalChatHydrationKey(room, source, includeSlack);
const existing = canonicalChatHydrations.get(key);
if (existing) return existing;
const chatId = String(room.chatId);
const surface = conversationSurface(source);
const generation = ++canonicalChatRequestGeneration;
canonicalChatLatestStartedGeneration.set(chatId, generation);
const request = Promise.resolve(window.relay.canonicalChat(room.chatId, {
surface,
includeSlack,
})).then((result) => result && typeof result === "object"
? { ...result, canonicalRequest:{ chatId, generation, surface, includeSlack } }
: result)
.catch((error) => ({ ok:false, error:error?.message || String(error), canonicalRequest:{ chatId, generation, surface, includeSlack } }))
.finally(() => canonicalChatHydrations.delete(key));
canonicalChatHydrations.set(key, request);
return request;
}
function canonicalChatResultIsCurrent(result, chatId) {
const request = result?.canonicalRequest;
if (!request) return true;
const id = String(chatId || result?.chat?.chatId || "");
const generation = Number(request.generation || 0);
if (!id || String(request.chatId || "") !== id || generation <= 0) return false;
if (generation < Number(canonicalChatLatestStartedGeneration.get(id) || 0)) return false;
if (generation < Number(canonicalChatLatestAppliedGeneration.get(id) || 0)) return false;
canonicalChatLatestAppliedGeneration.set(id, generation);
return true;
}
function canonicalChatResultMatchesVisibleProjection(result, { chatId, surface = conversationSurface() } = {}) {
const id = String(chatId || result?.chat?.chatId || "");
if (activeView !== "threads" || conversationSurface() !== surface) return false;
const visibleRoom = chatRoomForThread(threadDetailId, surface);
if (!visibleRoom || String(visibleRoom.chatId || "") !== id) return false;
const request = result?.canonicalRequest;
return !request || (
String(request.surface || "") === surface
&& Boolean(request.includeSlack) === slackMessagesVisible(visibleRoom, surface)
);
}
function reconcileCanonicalChatResult(result, {
chatId,
surface = conversationSurface(),
markArrivals = false,
readVisible = false,
} = {}) {
if (!result?.ok || !result.chat) return false;
const id = String(chatId || result.chat.chatId || "");
if (!canonicalChatResultMatchesVisibleProjection(result, { chatId:id, surface })) return false;
const request = result.canonicalRequest;
if (!canonicalChatResultIsCurrent(result, id)) return false;
let changed = storeCanonicalChatDetail(result.chat, {
markArrivals,
surface:request?.surface || surface,
includeSlack:Boolean(request?.includeSlack),
});
const directAnchor = directContactAnchorForChatId(id);
if (directAnchor && changed) {
const refreshed = directContactRoomAnchor(
result.chat,
{ name:directAnchor.name },
directAnchor.addressRecipient?.email || String(directAnchor.partyKey || "").replace(/^email:/, ""),
directAnchor.addressRecipient,
);
refreshed.threadIds = new Set([
refreshed.threadId,
...(refreshed.threadIds || []),
directAnchor.threadId,
...(directAnchor.threadIds || []),
].filter(Boolean));
contactChatAnchors.set(id, refreshed);
}
const reconcile = () => {
if (activeView !== "threads" || conversationSurface() !== surface) return;
const currentRoom = chatRoomForThread(threadDetailId, surface);
if (!currentRoom || String(currentRoom.chatId || "") !== id) return;
if (request && Boolean(request.includeSlack) !== slackMessagesVisible(currentRoom, surface)) return;
if (!rendererSurfaceActive()) {
surfaceRenderDeferred = true;
return;
}
if (changed) renderThreadDetail();
if (readVisible) readVisibleChatRoom();
};
afterRoomViewTransition(reconcile);
return changed;
}
function syncSlackVisibilityButton(room = threadDetailId ? chatRoomForThread(threadDetailId) : null) {
const linked = payload.features?.slack === true && isSlackIntegratedRoom(room);
thSlackVisibilityEl.classList.toggle("hidden", !linked);
if (!linked) return;
const visible = slackMessagesVisible(room);
thSlackVisibilityEl.disabled = false;
thSlackVisibilityEl.setAttribute("aria-pressed", visible ? "true" : "false");
thSlackVisibilityEl.title = visible ? "Hide messages from Slack" : "Show messages from Slack";
thSlackVisibilityLabelEl.textContent = visible ? "Hide Slack" : "Show Slack";
}
// Store one server generation. New ids are marked only after the first
// hydration, so entering an existing channel never animates its whole
// history; genuinely live arrivals use the same small settle as native
// Relay messages instead of making the transcript jump as one block.
function storeCanonicalChatDetail(chat, {
markArrivals = false,
surface = conversationSurface(),
includeSlack = false,
} = {}) {
if (!chat?.chatId) return false;
const id = String(chat.chatId);
const projectionKey = `${surface}:${id}:${includeSlack ? "with-slack" : "relay-only"}`;
const nextFingerprint = canonicalChatFingerprint(chat);
if (canonicalChatDetailProjectionKeys.get(id) === projectionKey
&& canonicalChatFingerprints.get(projectionKey) === nextFingerprint) return false;
const previous = canonicalChatDetails.get(id);
if (markArrivals && previous) {
const previousIds = new Set((previous.items || []).map((item) => String(item.relayId || "")));
for (const item of chat.items || []) {
const messageId = String(item.relayId || "");
if (messageId && !previousIds.has(messageId)) liveCanonicalArrivalIds.add(messageId);
}
}
canonicalChatDetails.set(id, chat);
canonicalChatDetailProjectionKeys.set(id, projectionKey);
canonicalChatFingerprints.set(projectionKey, nextFingerprint);
// A summary may have been painted before this detail introduced its first
// synchronized root. Keep exact ids current so the room remains addressable
// when the detail swaps from its synthetic fallback to a real thread id.
for (const key of ["chats", "slackChats"]) {
payload[key] = (payload[key] || []).map((summary) => String(summary.chatId || "") === id
? {
...summary,
threadIds:[...new Set([...(summary.threadIds || []), ...(chat.threadIds || [])])],
...(chat.integration ? { integration:chat.integration } : {}),
...(chat.channel ? { channel:chat.channel } : {}),
}
: summary);
}
return true;
}
async function refreshActiveCanonicalChat({ readVisible = true } = {}) {
if (activeCanonicalChatRefresh || !rendererSurfaceActive()) return;
if (activeView !== "threads" || !threadDetailId) return;
const room = chatRoomForThread(threadDetailId, conversationSurface());
if (!room && threadsSource === "slack" && threadDetailChatId) {
recoverActiveSlackRoom();
return;
}
const includeSlack = slackMessagesVisible(room);
const resolvedDirect = room?.chatId ? directContactAnchorForChatId(room.chatId) : null;
if ((!isSlackIntegratedRoom(room) && !resolvedDirect) || !room.chatId || !window.relay.canonicalChat) return;
const surface = conversationSurface();
activeCanonicalChatRefresh = requestCanonicalChatDetail(room, surface, { includeSlack });
try {
const result = await activeCanonicalChatRefresh;
if (!result?.ok || !result.chat) return;
reconcileCanonicalChatResult(result, {
chatId:room.chatId,
surface,
markArrivals:true,
readVisible,
});
} catch {}
finally { activeCanonicalChatRefresh = null; }
}
let signupStage = "installed";
let signupBusy = false;
let signupEmail = "";
let signupAccount = null;
let signupError = "";
let signupNote = "";
let signupPollTimer = null;
let signupPollFailures = 0;
let signupPollErrorVisible = false;
let signupConnectionCommitted = false;
let signupStateLoaded = false;
let signupForceGoogleSelection = false;
const SIGNUP_POLL_BASE_MS = 1400;
const SIGNUP_POLL_MAX_MS = 11200;
const SIGNUP_POLL_SURFACE_FAILURE = 3;
const SIGNUP_POLL_STOP_FAILURE = 6;
let prevRelayIds = new Set();
let contactsList = [];
// Direct chats resolved from People before inbox/Sent hydration finishes.
// These anchors keep the canonical conversation inside the pill without
// manufacturing a second chat window. Once local message projections arrive
// they merge into the same email-keyed conversation below.
const contactChatAnchors = new Map();
let cvEditing = null;
// rows mid-open: id -> click time (or -1 once a stop is scheduled). Kept across re-renders.
const openingIds = new Map();
// The relay row whose open-actions card is expanded. Like openingIds, this
// survives the innerHTML rebuilds renderRelays performs on every push.
let expandedRelayId = null;
let expandedSentId = null; // Sent row whose open-actions menu is expanded
function openingTargets(id) {
return [...document.querySelectorAll("[data-opening-id]")].filter((e) => e.getAttribute("data-opening-id") === id);
}
function stopOpening(id) {
const start = openingIds.get(id);
if (start === undefined || start === -1) return;
openingIds.set(id, -1);
const elapsed = performance.now() - start;
const wait = Math.max(380, 650 - elapsed);
setTimeout(() => {
openingIds.delete(id);
for (const el of openingTargets(id)) el.classList.remove("opening");
}, wait);
}
// The verb NAMES an app ("Open in Codex"), so the open must go THERE — the
// running-process heuristic only decides when nothing was named (David's
// live test: the button said Codex and Claude came forward).
// Display names → runtime ids the main process speaks (claude-opus-5,
// gpt-5.3-codex). The rail shows the human name; the runtime needs the id.
const CODEX_MODEL_ID = {
"5.6 Sol": "gpt-5.6-sol",
"5.6 Terra": "gpt-5.6-terra",
"5.6 Luna": "gpt-5.6-luna",
"5.5": "gpt-5.5",
"5.4": "gpt-5.4",
"5.3 Codex": "gpt-5.3-codex",
"5.3 Codex Spark": "gpt-5.3-codex-spark",
};
function modelIdFor(appName, modelName) {
const m = String(modelName || "").trim().toLowerCase().replace(/\s+/g, "-");
if (!m) return "";
if (hostKeyFor(appName) === "codex") {
return CODEX_MODEL_ID[String(modelName || "").trim()] || m;
}
return m.startsWith("claude-") ? m : `claude-${m}`;
}
function hostKeyFor(appName) {
const n = String(appName || "").toLowerCase();
if (n.includes("codex")) return "codex";
if (n.includes("cowork")) return "cowork";
if (n.includes("claude")) return "claude";
return "";
}
function openRelayFromUI(id, source = "relay", mode = "open", host = "", note = "") {
if (!id || openingIds.has(id)) return;
clearRowNote(id); // a previous open's failure text must not outlive the retry
openingIds.set(id, performance.now());
for (const el of openingTargets(id)) el.classList.add("opening");
setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
// B-rule bookkeeping: a real hand-off (current or fresh, never preview)
// teaches the agent this conversation — later hand-offs say "continues".
if (source !== "sent" && (mode === "current" || mode === "fresh")) {
const row = (payload.relays || []).find((r) => r.id === id);
if (row) handedThreads.set(row.threadId || row.id, Date.now());
}
// Sent rows carry the same three modes as received relays; each has its own
// IPC so the sent copy is staged/forged before the open.
if (source === "sent" && mode === "current" && window.relay.openSentInCurrent) window.relay.openSentInCurrent(id, host || hostKeyFor(agentAppName()));
else if (source === "sent" && mode === "fresh" && window.relay.openSentFresh) window.relay.openSentFresh(id, host || hostKeyFor(agentAppName()));
// THE NAMED APP IS A PROMISE: every button that opens says "Open in {app}",
// so an open with no host named by its caller goes where Settings says,
// not where the frontmost-window heuristic guesses. Callers that name a
// host themselves (David's per-relay rows, the Task rail's route) win.
else if (source === "sent" && window.relay.openSent) window.relay.openSent(id, host || hostKeyFor(agentAppName()));
else if (mode === "current" && window.relay.openInCurrent) window.relay.openInCurrent(id, host || hostKeyFor(agentAppName()));
else if (mode === "fresh" && window.relay.openFresh) window.relay.openFresh(id, host || hostKeyFor(agentAppName()), note);
else window.relay.open(id, host || hostKeyFor(agentAppName()));
}
let unreadCount = 0;
function refreshCountLabel() {
const label = collapsed ? `${unreadCount} unread` : String(unreadCount);
if (countEl.textContent !== label) {
countEl.textContent = label;
if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
}
}
function setCount(n) {
unreadCount = n;
cardEl.classList.toggle("has-unread", n > 0);
countEl.classList.toggle("zero", n === 0);
refreshCountLabel();
}
function setBadge(el, n) {
el.textContent = String(n);
el.classList.toggle("gone", !(n > 0));
}
// ---------- Relays view ----------
// A reply <textarea class="qr-input"> (thread reply composer) is "active" while the user is
// mid-answer: it's focused OR holds typed text. The inbox payload refreshes on a 2.5s safety
// poll (+ state.json fs.watch), and each refresh used to rebuild relaysListEl.innerHTML —
// destroying the in-progress textarea and wiping the typed text/focus. We must NOT rebuild the
// relays list while a reply is being written; the refresh is deferred (pendingRelaysRender)
// and flushed once the input blurs / the answer is sent.
let pendingRelaysRender = false;
function quickReplyIsActive() {
const active = document.activeElement;
if (active && active.classList && active.classList.contains("qr-input")) return true;
for (const ta of relaysListEl.querySelectorAll(".qr-input")) {
if (ta.value && ta.value.length) return true;
}
return false;
}
function flushPendingRelaysRender() {
if (!pendingRelaysRender) return;
pendingRelaysRender = false;
if (activeView === "relays") renderRelays();
}
function relayIdentityRows() {
// Relays and Chat are two views of the SAME rooms. The room projection
// already keeps an owned agent as a speaking face inside its human or
// group conversation; rebuilding identity from each message here used to
// promote "Shane's Codex" into a second person and could let agent bylines
// leak into a group's avatar roster. Quiet saved groups belong in Chat but
// not in the correspondence index, so Relays keeps only active rooms.
const { rooms } = chatSections();
return rooms
.filter((room) => room.hasActivity !== false)
.map((room) => ({ ...room, latestAt: room.latestAt || (room.latest && room.latest.at) || "" }));
}
function renderRelays() {
// WhatsApp chronology: a room's preview is the newest correspondence in
// either direction. `threadMessages` already normalizes staged inbound and
// live Sent rows, deduplicates group fan-out siblings, and keeps optimistic
// sends visible while the Sent projection catches up. A Task root is
// correspondence too: it appears in the room, so it must also be allowed
// to become that room's latest preview. Progress stays exclusively on the
// Tasks surface; the terminal completion Relay also closes the loop here.
const allRows = threadMessages()
.sort((a, b) => new Date(b.at || 0) - new Date(a.at || 0));
const identityRows = relayIdentityRows();
if (peeking) {
// A notification wears the SAME species as the list (David: the banner was
// still main's old row with a "Preview / Start task" menu — furniture the
// redesign deleted). Unread only, newest first; a tap opens the relay the
// way tapping it anywhere else does: the conversation, then the reader.
const unread = identityRows.filter((row) => row.unreadCount > 0);
relaysEmptyEl.classList.toggle("gone", unread.length > 0);
const nextNotifHtml = unread.map((row) => relayIdentityRowHtml(row)).join("");
// A BANNER WITH NOTHING IN IT IS A GLITCH, not a notification. It can
// happen on the beat between an arrival waking the pill and the payload
// that carries it (David caught the empty card with the list's own empty
// state bleeding through). Say nothing until there is something to say.
if (!nextNotifHtml) {
relaysEmptyEl.classList.add("gone");
if (relaysListEl.innerHTML !== "") relaysListEl.innerHTML = "";
return;
}
prevRelayIds = new Set(allRows.filter((r) => r.unread).map((r) => r.id));
if (relaysListEl.innerHTML === nextNotifHtml) return;
relaysListEl.innerHTML = nextNotifHtml;
for (const el of relaysListEl.querySelectorAll(".relay-arrival")) {
el.addEventListener("click", () => {
openFull(); // a banner tap brings the real pill up first
const morphFromList = prepareReaderMorph(activeView, { motion:"forward" });
openThreadDetail(el.getAttribute("data-thread"), el.getAttribute("data-party") || "", "relays");
if (morphFromList) startReaderMorph("threads");
});
}
// The banner is sized to its rows, so a re-render that changes them (a second
// arrival, a read row dropping out, a quick-reply box opening) must re-size too —
// otherwise the card keeps the old height and either clips or shows dead space.
// (The ghost banner's first paint is unaffected: its snapSize runs after this
// render and cancels the spring outright.)
sizePeek();
return;
}
// Relays is the WhatsApp-shaped identity index: exactly one row per person
// or exact saved group, carrying that room's latest Relay and unread sum.
relaysEmptyEl.classList.toggle("gone", identityRows.length > 0);
hasMoreThreads = identityRows.length > renderedThreadCount + RENDER_WINDOW;
const visible = identityRows.slice(0, renderedThreadCount + RENDER_WINDOW);
// Identical markup means NO DOM write (main's guard, kept): a 4s poll that
// rebuilds the list destroys open menus and their handlers, and flickers.
prevRelayIds = new Set(allRows.map((r) => r.id));
const nextListHtml = visible.map((row) => relayIdentityRowHtml(row)).join("");
if (relaysListEl.innerHTML === nextListHtml) return;
relaysListEl.innerHTML = nextListHtml;
for (const el of relaysListEl.querySelectorAll(".relay-arrival")) {
el.addEventListener("click", () => {
// One tap, straight into the conversation (Sven's standing call).
startRoomViewTransition(() => {
openThreadDetail(el.getAttribute("data-thread"), el.getAttribute("data-party") || "");
}, { motion:"forward" });
});
}
}
function relayIdentityRowHtml(identity) {
const row = identity.latest;
const name = identity.name;
// A conversation row opens the conversation, not whichever message happens
// to be newest. Slack's newest item may be a thread reply whose id is not a
// root id, so it cannot address the room. Prefer the immutable canonical
// chat id, then the room's own thread coordinate; message ids are only a
// compatibility fallback for legacy Relay rows.
const roomCoordinate = identity.chatId || identity.threadId || row.threadId || row.id;
const gist = relayListGist(row.title || row.body || "Message", 90);
const unreadCls = identity.unreadCount ? " unread" : "";
const groupPeople = identity.isGroup ? [...(identity.people || [])].slice(0, 2) : [];
while (identity.isGroup && groupPeople.length < 2) groupPeople.push(groupPeople.length ? name : (row.party || name));
const avatar = identity.isGroup
? `<span class="av-stack">${groupPeople.map((person, index) => `<span class="avatar sm${index === 0 ? " sq" : ""}" style="--av-h:${avatarHue(person)}" aria-hidden="true">${esc(avatarInitials(person))}</span>`).join("")}</span>`
: `<span class="avatar" style="--av-h:${avatarHue(name)}" aria-hidden="true">${esc(avatarInitials(name))}</span>`;
const senderPrefix = row.direction === "out"
? '<span class="gist-who">You:</span> '
: identity.isGroup
? `<span class="gist-who">${esc(String(row.party || "Someone").split(" ")[0])}:</span> `
: "";
return `
<div class="relay-row relay-arrival${unreadCls}" data-thread="${esc(roomCoordinate)}" data-party="${esc(name)}" data-opening-id="${esc(row.id)}">
${avatar}
<span class="rk-body">
<span class="th-top">
<span class="th-party">${esc(name)}</span>
${row.e2ee ? `<span class="echip" title="Only the sender's and recipient's devices can read this message">Encrypted</span>` : ""}
<span class="th-time">${esc(timeAgo(identity.latestAt))}</span>
</span>
<span class="rk-line"><span class="th-title">${senderPrefix}${esc(gist)}</span>${identity.unreadCount ? `<span class="th-count">${identity.unreadCount}</span>` : ""}</span>
</span>
</div>`;
}
// ---------- the reader: one relay as a full page (chat-first prototype) ----
// A relay is a REQUEST (sender's agent set up work for your agent -> Approve /
// note / Not now) or a MESSAGE (-> Reply). Task-ness is DECLARED by the
// sender: relayNotificationKind === "task_request".
const expandedMsgIds = new Set(); // bubbles currently unfolded in a thread
const expandedReceiptIds = new Set(); // group receipt rosters currently open
// The agent lane (David's round). A relay has TWO readers: the human part is
// the letter, and whatever the sender packed for the recipient's AGENT stays
// behind one quiet chip — the human sees it only on request, the agent always
// gets both. The channel already exists on main (relays.for_agent);
// the pill never rendered it, so senders merged both voices into forHuman.
// The reply rail's address, sized to fit (David's rules): a named group is
// addressed by its name; one person by their full name; two by first names
// ("to David & Shane"); more by "to David, Shane +n more". Up to two disc-hue
// squares stand for the people.
function replyRail(parties, groupName) {
const uniq = [...new Set((parties || []).filter(Boolean))];
const sq = uniq.slice(0, 2).map((p) =>
`<span class="sq" style="background:hsl(${avatarHue(p)} 40% 55%)"></span>`).join("");
let label;
if (groupName) label = `to ${groupName}`;
else if (uniq.length <= 1) label = `to ${uniq[0] || "them"}`;
else {
const firsts = uniq.map((p) => String(p).trim().split(/\s+/)[0]);
label = uniq.length === 2
? `to ${firsts[0]} & ${firsts[1]}`
: `to ${firsts[0]}, ${firsts[1]} +${uniq.length - 2} more`;
}
return `<span class="rp-to">${sq}${esc(label)}</span>`;
}
// Dress a composer field: grow with the words (up to ~6 lines, capsule
// easing to panel), Enter sends, Shift+Enter breaks the line.
// Staged composer attachments — keyed by surface so chips survive the
// constant re-renders (state on DOM nodes dies with them).
const stagedFiles = new Map(); // stageKey -> [{name,size,file}]
function stageKeyFor(field) {
const dock = field.closest("[data-request]") || field.closest(".ta-dock");
if (dock) return "request:" + (dock.getAttribute("data-request") || (readerId || "dock"));
if (field.closest("#readerView")) return "reader:" + (readerId || "");
if (field.closest("#thDetail") || field.closest("#thHistory")) return "thread:" + (threadDetailId || "");
return "composer:misc";
}
function takeStagedFiles(field) {
const key = stageKeyFor(field);
const files = stagedFiles.get(key) || [];
stagedFiles.delete(key);
const qr = field.closest(".qr");
if (qr) paintStagedChips(qr, key);
return files;
}
function peekStagedFiles(field) {
return [...(stagedFiles.get(stageKeyFor(field)) || [])];
}
function clearStagedFiles(field) {
const key = stageKeyFor(field);
stagedFiles.delete(key);
const qr = field.closest(".qr");
if (qr) paintStagedChips(qr, key);
}
async function composerFilePayloads(staged) {
const files = [];
const attachments = [];
for (const stagedFile of staged) {
const file = stagedFile.file || stagedFile;
const name = String(stagedFile.name || file?.name || "image");
const size = Number(stagedFile.size || file?.size || 0);
const contentType = String(file?.type || "application/octet-stream");
const image = contentType.startsWith("image/") || /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(name);
const osPath = window.relay.pathForFile ? window.relay.pathForFile(file) : "";
let previewUrl = "";
if (image && file) {
try { previewUrl = URL.createObjectURL(file); } catch {}
}
if (osPath) {
files.push({ name, size, contentType, path: osPath });
} else if (file?.arrayBuffer) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
const contentBase64 = btoa(binary);
files.push({ name, size, contentType, contentBase64 });
if (image && !previewUrl) previewUrl = `data:${contentType};base64,${contentBase64}`;
}
attachments.push({ name, image, previewUrl });
}
return { files, attachments };
}
function fmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return "";
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
function paintStagedChips(qr, key) {
let rowEl = qr.querySelector(".cmp-files");
const files = stagedFiles.get(key) || [];
if (!files.length) { if (rowEl) rowEl.remove(); return; }
if (!rowEl) {
rowEl = document.createElement("div");
rowEl.className = "cmp-files";
qr.insertBefore(rowEl, qr.firstChild);
}
rowEl.innerHTML = files.map((f, i) => `
<span class="cmp-chip"><span class="fico-mini"></span>${esc(f.name)}${f.size ? `<span class="sz">${fmtBytes(f.size)}</span>` : ""}<button type="button" class="x" data-unstage="${i}" aria-label="Remove ${esc(f.name)}">×</button></span>`).join("");
for (const b of rowEl.querySelectorAll("[data-unstage]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
files.splice(Number(b.getAttribute("data-unstage")), 1);
stagedFiles.set(key, files);
paintStagedChips(qr, key);
});
}
}
function stageFileList(field, list) {
const incoming = [...(list || [])].filter(Boolean);
if (!incoming.length) return;
const key = stageKeyFor(field);
const files = stagedFiles.get(key) || [];
for (const f of incoming) files.push({ name: f.name || `pasted-${files.length + 1}`, size: f.size || 0, file: f });
stagedFiles.set(key, files);
const qr = field.closest(".qr");
if (qr) paintStagedChips(qr, key);
}
function clipboardFiles(event) {
const clipboard = event && event.clipboardData;
const files = [...(clipboard?.files || [])].filter(Boolean);
if (files.length) return files;
return [...(clipboard?.items || [])]
.filter((item) => item && item.kind === "file" && typeof item.getAsFile === "function")
.map((item) => item.getAsFile())
.filter(Boolean);
}
const COMPOSER_ATTACHMENTS_ENABLED = true;
function dressComposer(field, onSend) {
if (!field || field.dataset.dressed) return;
field.dataset.dressed = "1";
// ---- attachments: the + button, paste, and the staged chips ----
const qr = field.closest(".qr");
if (COMPOSER_ATTACHMENTS_ENABLED && qr && !qr.querySelector(".cmp-plus")) {
const key = stageKeyFor(field);
const plus = document.createElement("button");
plus.type = "button";
plus.className = "cmp-plus";
plus.setAttribute("aria-label", "Attach files");
plus.title = "Attach files";
plus.innerHTML = '<svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M5 1v8M1 5h8"/></svg>';
const picker = document.createElement("input");
picker.type = "file";
picker.multiple = true;
picker.style.display = "none";
plus.addEventListener("mousedown", (e) => e.stopPropagation());
plus.addEventListener("click", (e) => { e.stopPropagation(); picker.click(); });
picker.addEventListener("change", () => { stageFileList(field, picker.files); picker.value = ""; });
const rail = qr.querySelector(".ta-rail, .rail");
if (rail) rail.insertBefore(plus, rail.firstChild); else qr.appendChild(plus);
qr.appendChild(picker);
paintStagedChips(qr, key); // chips survive re-renders via the store
}
field.addEventListener("paste", (e) => {
const files = clipboardFiles(e);
if (files.length) { e.preventDefault(); stageFileList(field, files); }
});
field.addEventListener("dragover", (e) => {
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes("Files")) e.preventDefault();
});
field.addEventListener("drop", (e) => {
const files = [...(e.dataTransfer?.files || [])].filter(Boolean);
if (files.length) { e.preventDefault(); stageFileList(field, files); }
});
if (qr && !qr.dataset.focusWired) {
qr.dataset.focusWired = "1";
qr.addEventListener("click", (e) => {
if (e.target.closest("button, a, input, textarea, [contenteditable], [data-route-menu], .cmp-chip")) return;
field.focus();
});
qr.style.cursor = "text";
}
const grow = () => {
field.style.height = "auto";
field.style.height = Math.min(field.scrollHeight, 126) + "px";
const box = field.closest(".qr");
if (box && !box.classList.contains("col")) box.style.borderRadius = field.scrollHeight > 34 ? "16px" : "22px";
};
field.addEventListener("input", grow);
requestAnimationFrame(grow);
field.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); onSend(); }
});
// grow() is bound to input, so a composer that survives a send must be told
// to shrink back after its value is cleared out from under it.
field.relayResize = grow;
}
let readerSource = "relays";
let readerReturn = null; // exact view/room/scroll state the reader must return to
let readerId = null;
// Prototype preferences (Sven's live A/B switches, Settings → Prototype).
function protoPref(key, fallback) {
try { return localStorage.getItem(key) || fallback; } catch { return fallback; }
}
function setProtoPref(key, value) { try { localStorage.setItem(key, value); } catch {} }
// A LETTER SHORT ENOUGH TO BE SELF-CONTAINED IS (Sven): bodies at or under
// this render whole in the conversation bubble — ~eight lines at pill width,
// readable in one glance. Longer letters keep title + preview and their tap
// is the reader (no in-between). 4000 was tried and read as a wall.
const INLINE_BODY_MAX = 550;
// WHICH AGENT APPS THIS MAC HAS. main's relay:capabilities (detectAgentSurfaces)
// — null until the first answer. THE APP, NOT THE CLI (Sven, 2026-08-17):
// "Open in {app}" opens the desktop app, so the desktop entries decide
// (_claudeDesktop for Claude Code, _codexDesktop for Codex); an app the
// probe does not name counts as present, so an old main or a failed probe
// never hides a real app.
let agentSurfaces = null;
const AGENT_APP_OPTIONS = ["Claude Code", "Codex"];
function desktopEntry(app) {
const key = app === "Codex" ? "_codexDesktop" : app === "Claude Code" ? "_claudeDesktop" : "";
return key && agentSurfaces ? agentSurfaces[key] : null;
}
function appAvailable(app) {
const entry = desktopEntry(app);
return !entry || entry.available !== false;
}
function appUnavailableReason(app) {
const entry = desktopEntry(app);
return entry && entry.available === false ? String(entry.reason || `${app === "Codex" ? "Codex" : "Claude"} isn’t installed on this Mac`) : "";
}
async function loadAgentSurfaces() {
if (!window.relay.capabilities) return;
let next = null;
try { next = await window.relay.capabilities(); } catch { next = null; }
if (!next || typeof next !== "object" || !Object.keys(next).length) return;
const before = agentAppName();
agentSurfaces = next;
// The effective app can change with the answer (nothing saved, only Codex
// here): repaint whatever names it — Settings' rows or a room's verbs.
if (activeView === "settings") renderSettings();
else if (agentAppName() !== before) renderAll();
}
// THE CHOICE: independent app switches. Missing v2 state intentionally does
// not inherit proto.agentApp: the old radio picker wrote a single app on
// machines that should have offered both, leaving Relays stuck with one
// action after both desktop apps were installed. The first v2 render starts
// from detection — both on when both are present, the installed app when
// only one is present. Once touched, the exact enabled set is durable.
const AGENT_APP_BOTH = "Both";
const AGENT_APPS_PREF = "proto.agentApps.v2";
function agentAppSelection() {
const saved = protoPref(AGENT_APPS_PREF, "");
const present = AGENT_APP_OPTIONS.filter(appAvailable);
const requested = saved.split("|").filter((app) => AGENT_APP_OPTIONS.includes(app) && appAvailable(app));
if (saved && requested.length) return AGENT_APP_OPTIONS.filter((app) => requested.includes(app));
return present.length ? present : ["Claude Code"];
}
function agentAppChoice() {
const selected = agentAppSelection();
return selected.length === 2 ? AGENT_APP_BOTH : selected[0];
}
function setAgentAppEnabled(app, enabled) {
if (!AGENT_APP_OPTIONS.includes(app) || !appAvailable(app)) return;
const selected = new Set(agentAppSelection());
if (enabled) selected.add(app);
else {
if (selected.size <= 1) return;
selected.delete(app);
}
const next = AGENT_APP_OPTIONS.filter((option) => selected.has(option));
setProtoPref(AGENT_APPS_PREF, next.join("|"));
// Keep downgrade behavior coherent for a previous Relay build.
setProtoPref("proto.agentApp", next.length === 2 ? AGENT_APP_BOTH : next[0]);
}
// The label: the app's name, or "your agent" when the choice is Both — the
// reader's second document is then "For your agent", and an open with no
// host named lets main pick the app you are working in.
function agentAppName() {
const choice = agentAppChoice();
return choice === AGENT_APP_BOTH ? "your agent" : choice;
}
// The rows: David's order when both, else the one you chose.
function agentAppHosts() {
const choice = agentAppChoice();
if (choice === AGENT_APP_BOTH) return ["codex", "claude"];
return [hostKeyFor(choice) === "codex" ? "codex" : "claude"];
}
function chatOrder() { return protoPref("proto.convOrder", "chat"); } // "chat" | "inbox"
const taskLocal = new Map(); // packetId -> "running" | "parked" (prototype-local approval state)
const taskClaimNotes = new Map(); // ownership errors stay with the separate claim control
const taskStopPending = new Set(); // one server-side idle transition per terminal Work feed
// A Task whose chosen provider is not ready owns a real Work face, but no
// provider session yet. Keeping that state separate from taskLocal prevents
// the auth prompt from manufacturing a running Task or attaching a feed that
// cannot exist.
const workProviderPrompts = new Map(); // relayId -> { requested, busy, error }
const requestSafetyReviews = new Map(); // decrypted, local-only advisory review
// A REQUEST arrives on the wire as kind "task" and stages as relayNotificationKind
// "task"; the legacy task-pipeline rows say "task_request". Both are Tasks —
// matching only one is why the Tasks tab read "No Tasks yet" while three
// staged requests sat in the pill (David, live).
function isTaskRow(r) {
const k = r && r.relayNotificationKind;
return k === "task_request" || k === "task";
}
function taskRows() { return (payload.relays || []).filter(isTaskRow); }
function taskClaimFor(row) {
const claim = row && row.taskClaim;
return claim && claim.scope === "channel" ? claim : null;
}
function taskClaimAllowsStart(row) {
const claim = row && row.taskClaim;
return !claim || claim.scope === "direct" || claim.capabilities?.canStart === true;
}
function taskClaimControlHtml(row, { surface = "default" } = {}) {
const claim = taskClaimFor(row);
if (!claim) return "";
const id = String(row.id || row.relayId || "");
const working = claim.workState === "working"
|| (!claim.workState && Boolean(row.taskStartedAt && !row.taskCompletedAt));
const claimant = claim.claimant;
const error = taskClaimNotes.get(id);
const errorHtml = error ? `<div class="task-claim-error" role="status">${esc(error)}</div>` : "";
if (claim.state === "unclaimed") {
const release = claim.releasedAt
? `<div class="task-claim-release">${esc(`Unclaimed again${claim.releasedBy?.name ? ` · Released by ${claim.releasedBy.self ? "you" : claim.releasedBy.name}` : ""}`)}</div>`
: "";
if (claim.capabilities?.canClaim !== true) {
return `<div class="task-claim-control ${esc(surface)}" data-stop="1"><div class="task-claim-status">${claim.releasedAt ? "Unclaimed again" : "Unclaimed"}</div>${release}${errorHtml}</div>`;
}
return `<div class="task-claim-control ${esc(surface)}" data-stop="1">${release}<button class="task-claim-button claim" type="button" data-task-claim-action="claim" data-task-claim-id="${esc(id)}" data-task-claim-version="${Number(claim.version || 0)}">Claim task</button>${errorHtml}</div>`;
}
if (claimant?.self && claim.capabilities?.canUnclaim === true && !working) {
return `<div class="task-claim-control ${esc(surface)}" data-stop="1"><button class="task-claim-button unclaim" type="button" data-task-claim-action="unclaim" data-task-claim-id="${esc(id)}" data-task-claim-version="${Number(claim.version || 0)}">Unclaim task</button>${errorHtml}</div>`;
}
const label = claimant?.self
? `Claimed by you${working ? " · Working" : ""}`
: `Claimed by ${String(claimant?.name || "someone else")}`;
return `<div class="task-claim-control ${esc(surface)}" data-stop="1"><div class="task-claim-status${claimant?.self ? " mine" : ""}">${esc(label)}</div>${errorHtml}</div>`;
}
function applyTaskClaimProjection(row, taskClaim) {
if (!row || !taskClaim) return;
const groupSendId = row.groupSendId || null;
for (const item of payload.relays || []) {
if (String(item.id || item.relayId || "") === String(row.id || row.relayId || "")
|| (groupSendId && item.groupSendId === groupSendId)) item.taskClaim = taskClaim;
}
for (const item of payload.sent || []) {
if (String(item.id || item.relayId || "") === String(row.id || row.relayId || "")
|| (groupSendId && item.groupSendId === groupSendId)) item.taskClaim = taskClaim;
}
row.taskClaim = taskClaim;
}
function wireTaskClaimControls(scope, rerender) {
for (const button of scope.querySelectorAll("[data-task-claim-action]")) {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
const id = String(button.getAttribute("data-task-claim-id") || "");
const version = Number(button.getAttribute("data-task-claim-version"));
const action = button.getAttribute("data-task-claim-action");
const row = relayById(id) || (payload.sent || []).find((item) => String(item.relayId || item.id || "") === id);
button.disabled = true;
button.textContent = action === "unclaim" ? "Unclaiming…" : "Claiming…";
taskClaimNotes.delete(id);
const result = action === "unclaim"
? await window.relay.taskUnclaim(id, Number.isInteger(version) ? version : undefined)
: await window.relay.taskClaim(id, Number.isInteger(version) ? version : undefined);
if (result?.taskClaim) applyTaskClaimProjection(row, result.taskClaim);
if (!result?.ok) {
taskClaimNotes.set(id, result?.error || `Could not ${action} this Task.`);
} else {
taskClaimNotes.delete(id);
}
rerender();
});
}
}
// State is READ FROM THE RECORD, not remembered in the tab. It used to be a
// local Map, so a restarted pill forgot every run in flight and a finished
// run sat under "Running" forever. The row's own receipts answer it: a
// completion stamp means done, a start stamp means running. Parking is the
// one genuinely local choice, and only until the run actually starts.
// Each vendor's own modes, in plain words. "Ask me first" is the honest name
// for a mode that stops mid-run; "Do the work" is bypass, and it is the
// default because pressing Start already said yes (David's rule).
const PERM_VENDORS = [
{
key: "claude",
label: "Claude Code",
sample: "Claude Code",
modes: [
{ id: "auto", name: "Auto" },
{ id: "acceptEdits", name: "Accept Edits" },
{ id: "bypassPermissions", name: "Bypass Permissions" },
// Plan is NOT offered: it stops the agent from executing at all, and a
// request is a job someone sent to be DONE. A run that only plans has
// not done it (David).
],
},
{
key: "codex",
label: "Codex",
sample: "Codex",
modes: [
{ id: "full", name: "Full access" },
{ id: "guardian", name: "Approve for me" },
{ id: "ask", name: "Ask for approval" },
],
},
];
const PERM_DEFAULTS = { claude: "auto", codex: "full" };
function permMode(vendor) {
return protoPref(`proto.perm.${vendor}`, PERM_DEFAULTS[vendor] || "");
}
function taskBoardState(id) {
const row = relayById(id) || {};
const local = taskLocal.get(id);
// A follow-up reopens the same session even if the original request has a
// completion receipt. The local transition wins until that turn settles.
if (local === "running") return "running";
if (local === "done") return "done";
if (local === "stopped") return "stopped";
if (row.completionReview) return "review";
// The completion receipt may arrive in Claude's final tool turn a few
// milliseconds before Relay's worker has flushed and released the native
// transcript. Ownership wins over the receipt for that interval: Open must
// stay disabled until there is only one possible writer.
if (row.runLive) return "running";
if (row.taskCompletedAt) return "done";
const claim = taskClaimFor(row);
if (claim?.workState === "completed") return "done";
if (claim?.workState === "working") return "running";
// RUNNING MEANS RUNNING. A started stamp with nothing alive behind it is a
// run that stopped without finishing — it says so, and Start comes back.
if (row.taskStartedAt) {
// A Task started by the current MCP agent is genuinely Working even
// though the Relay pill does not own or monitor that provider process.
if (row.taskRunOwner && row.taskRunOwner.kind === "external_mcp" && claim?.workState !== "idle") return "running";
return row.runLive || local === "running" ? "running" : "stopped";
}
if (local === "parked") return "parked";
return "waiting";
}
function relayWorkState(row) {
if (!row || isTaskRow(row)) return row ? taskBoardState(row.id) : "idle";
const local = taskLocal.get(String(row.id));
if (local === "running" || local === "done" || local === "stopped") return local;
if (row.source?.host === "relay-agent-run") return "running";
if (row.runLive) return "running";
if (row.workCompletedAt) return "done";
if (row.workStartedAt) return "stopped";
return "idle";
}
// The B-rule (Sven, hand-off ambiguity round): the CONVERSATION owns the
// agent thread. The first open starts your agent on the conversation; every
// later one reopens that same agent chat — never an unrelated session. The
// pill remembers the pairing; the row note says what happened AFTER the tap
// (the pre-tap whisper under the button was cut — David, 2026-08-12).
const handedThreads = new Map(); // threadId -> ts of the last open
function threadHanded(threadId) { return Boolean(threadId && handedThreads.has(threadId)); }
// The vendor square (David's law): Claude-family apps wear the anthropic
// ORANGE; Codex/ChatGPT wear the codex BLUE. Everywhere an agent app is
// named with a square, the square is the vendor's.
function vendorSq(app) {
return /claude/i.test(String(app)) ? "var(--brand-anthropic)" : "var(--agent-sq)";
}
// ---- the route: which agent app runs this Task, on which model, at what
// thinking level. Settings picks the DEFAULT; the composer's rail changes
// it per request. Model lists read from the installed apps' own binaries
// (Claude CLI 2.1.222; ChatGPT.app app.asar), 2026-08-12.
const AGENT_ROUTES = {
"Claude Code": { models:["Opus 5","Fable 5","Sonnet 5","Haiku 4.5"], efforts:["low","medium","high","xhigh","max"], model:"Opus 5", effort:"high" },
// The app's OWN catalogue, read from ChatGPT.app/Contents/Resources/app.asar
// (gpt-5.6-sol / -terra / -luna, gpt-5.5, gpt-5.4, gpt-5.3-codex[-spark]).
// Labels are the app's, so the picker reads like the one the user knows.
"Codex": { models:["5.6 Sol","5.6 Terra","5.6 Luna","5.5","5.4","5.3 Codex","5.3 Codex Spark"], efforts:["auto","low","medium","high","xhigh"], model:"5.6 Sol", effort:"high" },
};
const taskRoutes = new Map(); // packetId -> { app, model, effort } (ad-hoc overrides)
function routeFor(id) {
const base = AGENT_ROUTES[agentAppName()] ? agentAppName() : "Claude Code";
const o = taskRoutes.get(id) || {};
const app = AGENT_ROUTES[o.app] ? o.app : base;
const spec = AGENT_ROUTES[app];
return { app, model: o.model || spec.model, effort: o.effort || spec.effort };
}
function setRoute(id, patch) { taskRoutes.set(id, { ...(taskRoutes.get(id) || {}), ...patch }); }
function providerIdForApp(app) { return /codex/i.test(String(app || "")) ? "codex" : "claude"; }
function providerAppName(provider) { return provider === "codex" ? "Codex" : "Claude Code"; }
function providerStatusForWork(provider) { return providerAuthInfo?.providers?.[provider] || null; }
function providerSetupPrompt(provider) {
const agent = provider === "codex" ? "Codex" : "Claude Code";
const command = provider === "codex" ? "codex" : "claude";
const subscription = provider === "codex" ? "ChatGPT" : "Claude";
return `Install the official ${agent} CLI on this computer and authenticate it with my ${subscription} subscription. Make sure the ${command} command is available to background processes, verify the CLI is signed in, then make sure Relay is running and wait for it to detect the installation. Do not paste credentials or access tokens into chat.`;
}
function ownedAgentMentionOption(provider) {
const item = providerStatusForWork(provider);
const app = providerAppName(provider);
const note = item?.authLoaded && !item.installed
? `Needs ${app} CLI installation`
: item?.authLoaded && item.installed && !item.connected
? `Needs ${app} sign-in`
: `Run ${provider === "codex" ? "Codex" : "Claude"} on my computer`;
return { token:provider === "codex" ? "Codex" : "Claude", name:`@${provider === "codex" ? "Codex" : "Claude"}`, note, agent:true };
}
function providerReadyForWork(provider) {
const item = providerStatusForWork(provider);
return Boolean(item?.authLoaded && item?.enabled && item?.connected);
}
function showWorkProviderPrompt(id, provider, error = "") {
const key = String(id || "");
if (!key) return;
workProviderPrompts.set(key, { requested:provider || providerIdForApp(routeFor(key).app), busy:"", error:String(error || "") });
if (activeView === "reader" && String(readerId) === key) {
readerTab = "work";
readerWorkVisible = true;
readerTabSwap = true;
}
renderAll();
readerTabSwap = false;
}
async function providerReadyBeforeStart(id, app) {
const provider = providerIdForApp(app);
let result = null;
try { result = window.relay.providerAuthStatus ? await window.relay.providerAuthStatus() : null; }
catch { result = null; }
if (result?.providers) applyProviderAuthResult(result);
// If the readiness probe itself is unavailable, main remains the
// authority and returns the structured provider_not_ready result below.
if (!result || result.ok === false || !result.providers) return true;
if (providerReadyForWork(provider)) return true;
showWorkProviderPrompt(id, provider);
return false;
}
function chooseConnectedWorkProvider(id, provider) {
const key = String(id || "");
const app = providerAppName(provider);
const spec = AGENT_ROUTES[app];
setRoute(key, { app, model:spec.model, effort:spec.effort });
workProviderPrompts.delete(key);
clearRowNote(key);
setRowNote(key, `${app} connected. This Task is ready to start.`, "ok");
fadeRowNoteLater(key, 3000);
if (activeView === "reader" && String(readerId) === key) {
readerTab = "agent";
readerWorkVisible = false;
renderReader();
} else renderAll();
}
function settleWorkProviderPrompts(provider, error = "") {
for (const [id, prompt] of workProviderPrompts) {
if (prompt.requested !== provider && prompt.busy !== provider) continue;
if (!error && providerReadyForWork(provider)) chooseConnectedWorkProvider(id, provider);
else if (error) {
workProviderPrompts.set(id, { ...prompt, busy:"", error:String(error) });
if (activeView === "reader" && String(readerId) === String(id)) renderReader();
}
}
}
function workProviderPromptHtml(r) {
const prompt = workProviderPrompts.get(String(r.id)) || {};
const busyProvider = prompt.busy || providerAuthBusy;
const row = (provider) => {
const app = providerAppName(provider);
const item = providerStatusForWork(provider);
const connected = providerReadyForWork(provider);
const busy = busyProvider === provider;
const disabled = Boolean(busyProvider && !busy) || Boolean(item?.authLoaded && (!item.installed || !item.enabled) && !connected);
const label = connected ? `Use ${app}` : busy ? `Connecting ${app}…` : `Connect ${app}`;
const context = connected ? "Ready for Tasks" : provider === "codex" ? "ChatGPT subscription" : "Claude subscription";
return `<button class="th-host-action" type="button" data-work-provider="${provider}" data-work-provider-relay="${esc(r.id)}" data-host="${provider}"${busy || disabled ? " disabled" : ""}>
<img class="th-host-logo" src="${provider === "codex" ? "codexMark.svg" : "claudeCodeMark.svg"}" alt="" />
<span class="th-host-copy"><span class="th-host-label">${esc(label)}</span><span class="th-host-context">${esc(context)}</span></span>
<span class="th-host-arrow" aria-hidden="true">→</span>
</button>`;
};
return `<div class="work-connect" role="region" aria-label="Agent connection required">
<div class="work-connect-title">Connect an agent to start this Task</div>
<div class="work-connect-copy">Work runs in Codex or Claude Code using your existing subscription. Choose one to connect and continue.</div>
<div class="work-connect-actions">${row("codex")}${row("claude")}</div>
<div class="work-connect-error" role="status" aria-live="polite">${esc(prompt.error || "")}</div>
</div>`;
}
function workProviderPromptDockHtml() {
return `<div class="ta-dock inline work-connect-dock" data-stop="1"><div class="qr ta-qr col">
<textarea rows="1" class="ta-note" placeholder="Connect an agent to send a follow-up…" disabled></textarea>
<div class="ta-rail"><div class="rt-chips"><span class="rt-chip static"><span class="sq"></span>No agent connected</span></div>
<span class="rt-spacer"></span><span class="ta-acts"><button class="ta-later" type="button" disabled>Open in agent</button><button class="ta-approve" type="button" disabled>Send</button></span>
</div></div></div>`;
}
// The route menu: the island's night material, one list, current choice
// wears the blue square.
let routeMenuEl = null;
function closeRouteMenu() { if (routeMenuEl) { routeMenuEl.remove(); routeMenuEl = null; } }
function openRouteMenu(anchor, kind, requestId, rerender) {
closeRouteMenu();
const r = routeFor(requestId);
const items = kind === "app" ? Object.keys(AGENT_ROUTES) : kind === "model" ? AGENT_ROUTES[r.app].models : AGENT_ROUTES[r.app].efforts;
const current = kind === "app" ? r.app : kind === "model" ? r.model : r.effort;
routeMenuEl = document.createElement("div");
routeMenuEl.className = "rt-menu";
routeMenuEl.innerHTML = items.map((it) => {
const sqColor = kind === "app" ? vendorSq(it) : vendorSq(r.app);
return `<button type="button" class="rt-item${it === current ? " on" : ""}" data-val="${esc(it)}"><span class="sq" style="${it === current ? `background:${sqColor}` : ""}"></span>${esc(it)}</button>`;
}).join("");
document.body.appendChild(routeMenuEl);
const a = anchor.getBoundingClientRect();
routeMenuEl.style.left = Math.max(8, Math.min(a.left - 6, window.innerWidth - routeMenuEl.offsetWidth - 8)) + "px";
routeMenuEl.style.top = Math.max(8, a.top - routeMenuEl.offsetHeight - 8) + "px";
for (const b of routeMenuEl.querySelectorAll(".rt-item")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
const val = b.getAttribute("data-val");
if (kind === "app") { const spec = AGENT_ROUTES[val]; setRoute(requestId, { app: val, model: spec.model, effort: spec.effort }); }
else if (kind === "model") setRoute(requestId, { model: val });
else setRoute(requestId, { effort: val });
closeRouteMenu();
rerender();
});
}
setTimeout(() => document.addEventListener("mousedown", (e) => {
if (routeMenuEl && !routeMenuEl.contains(e.target)) closeRouteMenu();
}, { capture: true, once: true }), 0);
}
// ---- request controls, ONE component ----
// The reader's sticky dock and the unfolded inline form (conversation
// bubbles, board rows) are the same markup; `inline` only drops the sticky
// page-bottom chrome. Reply boxes talk to people, this box talks to your
// agent, and Approve sits where Send sits.
function taskStatusLine(r, { inline = false } = {}) {
const state = taskBoardState(r.id);
if (state === "waiting" || state === "running" || state === "done") return "";
if (state === "stopped") {
const recovery = requestHasNativeSession(r) ? "Send a follow-up to resume it." : "Start it again.";
return `<div class="request-terminal-status${inline ? " inline" : ""}"><b>Didn't finish — the agent stopped before it reported back. ${recovery}</b></div>`;
}
return `<div class="request-terminal-status${inline ? " inline" : ""}"><b>Parked — waiting in Tasks</b></div>`;
}
// --- The run stream ---------------------------------------------------------
// The reader MORPHS IN PLACE when a request starts (§2.15 / §4b): the kicker
// says who is working and for how long, and the agent's own events accrete
// under the letter in observed order. The card that replaced the old preview
// window shipped without any of this, so "Started" was the last thing the
// reader ever saw while the agent worked in the dark.
// --- Scheduled requests ---------------------------------------------------
// A SCHEDULE is a standing contract: this Task, on this cadence, on this
// route, until this end. It keeps its own shelf on the board — "On a
// schedule" — because a request between runs is not Running and not Waiting,
// and squatting in either would be a lie about its state.
let schedules = [];
let schedulesLoadedAt = 0;
function loadSchedules(force = false) {
if (!window.relay.schedules) return;
// A schedule can be written by the daemon (a fire lands, a contract ends)
// or by another window, so a boot-time read goes stale. Cheap file read,
// refreshed whenever the surfaces that show cadence are on screen.
if (!force && Date.now() - schedulesLoadedAt < 4000) return;
schedulesLoadedAt = Date.now();
window.relay.schedules().then((rows) => {
schedules = Array.isArray(rows) ? rows : [];
if (activeView === "tasks") renderTasksBoard();
if (activeView === "reader") renderReader();
}).catch(() => {});
}
function scheduleFor(relayId) {
return schedules.find((sc) => String(sc.relayId || "") === String(relayId) && sc.state !== "ended" && sc.state !== "declined") || null;
}
async function saveSchedule(input) {
if (!window.relay.scheduleSave) return { ok: false, error: "unavailable" };
const res = await window.relay.scheduleSave(input).catch((e) => ({ ok: false, error: String(e && e.message || e) }));
loadSchedules(true);
return res;
}
const runFeedTimers = new Map(); // relayId -> interval handle
const runFeedSeen = new Map(); // relayId -> last feed, so a re-render paints from memory
const runFeedSettle = new Map(); // relayId -> polls since completion (grace window)
const runPaintedSegments = new Map(); // relayId -> stable segment keys already animated
const runFeedEpoch = new Map(); // relayId -> generation; invalidates an awaited stale poll
const runActivityOpen = new Set(); // turn keys whose completed activity is expanded
const runDisclosureControllers = new Map();
const runScrollControllers = new Map();
const runScrollSnapshots = new Map();
const runTurnNodes = new Map(); // relayId -> keyed turn nodes, preserved across feed events
const runSummaryStabilizer = RelayWorkUI.createSummaryStabilizer({ delayMs: 1000 });
const runFeedPending = new Set(); // a user turn is visible while native transport starts
const runFeedRevision = new Map(); // relayId -> last monotonic main-owned snapshot revision
const MAX_NATIVE_PRESENTATION_TURNS = 20; // broker bounds snapshots before IPC
const runQueuedMessages = new Map(); // relayId -> follow-ups waiting for this turn to finish
const runQueueSending = new Set(); // relayId -> one queue transport at a time
const runPushFeeds = new Set(); // relay ids attached to main's native event bridge
function runFeedFor(id) { return runFeedSeen.get(String(id || "")) || null; }
function elapsedClock(fromISO, toISO) {
const from = fromISO ? new Date(fromISO).getTime() : 0;
if (!from) return "";
const to = toISO ? new Date(toISO).getTime() : Date.now();
const secs = Math.max(0, Math.round((to - from) / 1000));
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function humanRunDuration(durationMs) {
if (!Number.isFinite(durationMs) || durationMs < 0) return "";
const seconds = Math.max(1, Math.round(durationMs / 1000));
if (seconds < 60) return `${seconds} sec`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
if (minutes < 60) return remainder ? `${minutes} min ${remainder} sec` : `${minutes} min`;
const hours = Math.floor(minutes / 60);
const minuteRemainder = minutes % 60;
return minuteRemainder ? `${hours} h ${minuteRemainder} min` : `${hours} h`;
}
// An act reads as a SENTENCE: the verb belongs to the tool, the object is a
// noun phrase. Falling back to "Ran" + whatever argument came first produced
// "Ran David Kariuki" for a contact lookup (David, live) — a verb borrowed
// from the shell wrapped around a search query.
const ACTS = {
Bash: { active: "Running", done: "Ran", from: ["description", "command"], kind: "command" },
Read: { active: "Reading", done: "Read", from: ["file_path"], kind: "read", objectType: "file" },
Write: { active: "Writing", done: "Wrote", from: ["file_path"], kind: "edit", objectType: "file" },
Edit: { active: "Editing", done: "Edited", from: ["file_path"], kind: "edit", objectType: "file" },
Glob: { active: "Searching files for", done: "Searched files for", from: ["pattern"], kind: "search" },
ListFiles: { active: "Listing", done: "Listed", from: ["path"], kind: "list", objectType: "file" },
Grep: { active: "Searching for", done: "Searched for", from: ["pattern"], kind: "search" },
WebFetch: { active: "Reading", done: "Read", from: ["url"], kind: "web" },
WebSearch: { active: "Searching the web for", done: "Searched the web for", from: ["query"], kind: "web" },
Task: { active: "Delegating", done: "Delegated", from: ["description"], kind: "call" },
shell: { active: "Running", done: "Ran", from: ["command"], kind: "command" },
apply_patch: { active: "Editing", done: "Edited", from: ["path", "file_path"], kind: "edit", objectType: "file" },
relay_send: { active: "Sending", done: "Sent", from: [], object: "the completion back to the sender", kind: "call" },
relay_contacts_search: { active: "Searching for", done: "Searched for", from: ["query"], kind: "search" },
relay_thread_fetch: { active: "Reading", done: "Read", from: [], object: "the conversation", kind: "read" },
relay_chat_fetch: { active: "Reading", done: "Read", from: [], object: "the conversation", kind: "read" },
relay_inbox_list: { active: "Reading", done: "Read", from: [], object: "the inbox", kind: "read" },
relay_sent_list: { active: "Reading", done: "Read", from: [], object: "sent relays", kind: "read" },
relay_groups_list: { active: "Listing", done: "Listed", from: [], object: "channels", kind: "list" },
relay_chats_list: { active: "Listing", done: "Listed", from: [], object: "conversations", kind: "list" },
};
// "relay_contacts_search" -> "relay contacts search": a readable name is a
// better object than a raw argument when the tool is one we do not know.
function humanTool(tool) {
return String(tool || "")
.replace(/^mcp__.*?__/, "")
.replace(/[_-]+/g, " ")
.trim();
}
function parsedActInput(rec) {
if (rec?.input && typeof rec.input === "object") return rec.input;
try {
const value = JSON.parse(String(rec?.input || "{}"));
return value && typeof value === "object" ? value : {};
} catch { return {}; }
}
function activityPathLabel(value) {
const full = String(value == null ? "" : value).trim().replace(/[\\/]+$/, "");
if (!full) return { label: "", full: "" };
const label = full.split(/[\\/]/).filter(Boolean).at(-1) || full;
return { label, full };
}
function compactActivityText(value, max = 96) {
const oneLine = String(value == null ? "" : value).replace(/\s+/g, " ").trim();
if (oneLine.length <= max) return oneLine;
const head = oneLine.slice(0, Math.max(1, max - 1));
const word = head.replace(/\s+\S*$/, "").trim();
return `${word || head.trimEnd()}…`;
}
function activityIcon(kind) {
const paths = {
command: `<path d="M2.5 3.5h11v9h-11zM4.5 6l2 2-2 2M8 10h3"/>`,
read: `<path d="M4 2.5h5l3 3v8H4zM9 2.5v3h3M6 8h4M6 10.5h3"/>`,
edit: `<path d="m3 11.7.4-2.3 6.8-6.8 2.2 2.2-6.8 6.8zM8.9 3.9l2.2 2.2"/>`,
search: `<circle cx="7" cy="7" r="3.5"/><path d="m9.7 9.7 3 3"/>`,
list: `<path d="M5.5 4h7M5.5 8h7M5.5 12h7M3 4h.1M3 8h.1M3 12h.1"/>`,
web: `<circle cx="8" cy="8" r="5.5"/><path d="M2.7 8h10.6M8 2.5c1.6 1.5 2.4 3.4 2.4 5.5S9.6 12 8 13.5C6.4 12 5.6 10.1 5.6 8S6.4 4 8 2.5"/>`,
call: `<path d="M5 3.5h7.5V11H9l-2.5 2v-2H5zM3.5 5.5H3v7h1.5"/>`,
};
return `<svg class="act-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">${paths[kind] || paths.call}</svg>`;
}
function actHtml(act, active = false) {
const object = compactActivityText(act.object);
const objectClass = act.objectType === "file" ? " file" : act.kind === "command" ? " command" : "";
const title = act.objectType === "file" && act.fullObject && act.fullObject !== object ? ` title="${esc(act.fullObject)}"` : "";
const progress = active && act.outputPreview
? `<span class="rd-act-progress" title="${esc(act.outputPreview)}">${esc(act.outputPreview)}</span>`
: "";
return `<div class="rd-act${active ? " live" : ""}" data-act="1" data-act-kind="${esc(act.kind || "call")}">${activityIcon(act.kind)}<span class="act-stack"><span class="act-copy"><span class="averb">${esc(act.verb)}</span>${object ? `<span class="aobj${objectClass}"${title}>${esc(object)}</span>` : ""}</span>${progress}</span></div>`;
}
function actOf(rec, active = false) {
if (!rec || rec.type !== "tool_call") return null;
const raw = String(rec.tool || "");
// Codex reports a bare name ("relay_contacts_search"); Claude reports the
// namespaced one ("mcp__relay__relay_contacts_search"). Same act.
const tool = raw.replace(/^mcp__.*?__/, "");
if (tool === "ToolSearch") return null; // the agent loading its own tools is plumbing
const input = parsedActInput(rec);
const spec = ACTS[tool];
const output = rec.outputPreview ? { outputPreview: String(rec.outputPreview) } : {};
if (spec) {
const verb = active ? spec.active : spec.done;
if (spec.object) return { verb, object: spec.object, kind: spec.kind, ...output };
for (const key of spec.from) {
const value = String(input[key] == null ? "" : input[key]).trim();
if (!value) continue;
if (spec.objectType === "file") {
const path = activityPathLabel(value);
return { verb, object: path.label, fullObject: path.full, objectType: "file", kind: spec.kind, ...output };
}
return { verb, object: compactActivityText(value), fullObject: value, kind: spec.kind, ...output };
}
return { verb, object: humanTool(tool), kind: spec.kind, ...output };
}
// Unknown tool: name the TOOL, never dress an argument in a borrowed verb.
return { verb: active ? "Calling" : "Called", object: humanTool(tool) || "a tool", kind: "call", ...output };
}
function activitySummary(acts) {
const counts = new Map();
for (const act of acts) {
const kind = ["read", "search", "list"].includes(act.kind) ? "exploration" : (act.kind || "call");
counts.set(kind, (counts.get(kind) || 0) + 1);
}
const noun = {
edit: (n) => `edited ${n === 1 ? "a file" : "files"}`,
exploration: () => "read files",
web: () => "searched the web",
command: (n) => `ran ${n === 1 ? "a command" : "commands"}`,
call: (n) => `called ${n === 1 ? "a tool" : "tools"}`,
};
const phrases = [...counts].map(([kind, count]) => (noun[kind] || noun.call)(count));
if (!phrases.length) return "Worked";
const joined = phrases.length === 1 ? phrases[0]
: phrases.length === 2 ? `${phrases[0]} and ${phrases[1]}`
: `${phrases.slice(0, -1).join(", ")}, and ${phrases.at(-1)}`;
return joined.charAt(0).toUpperCase() + joined.slice(1);
}
function toolCallHasResult(records, recordIndex) {
for (let i = recordIndex + 1; i < records.length; i += 1) {
const rec = records[i] || {};
if (rec.type === "tool_result") return true;
if (rec.type === "tool_call") return false;
}
return false;
}
function fadedRunSegment(part, delay) {
return part.html.replace(
/^<div class="/,
`<div data-run-segment="${esc(part.key)}" style="--fade-delay:${delay}ms" class="rd-fade `,
);
}
// Provider transports carry Relay's authorized result-return contract in the
// same native user event so the worker cannot miss it. It is runtime
// metadata, not text the human typed. Codex renders user-authored content as
// the right-side message and keeps developer/runtime instructions out of the
// conversation; apply the same partition here.
function visibleRunUserMessage(text) {
const raw = String(text || "");
// Claude's Monitor writes lifecycle notices into its JSONL as synthetic
// role:user messages. They are provider system events, not human turns.
if (/^\s*<task-notification\b/i.test(raw)) return "";
// A newly-created native provider turn carries the immutable Relay
// documents as context and the human's optional composer note in one
// transport event. The documents already live behind Show request; they
// are not a human chat message. Paint only what the human actually typed,
// exactly as Codex does with its user item.
const documents = raw.match(/<relay-documents>\s*([\s\S]*?)\s*<\/relay-documents>/i);
if (documents) {
return raw
.replace(/<relay-envelope>[\s\S]*?<\/relay-envelope>/gi, "")
.replace(/<relay-documents>[\s\S]*?<\/relay-documents>/gi, "")
.replace(/<relay-runtime-contract>[\s\S]*?<\/relay-runtime-contract>/gi, "")
.replace(/^\s*Begin the task as briefed\.\s*/i, "")
.trim();
}
// Historical in-flight runs may still carry the old single-body wrapper.
const body = raw.match(/<relay-body>\s*([\s\S]*?)\s*<\/relay-body>/i);
if (body) return String(body[1] || "").trim();
return raw
.replace(/<relay-envelope>[\s\S]*?<\/relay-envelope>/gi, "")
.replace(/<relay-documents>[\s\S]*?<\/relay-documents>/gi, "")
.replace(/<relay-runtime-contract>[\s\S]*?<\/relay-runtime-contract>/gi, "")
.replace(/^\s*Begin the task as briefed\.\s*/i, "")
.trim();
}
function safeWorkImageUrl(value) {
const url = String(value || "");
return /^(?:blob:|data:image\/(?:png|jpeg|gif|webp);base64,)/i.test(url) ? url : "";
}
function runUserAttachmentsHtml(attachments) {
const rows = Array.isArray(attachments) ? attachments : [];
if (!rows.length) return "";
const imageCount = rows.filter((attachment) => attachment?.image || attachment?.kind === "image" || /^image\//i.test(String(attachment?.mimeType || ""))).length;
return `<div class="rd-user-files${imageCount === 1 ? " one" : ""}">${rows.map((attachment, attachmentIndex) => {
const name = String(attachment?.name || "Image");
const image = attachment?.image || attachment?.kind === "image" || /^image\//i.test(String(attachment?.mimeType || ""));
const previewUrl = safeWorkImageUrl(attachment?.previewUrl || attachment?.url);
if (image) return `<figure class="rd-user-image-wrap${previewUrl ? " ready" : ""}" data-work-attachment="${attachmentIndex}"><span class="rd-user-image-frame">${previewUrl ? `<img class="rd-user-image" src="${esc(previewUrl)}" alt="${esc(name)}">` : ""}</span><figcaption class="rd-user-image-cap">${esc(name)}</figcaption></figure>`;
return `<span class="rd-user-file" data-work-attachment="${attachmentIndex}">${esc(name)}</span>`;
}).join("")}</div>`;
}
function hydrateRunAttachmentPreviews(node, attachments, context) {
if (!window.relay.runFeedAttachment) return;
(Array.isArray(attachments) ? attachments : []).forEach((attachment, attachmentIndex) => {
const image = attachment?.image || attachment?.kind === "image" || /^image\//i.test(String(attachment?.mimeType || ""));
const slot = node.querySelector(`[data-work-attachment="${attachmentIndex}"]`);
if (!image || !slot || slot.classList.contains("ready") || slot.dataset.loading === "1") return;
slot.dataset.loading = "1";
window.relay.runFeedAttachment({ ...context, itemId:String(context.itemId || ""), attachmentId:String(attachment?.id || ""), attachmentIndex }).then((result) => {
if (!slot.isConnected) return;
const mimeType = /^image\/(?:png|jpeg|gif|webp)$/i.test(String(result?.mimeType || "")) ? String(result.mimeType) : "";
const encoded = mimeType && /^[A-Za-z0-9+/=]+$/.test(String(result?.dataBase64 || "")) ? `data:${mimeType};base64,${result.dataBase64}` : "";
const src = safeWorkImageUrl(result?.dataUrl || encoded);
if (!result?.ok || !src) { slot.dataset.loading = ""; return; }
const img = document.createElement("img"); img.className = "rd-user-image"; img.alt = String(attachment?.name || "Image");
img.addEventListener("load", () => slot.classList.add("ready"), { once:true });
img.src = src;
slot.querySelector(".rd-user-image-frame")?.append(img);
}).catch(() => { if (slot.isConnected) slot.dataset.loading = ""; });
});
}
function runFeedPresentation(feed) {
// The main process owns native event reduction. Renderer input is the
// bounded, redacted, conversation-level presentation snapshot; it carries
// every turnId and explicit placement (user, collapsible, final, blocking,
// standalone, post). Never infer phase === "final_answer" from newest prose.
const snapshot = feed?.presentation || feed?.workPresentation || null;
return RelayWorkUI.normalizeConversationView(snapshot || { turns:[] });
}
function nativeShimmerMarkup(label, active) {
const value = esc(label || "Thinking");
if (!active) return `<span>${value}</span>`;
return `<span class="native-cadenced-shimmer" data-native-cadenced-shimmer>${value}<span aria-hidden="true" class="native-cadenced-shimmer-sweep"><span class="native-cadenced-shimmer-highlight">${value}</span></span></span>`;
}
function bindNativeCadencedShimmer(node) {
node._nativeShimmerDestroy?.();
const shimmer = node.querySelector?.("[data-native-cadenced-shimmer]");
if (!shimmer || matchMedia("(prefers-reduced-motion: reduce)").matches) { node._nativeShimmerDestroy = null; return; }
let removeTimer = 0;
const pulse = () => {
shimmer.classList.remove("active");
void shimmer.offsetWidth;
shimmer.classList.add("active");
clearTimeout(removeTimer);
removeTimer = setTimeout(() => shimmer.classList.remove("active"), 1000);
};
let cadenceTimer = 0;
const cadence = () => { pulse(); cadenceTimer = setTimeout(cadence, 4000); };
const initialTimer = setTimeout(cadence, 600);
node._nativeShimmerDestroy = () => {
clearTimeout(initialTimer); clearTimeout(removeTimer); clearTimeout(cadenceTimer);
shimmer.classList.remove("active");
};
}
function runUnitMarkup(unit) {
if (!unit) return "";
if (unit.type === "activity") {
const source = unit.activity || {};
const active = source.status === "inProgress";
return actHtml({ ...source, verb:active ? source.activeVerb : source.doneVerb, active }, active)
.replace(/^<div /, `<div data-work-detail-id="${esc(unit.id || "")}" `);
}
if (unit.type === "exploration" || unit.type === "activityGroup") {
const rows = (unit.items || []).map((item) => runUnitMarkup(item)).filter(Boolean).join("");
const active = unit.active === true || (unit.items || []).some((item) => item.activity?.status === "inProgress");
const label = active ? (unit.activeSummary || "Thinking") : (unit.summary || "Worked");
return rows ? `<div class="rd-semantic-group rd-exploration" data-semantic-disclosure data-disclosure-state="collapsed"><button type="button" class="rd-activity-toggle" data-work-disclosure-toggle aria-expanded="false"><span>${nativeShimmerMarkup(label, active)}</span><svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2.5 7.5 6 4 9.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button><div class="rd-activity-body" data-work-disclosure-body aria-hidden="true" inert><div class="rd-activity-body-inner">${rows}</div></div></div>` : "";
}
if (unit.type === "message" || unit.type === "plan" || unit.type === "compaction") {
return unit.text ? `<div class="rd-said work-markdown" data-markdown-animated="true">${readerParagraphs(unit.text)}</div>` : "";
}
if (unit.type === "artifact") {
const label = String(unit.text || unit.label || "Created an artifact");
return actHtml({ verb:"Created", object:activityPathLabel(label).label, fullObject:label, objectType:"file", kind:"edit" });
}
if (unit.type === "request") {
const label = String(unit.text || "Input required");
return `<div class="rd-blocking" role="status" aria-live="polite"><span>${readerParagraphs(label)}</span></div>`;
}
if (unit.type === "error") {
return `<div class="rd-error" role="alert"><span aria-hidden="true">!</span><span>${esc(unit.text || unit.message || "The run hit an error")}</span></div>`;
}
if (unit.type === "retry") return `<div class="rd-status" role="status" aria-live="polite">${esc(unit.text || "Reconnecting")}</div>`;
return unit.text ? `<div class="rd-said work-markdown">${readerParagraphs(unit.text)}</div>` : "";
}
function keyedChildren(parent, items, update, animateFresh = false) {
const existing = new Map([...parent.children].map((node) => [node.dataset.workKey, node]));
let cursor = parent.firstElementChild;
for (const item of items) {
let node = existing.get(item.key);
const fresh = !node;
if (!node) {
node = document.createElement(item.tag || "div");
node.dataset.workKey = item.key;
} else existing.delete(item.key);
update(node, item);
if (fresh && animateFresh) node.dataset.workAnimate = "1";
if (node.dataset.workAnimate === "1") node.classList.add("rd-fade");
if (node !== cursor) parent.insertBefore(node, cursor);
cursor = node.nextElementSibling;
}
for (const node of existing.values()) {
node._workDisclosure?.destroy?.();
node._workSemanticDisclosure?.destroy?.();
node._nativeShimmerDestroy?.();
if (node._workSummaryKey) runSummaryStabilizer.clear(node._workSummaryKey);
node.remove();
}
}
function updateMarkupNode(node, markup) {
if (node.dataset.workMarkup === markup) return;
// Only the changed semantic unit is replaced; sibling turns, disclosure
// state, selection, and the composer remain mounted.
node._workSemanticDisclosure?.destroy?.();
node._nativeShimmerDestroy?.();
node.innerHTML = markup;
node.dataset.workMarkup = markup;
for (const link of node.querySelectorAll("a[href]")) {
link.target = "_blank"; link.rel = "noreferrer noopener";
}
}
function bindSemanticDisclosure(node) {
const root = node.matches?.("[data-semantic-disclosure]") ? node : node.querySelector?.("[data-semantic-disclosure]");
if (!root) return;
if (!root._workSemanticDisclosure) root._workSemanticDisclosure = RelayWorkUI.createDisclosureController(root, { initiallyExpanded:false });
node._workSemanticDisclosure = root._workSemanticDisclosure;
bindNativeCadencedShimmer(node);
}
function bindRunUnitDetail(node, unit, context) {
if (!window.relay.runFeedDetail) return;
const targets = [
...(node.matches?.("[data-work-detail-id]") ? [node] : []),
...node.querySelectorAll("[data-work-detail-id]"),
];
for (const target of targets) {
target.tabIndex = 0; target.setAttribute("role", "button");
target.setAttribute("aria-expanded", String(Boolean(target._workDetailOpen)));
target._workDetailContext = { ...context, itemId:target.dataset.workDetailId };
if (target._workDetailBound) continue;
const toggle = async (event) => {
event?.stopPropagation?.();
if (target._workDetailOpen) {
target._workDetailOpen = false; target.setAttribute("aria-expanded", "false");
target.removeAttribute("aria-controls");
target._workDetailHost?.remove(); target._workDetailHost = null; return;
}
const generation = (target._workDetailGeneration || 0) + 1;
target._workDetailGeneration = generation;
let result = await window.relay.runFeedDetail(target._workDetailContext).catch((error) => ({
ok:false,
error:String(error?.message || error || "Detail could not be loaded."),
}));
// Provider adapters can recover an item whose persisted native turn id
// predates the current presentation key. Item ids are session-scoped;
// retrying without the optional turn hint is still constrained by the
// exact live Relay/session subscription in main.
if (!result?.ok && target._workDetailContext?.turnId) {
result = await window.relay.runFeedDetail({ ...target._workDetailContext, turnId:"" }).catch((error) => ({
ok:false,
error:String(error?.message || error || "Detail could not be loaded."),
}));
}
if (!target.isConnected || target._workDetailGeneration !== generation) return;
if (!result?.ok) {
const host = document.createElement("div");
host.className = "work-unit-detail error";
host.setAttribute("role", "status");
host.textContent = String(result?.error || "Detail is unavailable for this activity.");
host.id = `work-detail-${String(target.dataset.workDetailId || "item").replace(/[^A-Za-z0-9_-]/g, "-")}`;
target.after(host); target._workDetailHost = host; target._workDetailOpen = true;
target.setAttribute("aria-controls", host.id);
target.setAttribute("aria-expanded", "true");
return;
}
const detail = result.detail || {};
const execution = detail.execution || null;
const lines = execution
? [execution.title, execution.command || execution.script, execution.transcript || execution.result]
.filter((value) => value != null && String(value).trim())
.map((value) => typeof value === "string" ? value : JSON.stringify(value, null, 2))
: [typeof detail === "string" ? detail : JSON.stringify(detail, null, 2)];
const host = document.createElement("pre"); host.className = "work-unit-detail";
host.textContent = lines.join("\n\n");
host.id = `work-detail-${String(target.dataset.workDetailId || "item").replace(/[^A-Za-z0-9_-]/g, "-")}`;
// The activity header is a horizontal flex row. Detail is its next
// block sibling, as in Codex; nesting the pre inside that flex row sent
// the content off the right edge and made a successful click look dead.
target.after(host); target._workDetailHost = host; target._workDetailOpen = true;
target.setAttribute("aria-controls", host.id);
target.setAttribute("aria-expanded", "true");
};
target.addEventListener("click", toggle);
target.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(event); } });
target._workDetailBound = true;
}
}
function updateActivityDisclosure(node, item, relayKey) {
node.className = "rd-activity";
const openKey = `${relayKey}:${item.turnId}:${item.segment}`;
node._workSummaryKey = openKey;
if (!node._workBuilt) {
const button = document.createElement("button");
button.type = "button"; button.className = "rd-activity-toggle";
button.setAttribute("data-work-disclosure-toggle", "");
button.innerHTML = `<span data-work-summary></span><svg class="chev" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2.5 7.5 6 4 9.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const body = document.createElement("div");
body.className = "rd-activity-body"; body.setAttribute("data-work-disclosure-body", "");
const inner = document.createElement("div"); inner.className = "rd-activity-body-inner";
body.append(inner); node.append(button, body); node._workInner = inner; node._workBuilt = true;
node.dataset.workDisclosure = `${item.turnId}:${item.segment}`;
node._workDisclosure = RelayWorkUI.createDisclosureController(node, {
initiallyExpanded:runActivityOpen.has(openKey),
onStateChange:(state) => {
if (state === "opening" || state === "expanded") runActivityOpen.add(openKey);
else runActivityOpen.delete(openKey);
},
});
}
const button = node.querySelector("[data-work-disclosure-toggle]");
button.setAttribute("aria-label", `${item.label}. ${item.units.length} previous messages.`);
const summaryNode = button.querySelector("[data-work-summary]");
runSummaryStabilizer.offer(openKey, item.label, (label) => { summaryNode.textContent = label; });
keyedChildren(node._workInner, item.units, (unitNode, unit) => {
unitNode.className = "work-unit";
updateMarkupNode(unitNode, runUnitMarkup(unit));
bindSemanticDisclosure(unitNode);
bindRunUnitDetail(unitNode, unit, { relayId:relayKey, sessionId:item.sessionId || "", turnId:item.turnId });
}, true);
}
function paintRunStream(id, feed) {
const key = String(id);
const host = document.querySelector(`[data-run-stream="${CSS.escape(key)}"]`);
if (!host) return;
const presentation = runFeedPresentation(feed);
const turns = presentation.turns.map((turn, turnIndex) => {
const blocks = RelayWorkUI.partitionTurn({ ...turn, units:(turn.units || []).filter((unit) => runUnitMarkup(unit)) })
.map((block) => block.type === "activity" ? { ...block, sessionId:feed?.sessionId || feed?.presentation?.sessionId || "" } : block);
return { ...turn, key:String(turn.turnId || turn.key || `turn-${turnIndex}`), blocks };
});
const snapshot = runScrollControllers.get(key)?.controller.capture();
keyedChildren(host, turns, (turnNode, turn) => {
turnNode.className = `work-turn ${turn.settled ? "settled" : "active"}`;
turnNode.dataset.workTurn = turn.key; turnNode.dataset.workAnchor = turn.key;
let agent = turnNode.querySelector(":scope > .work-agent");
if (!agent) { agent = document.createElement("div"); agent.className = "work-agent"; turnNode.append(agent); }
keyedChildren(agent, turn.blocks, (blockNode, block) => {
if (block.type === "activity") return updateActivityDisclosure(blockNode, block, key);
if (block.type === "user") {
blockNode.className = "rd-user-wrap";
blockNode._workUserText = block.unit.text || "";
const userCopy = String(block.unit.text || "").trim();
updateMarkupNode(blockNode, `${runUserAttachmentsHtml(block.unit.attachments)}${userCopy ? `<div class="rd-user work-markdown" tabindex="0" role="group" aria-label="Your message"><div class="rd-user-copy">${readerParagraphs(userCopy)}</div></div>` : ""}<div class="rd-user-actions"><button type="button" aria-label="Copy message">Copy</button><button type="button" aria-label="Edit message">Edit</button></div>`);
hydrateRunAttachmentPreviews(blockNode, block.unit.attachments, { relayId:key, sessionId:feed?.sessionId || feed?.presentation?.sessionId || "", turnId:turn.turnId || turn.key, itemId:block.unit.id });
if (!blockNode._workUserBound) {
blockNode.addEventListener("click", (event) => {
const action = event.target.closest("button[aria-label]");
if (!action) return;
if (action.getAttribute("aria-label") === "Copy message") navigator.clipboard?.writeText(blockNode._workUserText || "");
if (action.getAttribute("aria-label") === "Edit message") {
const composer = document.querySelector(`[data-work-footer="${CSS.escape(key)}"] .ta-note`);
if (!composer) return;
composer.value = blockNode._workUserText || "";
requestWorkDrafts.set(key, composer.value);
composer.dispatchEvent(new Event("input", { bubbles:true }));
composer.focus();
}
});
blockNode.addEventListener("dblclick", (event) => {
if (event.target.closest(".rd-user")) blockNode.querySelector('[aria-label="Edit message"]')?.click();
});
blockNode._workUserBound = true;
}
return;
}
blockNode.className = block.type === "divider" ? "worked-for-divider" : block.type === "final" ? "rd-final work-markdown" : "work-unit";
updateMarkupNode(blockNode, block.type === "divider" ? "" : block.type === "final" ? readerParagraphs(block.unit.text || "") : runUnitMarkup(block.unit));
if (block.type === "unit") bindSemanticDisclosure(blockNode);
if (block.type === "final") blockNode.dataset.localConversationFinalAssistant = "true";
if (block.type === "unit") bindRunUnitDetail(blockNode, block.unit, {
relayId:key,
sessionId:feed?.sessionId || feed?.presentation?.sessionId || "",
turnId:turn.turnId || turn.key,
});
}, true);
});
if (presentation.done && !turns.length) {
keyedChildren(host, [{ key:"empty" }], (node) => { node.className = "rd-empty"; node.textContent = "No native run output was captured."; });
}
requestAnimationFrame(() => runScrollControllers.get(key)?.controller.contentChanged(snapshot));
}
// The kicker is the run's sky: who is working, on what, for how long.
function paintRunKicker(id, feed) {
const kicker = document.querySelector(`[data-run-kicker="${CSS.escape(String(id))}"]`);
if (!kicker || !feed) return;
const rt = routeFor(id);
const app = runApp(id, feed);
const runKind = isTaskRow(relayById(id)) ? "Task" : "work";
const providerKnown = !feed?.legacyMissingSession;
const sq = providerKnown ? `<span class="ksq" style="background:${vendorSq(app)}"></span>` : "";
const owner = providerKnown ? ` · ${esc(app.toUpperCase())}` : "";
if (runFeedCompleted(feed)) {
kicker.innerHTML = `${sq}${runKind}${owner} · done`;
return;
}
if (runFeedIsTerminal(feed)) {
const state = String(feed.liveState || "").toLowerCase();
const label = state === "review_ready" ? "finished · awaiting Relay completion" : state === "blocked" ? "needs input" : "didn't finish";
kicker.innerHTML = `${sq}${runKind}${owner} · ${label}`;
return;
}
// The model is the rail's only when the rail still names the app that ran;
// a run started on one app and re-routed later must not borrow the other's.
const rawModel = String(feed.model || (app === rt.app ? rt.model : ""));
const modelName = rawModel.replace(/^claude-/, "").replace(/-(\d+)-(\d+)$/, " $1.$2").replace(/(^|-)\w/g, (m) => m.replace("-", " ").toUpperCase());
const model = modelName ? ` · ${esc(modelName)}` : "";
kicker.innerHTML = feed.completedAt
? `${sq}${runKind} · ${esc(app.toUpperCase())} · done`
: `${sq}${esc(app.toUpperCase())} IS WORKING${model}${feed.turnStartedAt ? ` · ${esc(elapsedClock(feed.turnStartedAt))}` : ""}`;
}
function runAppName(id) {
const feed = runFeedFor(id);
if (feed) return runApp(id, feed);
// A renderer restart clears the in-memory route picker before the first
// feed poll. The persisted native session is the authority during a run;
// falling back to Settings made a real Claude run say “Steer Codex.”
const row = relayById(id);
if (row?.source?.host === "relay-agent-run") {
if (row.source.surface === "codex") return "Codex";
if (row.source.surface === "claude_code") return "Claude Code";
}
if (row?.coworkSessionId || row?.ranOnCowork) return "Claude Cowork";
if (row?.claudeNativeSession?.sessionId || row?.ranOnClaude) return "Claude Code";
if (row?.codexThreadId || row?.codexSessionPath || row?.sessionPath || row?.ranOnCodex) return "Codex";
return routeFor(id).app;
}
// Which app is actually doing the work: the provider of the transcript being
// tailed wins, because that is the session that exists.
function runApp(id, feed) {
const provider = String(feed?.provider || feed?.presentation?.provider || "");
if (provider === "codex") return "Codex";
if (provider === "cowork") return "Claude Cowork";
if (provider === "claude") {
const app = routeFor(id).app;
return /cowork/i.test(String(app)) ? app : "Claude Code";
}
return routeFor(id).app;
}
function stopRunFeed(id) {
const key = String(id);
const handle = runFeedTimers.get(key);
if (handle && handle !== "push") clearInterval(handle);
if (runPushFeeds.delete(key)) window.relay.unwatchRunFeed?.(key);
runFeedTimers.delete(key);
}
function resetRunFeedForNewRun(id, { preserveHistory = false } = {}) {
const key = String(id || "");
runFeedEpoch.set(key, (runFeedEpoch.get(key) || 0) + 1);
stopRunFeed(key);
runFeedSeen.delete(key);
runFeedSettle.delete(key);
runPaintedSegments.delete(key);
}
function appendOptimisticUserTurn(id, body, newTurn, attachments = [], { preserveHistory = true } = {}) {
const key = String(id || "");
const at = new Date().toISOString();
const previous = runFeedSeen.get(key) || null;
const previousState = taskLocal.has(key) ? taskLocal.get(key) : undefined;
if (newTurn) resetRunFeedForNewRun(key, { preserveHistory });
else stopRunFeed(key);
const base = newTurn && !preserveHistory ? null : previous;
const provider = String(base?.provider || (/cowork/i.test(runAppName(key)) ? "cowork" : /codex/i.test(runAppName(key)) ? "codex" : "claude"));
const user = { type: "message", role: "user", text: String(body || ""), attachments, at };
const optimisticId = `optimistic:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
const previousTurns = Array.isArray(base?.presentation?.turns) ? base.presentation.turns : [];
const userUnit = { id:`user:${optimisticId}`, type:"message", role:"user", side:"right", phase:null,
text:String(body || ""), attachments, status:"pending", optimistic:true, placement:"user",
optimisticMode:newTurn ? "newTurn" : "steer",
persistent:true, blocking:false, standalone:false, collapsible:false };
let optimisticTurns;
if (!newTurn && previousTurns.length) {
optimisticTurns = previousTurns.map((turn, index) => index === previousTurns.length - 1
? { ...turn, active:true, settled:false, status:"inProgress", units:[...(turn.units || []), userUnit], users:[...(turn.users || []), userUnit] }
: turn);
} else {
optimisticTurns = [...previousTurns, { key:optimisticId, status:"inProgress", active:true, settled:false, cancelled:false,
users:[userUnit], user:userUnit, units:[userUnit], final:null, finalEligible:false, canCollapse:false,
timing:{ state:"active", durationMs:null, precise:false }, summary:"", empty:false, retryText:[], error:null }];
}
runFeedSeen.set(key, {
...(base || {}),
ok: true,
started: true,
provider,
records: [user, ...(Array.isArray(base?.records) ? base.records : [])],
presentation:{ ...(base?.presentation || {}), provider, turns:optimisticTurns },
optimisticId,
turnStartedAt: at,
completedAt: null,
finalText: "",
liveState: "active",
});
runFeedPending.add(key);
taskLocal.set(key, "running");
renderAll();
return { previous, previousState };
}
function queuedRunMessages(id) {
return runQueuedMessages.get(String(id || "")) || [];
}
function queuedRunMessagesHtml(id) {
const rows = queuedRunMessages(id);
if (!rows.length) return "";
return `<div class="run-queue" aria-label="Queued follow-ups">${rows.map((message) => {
const preview = message.attachments?.find((attachment) => attachment.image && attachment.previewUrl);
const text = String(message.body || "") || (message.attachments?.length === 1 ? "1 image" : `${message.attachments?.length || 0} images`);
return `<div class="run-queued" data-run-queued="${esc(message.id)}">
${preview ? `<img class="run-queued-preview" src="${esc(preview.previewUrl)}" alt="">` : ""}
<span class="run-queued-text">${esc(text)}</span>
<button type="button" class="run-queued-now" data-run-queued-now="${esc(message.id)}" title="Submit without interrupting the model">↳ Steer</button>
<button type="button" class="run-queued-delete" data-run-queued-delete="${esc(message.id)}" aria-label="Delete queued message">×</button>
</div>`;
}).join("")}</div>`;
}
function removeQueuedRunMessage(id, messageId) {
const key = String(id || "");
const next = queuedRunMessages(key).filter((message) => message.id !== messageId);
if (next.length) runQueuedMessages.set(key, next); else runQueuedMessages.delete(key);
}
async function sendQueuedRunMessage(id, messageId, { steerNow = false } = {}) {
const key = String(id || "");
if (runQueueSending.has(key)) return;
const message = queuedRunMessages(key).find((row) => row.id === messageId);
if (!message) return;
runQueueSending.add(key);
const optimistic = appendOptimisticUserTurn(key, message.body, !steerNow, message.attachments);
const result = await window.relay.runSteer(key, message.body, {
newTurn: !steerNow,
model: message.model,
effort: message.effort,
files: message.files,
}).catch((error) => ({ ok: false, error: String(error?.message || error) }));
runFeedPending.delete(key);
runQueueSending.delete(key);
if (result?.ok) {
removeQueuedRunMessage(key, messageId);
requestWorkDrafts.delete(key);
setRowNote(key, steerNow ? "Steered into the current turn." : "Queued follow-up started.", "ok");
fadeRowNoteLater(key, 3000);
renderAll();
return;
}
stopRunFeed(key);
if (optimistic.previous) runFeedSeen.set(key, optimistic.previous); else runFeedSeen.delete(key);
if (optimistic.previousState === undefined) taskLocal.delete(key); else taskLocal.set(key, optimistic.previousState);
setRowNote(key, result?.error || "Could not reach the session.", "err");
renderAll();
}
function runFeedIsTerminal(feed) {
const turn = feed?.presentation?.turns?.at(-1);
if (turn && (turn.settled || turn.cancelled || ["completed", "failed", "cancelled", "interrupted"].includes(String(turn.status)))) return true;
const liveState = String((feed && feed.liveState) || "").toLowerCase();
return !feed?.completedAt && ["review_ready", "blocked", "completed", "failed", "idle", "archived", "stopped", "stalled", "offline"].includes(liveState);
}
function runFeedCompleted(feed) {
const turn = feed?.presentation?.turns?.at(-1);
return Boolean(feed?.completedAt || turn && turn.settled && turn.finalEligible && !turn.cancelled);
}
function preserveOptimisticUsers(feed, previous) {
if (!feed || !previous) return feed;
const optimistic = (previous.records || []).find((record) => record?.role === "user" && record.attachments?.some((attachment) => attachment.previewUrl));
const oldTurns = previous?.presentation?.turns || [];
const oldUsers = oldTurns.flatMap((turn) => turn.units || []).filter((unit) => unit?.placement === "user" && unit.optimistic);
let presentation = feed?.presentation;
if (presentation && oldUsers.length) {
const echoed = new Set();
let turns = (presentation.turns || []).map((turn) => ({
...turn,
units:(turn.units || []).map((unit) => {
if (unit?.placement !== "user") return unit;
const match = oldUsers.find((old) => String(old.text || "") === String(unit.text || ""));
if (!match) return unit;
echoed.add(match.id);
return match.attachments?.length && !unit.attachments?.length ? { ...unit, attachments:match.attachments } : unit;
}),
}));
const pending = oldUsers.filter((unit) => !echoed.has(unit.id));
if (pending.length) {
if (!turns.length) {
turns = [{ key:`optimistic:${Date.now()}`, status:"inProgress", active:true, settled:false, cancelled:false,
users:pending, user:pending[0], units:pending, final:null, finalEligible:false, canCollapse:false,
timing:{ state:"active", durationMs:null, precise:false }, summary:"", empty:false, retryText:[], error:null }];
} else {
const pendingTurns = oldTurns.filter((turn) => (turn.units || []).some((unit) => pending.some((row) => row.id === unit?.id)));
const pendingNewTurns = pendingTurns.filter((turn) => (turn.units || []).some((unit) => unit?.optimisticMode === "newTurn"));
for (const oldTurn of pendingNewTurns) {
if (turns.some((turn) => turn.key === oldTurn.key)) continue;
const users = (oldTurn.units || []).filter((unit) => pending.some((row) => row.id === unit?.id));
turns.push({ ...oldTurn, active:true, settled:false, status:"inProgress", users, user:users[0] || null, units:users });
}
const index = turns.length - 1;
const turn = turns[index];
const ids = new Set((turn.units || []).map((unit) => unit?.id));
const missing = pending.filter((unit) => unit.optimisticMode !== "newTurn" && !ids.has(unit.id));
turns[index] = missing.length ? {
...turn,
active:true,
settled:false,
units:[...(turn.units || []), ...missing],
users:[...(turn.users || []), ...missing],
} : turn;
}
}
presentation = { ...presentation, turns };
}
if (!optimistic) return presentation === feed?.presentation ? feed : { ...feed, presentation };
const text = visibleRunUserMessage(optimistic.text);
const records = Array.isArray(feed.records) ? feed.records.map((record) => {
if (record?.role !== "user" || visibleRunUserMessage(record.text) !== text) return record;
return { ...record, attachments: optimistic.attachments };
}) : [];
return { ...feed, records, ...(presentation ? { presentation } : {}) };
}
function acceptRunFeedUpdate(key, incoming) {
if (!incoming || !document.querySelector(`[data-run-stream="${CSS.escape(key)}"]`)) return;
if (incoming.ok === false && !incoming.presentation) {
presentRunFeedWatchError(key, incoming.error);
return;
}
const revision = Number(incoming.revision) || 0;
if (revision && revision <= (runFeedRevision.get(key) || 0)) return;
if (revision) runFeedRevision.set(key, revision);
const feed = preserveOptimisticUsers(incoming, runFeedSeen.get(key));
runFeedSeen.set(key, feed);
paintRunStream(key, feed); paintRunKicker(key, feed);
const queued = queuedRunMessages(key);
if ((runFeedCompleted(feed) || runFeedIsTerminal(feed)) && queued.length) {
stopRunFeed(key); sendQueuedRunMessage(key, queued[0].id); return;
}
if (runFeedIsTerminal(feed)) {
taskLocal.set(key, runFeedCompleted(feed) ? "done" : "stopped");
const row = relayById(key);
const claim = taskClaimFor(row);
if (claim?.claimant?.self && claim.workState === "working" && !taskStopPending.has(key)) {
taskStopPending.add(key);
void window.relay.taskStop(key).then((result) => {
if (result?.ok && result.taskClaim) applyTaskClaimProjection(row, result.taskClaim);
}).finally(() => {
taskStopPending.delete(key);
renderAll();
});
}
stopRunFeed(key); renderAll(); return;
}
}
function presentRunFeedWatchError(id, error) {
const key = String(id || "");
const cached = runFeedSeen.get(key) || {};
const current = cached.presentation || cached.workPresentation || { turns:[] };
const turns = Array.isArray(current.turns) ? current.turns.map((turn) => ({ ...turn, units:Array.isArray(turn.units) ? turn.units.slice() : [] })) : [];
const message = String(error?.message || error || "The live event stream is unavailable.").replace(/\s+/g, " ").trim().slice(0, 240);
const row = relayById(key) || {};
const completedAt = row.taskCompletedAt || row.workCompletedAt || "";
if (/No provider-native Work session exists/i.test(message) && completedAt) {
const unit = { id:"legacy-work-unavailable", type:"message", placement:"standalone", persistent:true,
text:"This Task is complete. Live Work history is unavailable for this older run." };
const settledTurn = { key:"legacy-work", turnId:"legacy-work", status:"completed", active:false, settled:true,
finalEligible:false, canCollapse:false, units:[unit] };
const next = { ...cached, ok:true, completedAt, liveState:"completed", legacyMissingSession:true,
presentation:{ ...current, provider:"", done:true, turns:[settledTurn] } };
taskLocal.set(key, "done");
runFeedSeen.set(key, next);
paintRunStream(key, next); paintRunKicker(key, next);
return;
}
const unit = { id:"renderer-watch-error", type:"error", placement:"standalone", persistent:true, text:message || "The live event stream is unavailable." };
if (!turns.length) turns.push({ key:"renderer-watch", turnId:"renderer-watch", status:"inProgress", active:true, settled:false, canCollapse:false, units:[unit] });
else {
const latest = { ...turns.at(-1) };
latest.units = [...latest.units.filter((item) => item?.id !== unit.id), unit];
turns[turns.length - 1] = latest;
}
const next = { ...cached, ok:false, error:message, presentation:{ ...current, turns } };
runFeedSeen.set(key, next);
paintRunStream(key, next); paintRunKicker(key, next);
}
const disposeRunFeedUpdates = (window.relay.onRunFeedUpdate || window.relay.onRunFeed)?.((id, feed) => {
const envelope = feed || (id && typeof id === "object" ? id : null);
const relayId = String(envelope?.relayId || (typeof id === "string" ? id : ""));
acceptRunFeedUpdate(relayId, envelope);
});
window.addEventListener("beforeunload", () => { if (typeof disposeRunFeedUpdates === "function") disposeRunFeedUpdates(); }, { once:true });
function startRunFeed(id) {
const key = String(id || "");
if (!key || runFeedPending.has(key) || runFeedTimers.has(key) || (!window.relay.watchRunFeed && !window.relay.runFeed)) return;
const epoch = runFeedEpoch.get(key) || 0;
const tick = async () => {
if (!document.querySelector(`[data-run-stream="${CSS.escape(key)}"]`)) return stopRunFeed(key);
let feed = null;
try { feed = await window.relay.runFeed(key); } catch { return; }
// A retry/continuation may have started while this Task was in
// flight. Never let the old provider/session repaint the new run.
if ((runFeedEpoch.get(key) || 0) !== epoch) return;
if (!feed || !feed.ok) return;
feed = preserveOptimisticAttachments(feed, runFeedSeen.get(key));
runFeedSeen.set(key, feed);
paintRunStream(key, feed);
paintRunKicker(key, feed);
const queued = queuedRunMessages(key);
if ((feed.completedAt || runFeedIsTerminal(feed)) && queued.length) {
stopRunFeed(key);
sendQueuedRunMessage(key, queued[0].id);
return;
}
if (runFeedIsTerminal(feed)) {
taskLocal.set(key, "stopped");
stopRunFeed(key);
renderAll();
return;
}
// Done is a fact from the wire (the agent's completion relay), so the
// board must learn it too — a finished run cannot sit under "Running".
// A run that never produced a line is not working — it is stuck, and the
// page must say so rather than hold "working" indefinitely (David).
if (!feed.completedAt && feed.startedAt) {
const idleMs = Date.now() - new Date(feed.startedAt).getTime();
const silent = !(feed.records || []).length;
if (silent && idleMs > 90000) {
const kicker = document.querySelector(`[data-run-kicker="${CSS.escape(key)}"]`);
if (kicker) kicker.innerHTML = `<span class="ksq" style="background:var(--danger,#B3564D)"></span>${esc(runAppName(key).toUpperCase())} NEVER STARTED`;
const strm = document.querySelector(`[data-run-stream="${CSS.escape(key)}"]`);
if (strm) strm.innerHTML = `<div class="rd-act"><span class="asq" style="background:var(--danger,#B3564D)"></span><span><span class="averb">Nothing ran.</span> <span class="aobj">Open it in ${esc(runAppName(key))} and send it there, or press Start again.</span></span></div>`;
stopRunFeed(key);
return;
}
}
if (feed.completedAt) {
// The open reader owns the runner and its composer. Updating only the
// hidden Tasks board left that visible composer saying Steer after
// the turn had settled; repaint every current surface so it becomes
// the same-session Send affordance immediately.
if (taskLocal.get(key) !== "done") { taskLocal.set(key, "done"); renderAll(); }
const settled = (runFeedSettle.get(key) || 0) + 1;
runFeedSettle.set(key, settled);
if (settled > 12) stopRunFeed(key); // ~12s grace for the closing message
}
};
const cached = runFeedSeen.get(key);
if (cached) {
paintRunStream(key, cached);
paintRunKicker(key, cached);
if (runFeedIsTerminal(cached)) {
const queued = queuedRunMessages(key);
if (queued.length) sendQueuedRunMessage(key, queued[0].id);
return;
}
}
// Attach to the provider's native event stream first. The initial push is
// hydration; subsequent pushes are bounded semantic updates. Only older
// providers without a push source retain a compatibility pull fallback.
if (window.relay.watchRunFeed) {
runFeedTimers.set(key, "push");
window.relay.watchRunFeed(key).then((result) => {
if (runFeedTimers.get(key) !== "push") { window.relay.unwatchRunFeed?.(key); return; }
if (result?.ok === false || !result?.presentation) {
runFeedTimers.delete(key);
window.relay.unwatchRunFeed?.(key);
presentRunFeedWatchError(key, result?.error || "The live event stream is unavailable.");
return;
}
runPushFeeds.add(key);
acceptRunFeedUpdate(key, result);
}).catch((error) => {
if (runFeedTimers.get(key) !== "push") return;
runFeedTimers.delete(key);
presentRunFeedWatchError(key, error);
});
return;
}
tick();
}
function destroyWorkScroller(id) {
const key = String(id || "");
const entry = runScrollControllers.get(key);
if (!entry) return;
runScrollSnapshots.set(key, entry.controller.capture());
entry.controller.destroy(); runScrollControllers.delete(key);
}
function bindWorkScroller(id) {
const key = String(id || "");
destroyWorkScroller(key);
const scroller = document.querySelector(`[data-work-scroll="${CSS.escape(key)}"]`);
const footer = document.querySelector(`[data-work-footer="${CSS.escape(key)}"]`);
if (!scroller || !footer) return;
const controller = RelayWorkUI.createScrollController(scroller, footer, {
onFollowChange:(following) => scroller.toggleAttribute("data-work-detached", !following),
});
runScrollControllers.set(key, { controller, scroller });
const saved = runScrollSnapshots.get(key);
requestAnimationFrame(() => saved ? controller.restore(saved) : controller.scrollToBottom("instant"));
}
// Every render rebuilds the stream element, so re-attach the poll to whatever
// is on screen now and drop the ones whose card just closed.
function syncRunMirrors() {
if (!rendererSurfaceActive()) {
for (const id of [...runFeedTimers.keys()]) stopRunFeed(id);
for (const id of [...runScrollControllers.keys()]) destroyWorkScroller(id);
return;
}
const open = new Set();
for (const el of document.querySelectorAll("[data-run-stream]")) {
const id = el.getAttribute("data-run-stream");
open.add(id);
const cached = runFeedFor(id);
if (cached) { paintRunStream(id, cached); paintRunKicker(id, cached); }
startRunFeed(id);
}
for (const id of [...runFeedTimers.keys()]) if (!open.has(id)) stopRunFeed(id);
for (const id of [...runScrollControllers.keys()]) if (!open.has(id)) destroyWorkScroller(id);
}
// THE CONTRACT (B1, canon: proto/scheduled-requests-board.html). Accepting a
// cadence is standing consent, so the whole promise sits on the page at
// reading size before Accept exists — FIVE shelves, in the board's own order
// and words: cadence · first run · runs in · touches · until. No box: the
// paper law allows one left rule and nothing else inside the measure, and
// the exit clause is what makes accepting calm.
function scheduleContractHtml(r) {
const sc = scheduleFor(r.id);
if (!sc) return "";
const rt = routeFor(r.id);
const first = (sc.next || [])[0];
const firstWhen = first
? `${esc(new Date(first).toLocaleString(undefined, { weekday: "long", day: "numeric", month: "short" }))} <span class="sch-mut">· ${esc(sc.countdown || "")}</span>`
: `<span class="sch-mut">no further runs</span>`;
const sender = relaySender(r);
const ends = sc.state === "paused"
? `paused <span class="sch-mut">— resume it below</span>`
: `you pause or end it <span class="sch-mut">— ${esc(sender)} is told either way</span>`;
const shelf = (label, value) => `<div class="sch-row"><span class="sch-l">${esc(label)}</span><span class="sch-v">${value}</span></div>`;
return `
<div class="sch-spec" data-stop="1">
${shelf("cadence", `${esc(sc.cadence || "on a schedule")} <span class="sch-mut">· your time</span>`)}
${shelf("first run", firstWhen)}
${shelf("runs in", `<span class="sch-sq" style="background:${vendorSq(rt.app)}"></span>${esc(rt.app)} · ${esc(rt.model)} · ${esc(rt.effort)} <span class="sch-mut">— reroute any run from the rail below</span>`)}
${shelf("until", ends)}
</div>
<div class="sch-choice">
<div class="sch-ask">When a run finishes</div>
<div class="sch-island${sc.autoSend ? " auto" : ""}" data-sch-choice="${esc(sc.id)}" role="group">
<span class="sch-thumb" aria-hidden="true"></span>
<button type="button" class="sch-opt" data-auto="0">I read it first</button>
<button type="button" class="sch-opt" data-auto="1">Send it for me</button>
</div>
<div class="sch-note">${sc.autoSend
? `Each result goes straight to ${esc(sender)}. You will see it in Sent.`
: `Each result waits in your Tasks as a draft, in your words, until you send it.`}</div>
</div>`;
}
// The cadence's verbs ride the DOCK's rail, where Start lives for a one-off —
// same box, same place, the word changed to what this Task actually is.
function scheduleActionsHtml(r) {
const sc = scheduleFor(r.id);
if (!sc) return "";
if (sc.state === "proposed") {
return `<button class="ta-later" type="button" data-sch="decline" data-sch-id="${esc(sc.id)}">Decline</button>
<button type="button" class="ta-approve" data-sch="accept" data-sch-id="${esc(sc.id)}">Accept schedule</button>`;
}
return `<button class="ta-later" type="button" data-sch="end" data-sch-id="${esc(sc.id)}">End</button>
<button class="ta-later" type="button" data-sch="${sc.state === "paused" ? "resume" : "pause"}" data-sch-id="${esc(sc.id)}">${sc.state === "paused" ? "Resume" : "Pause"}</button>`;
}
function requestHasNativeSession(r) {
return Boolean(
r?.coworkSessionId || r?.ranOnCowork || r?.codexThreadId || r?.codexSessionPath || r?.sessionPath
|| r?.claudeNativeSession?.sessionId || r?.ranOnClaude || r?.ranOnCodex,
);
}
function continuedRouteFor(r) {
const id = String(r?.id || "");
const app = runAppName(id);
const spec = AGENT_ROUTES[app];
if (!spec) return { app, model:"", effort:"", configurable:false };
const selected = taskRoutes.get(id);
if (!selected || selected.app !== app) {
const persistedModel = String(rtAppModel(r, app) || "");
const model = spec.models.find((label) => modelIdFor(app, label) === persistedModel) || spec.model;
const persistedEffort = String(rtAppEffort(r, app) || "").toLowerCase();
const effort = spec.efforts.includes(persistedEffort) ? persistedEffort : spec.effort;
setRoute(id, { app, model, effort });
}
return { ...routeFor(id), configurable:true };
}
function rtAppModel(r, app) {
return /claude/i.test(String(app)) ? (r?.claudeModel || r?.model) : (r?.codexModel || r?.model);
}
function rtAppEffort(r, app) {
return /claude/i.test(String(app)) ? (r?.claudeEffort || r?.effort) : (r?.codexEffort || r?.effort);
}
function continuedRouteChipsHtml(r) {
const rt = continuedRouteFor(r);
const appChip = `<span class="rt-chip static"><span class="sq" style="background:${vendorSq(rt.app)}"></span>${esc(rt.app)}</span>`;
if (!rt.configurable) return `<span class="rt-chips">${appChip}</span>`;
return `<div class="rt-chips">${appChip}
<span class="rt-dot">·</span>
<button type="button" class="rt-chip" data-route-menu="model" data-request="${esc(r.id)}">${esc(rt.model)}</button>
<span class="rt-dot">·</span>
<button type="button" class="rt-chip" data-route-menu="effort" data-request="${esc(r.id)}">${esc(rt.effort)}</button>
</div>`;
}
function continuedRouteOptions(id) {
const rt = continuedRouteFor(relayById(id) || { id });
return {
model: modelIdFor(rt.app, rt.model),
effort: String(rt.effort || "high").toLowerCase(),
};
}
// Tasks and ordinary Relay Work start on the same route-selecting
// composer. The document changes the verb (Start vs Send), never the
// available app, model, or thinking controls.
function idleRunDockHtml(r, { inline = false, draft = "", action = "request", label = "Start", actions = "" } = {}) {
const rt = routeFor(r.id);
const actionButton = action === "work"
? `<button type="button" class="ta-approve" data-work-start="${esc(r.id)}">${esc(label)}</button>`
: `<button type="button" class="ta-approve" data-approve="${esc(r.id)}">${esc(label)}</button>`;
const review = requestSafetyReviews.get(String(r.id));
const reviewHtml = action === "request" && review ? `
<div class="request-safety" role="status">
<strong>${review.concern === "high" ? "Look closely before accepting" : "Safety review"}</strong>
<div>${esc(review.summary || "")}</div>
${Array.isArray(review.permissions) && review.permissions.length
? `<div class="permission-line">May need: ${review.permissions.map((item) => esc(item.label || "")).filter(Boolean).join(" · ")}</div>
<ul>${review.permissions.map((item) => `<li>${esc(item.effect || "")}</li>`).join("")}</ul>`
: `<div class="permission-line">${esc(review.plainLanguage || "No powerful access is obvious from the Task.")}</div>`}
${review.trustContext ? `<div class="permission-line">${esc(review.trustContext)}</div>` : ""}
</div>` : "";
const defaultActions = action === "request"
? `<button class="ta-later" type="button" data-request-safety="${esc(r.id)}">Review Safety</button>${actionButton}`
: `<button class="ta-later" type="button" data-open-in="${esc(r.id)}" data-open-in-host="codex">Open in Codex</button>
<button class="ta-later" type="button" data-open-in="${esc(r.id)}" data-open-in-host="claude">Open in Claude Code</button>${actionButton}`;
const railActions = actions || defaultActions;
return `
<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col">
${reviewHtml}
<textarea rows="1" class="ta-note" placeholder="Tell ${esc(rt.app)} anything…">${esc(draft)}</textarea>
<div class="ta-rail">
<div class="rt-chips">
<button type="button" class="rt-chip" data-route-menu="app" data-request="${esc(r.id)}"><span class="sq" style="background:${vendorSq(rt.app)}"></span>${esc(rt.app)}</button>
<span class="rt-dot">·</span>
<button type="button" class="rt-chip" data-route-menu="model" data-request="${esc(r.id)}">${esc(rt.model)}</button>
<span class="rt-dot">·</span>
<button type="button" class="rt-chip" data-route-menu="effort" data-request="${esc(r.id)}">${esc(rt.effort)}</button>
</div>
<span class="rt-spacer"></span>
<span class="ta-acts">${railActions}</span>
</div>
</div>
</div>`;
}
function requestDockHtml(r, { inline = false } = {}) {
if (r.historyImported) {
return `<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col"><div class="request-safety" role="status">
<strong>Imported Task history</strong>
<div>This is a private copy of an earlier Task. It cannot be accepted or run again.</div>
</div></div>
</div>`;
}
const state = taskBoardState(r.id);
const draft = String(requestWorkDrafts.get(String(r.id)) || "");
if (state === "done" && !requestHasNativeSession(r)) {
return `<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col">
<textarea rows="1" class="ta-note" placeholder="This Task is complete" disabled></textarea>
<div class="ta-rail"><div class="rt-chips"><span class="rt-chip static"><span class="sq"></span>Older Task</span></div>
<span class="rt-spacer"></span><span class="ta-acts"><button class="ta-later" type="button" disabled>Native session unavailable</button><button class="ta-approve" type="button" disabled>Done</button></span>
</div>
</div>
</div>`;
}
if (state === "review") {
const pending = r.completionReview || {};
const assessment = pending.assessment || {};
const effects = Array.isArray(assessment.effects) ? assessment.effects : [];
return `
<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col">
<div class="result-review" role="status">
<strong>Result ready — check its release risk</strong>
<div>${esc(assessment.summary || "Check this result before it leaves your device.")}</div>
${effects.length ? `<ul>${effects.map((effect) => `<li>${esc(effect)}</li>`).join("")}</ul>` : ""}
</div>
<div class="ta-rail">
${continuedRouteChipsHtml(r)}
<span class="rt-spacer"></span>
<span class="ta-acts">
<button class="ta-later" type="button" data-open-run="${esc(r.id)}">Open result</button>
<button type="button" class="ta-approve" data-result-send="${esc(r.id)}">Send result</button>
</span>
</div>
</div>
</div>`;
}
const canContinueSession = state === "stopped" && requestHasNativeSession(r);
if (state === "running" || state === "done" || canContinueSession) {
const live = state === "running";
return `
<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col">
${queuedRunMessagesHtml(r.id)}
<textarea rows="1" class="ta-note" data-steer-note="${esc(r.id)}" placeholder="${live ? `Queue a follow-up for ${esc(runAppName(r.id))}…` : `Ask ${esc(runAppName(r.id))} for a follow-up…`}">${esc(draft)}</textarea>
<div class="ta-rail">
${continuedRouteChipsHtml(r)}
<span class="rt-spacer"></span>
<span class="ta-acts">
${(() => {
// Run here owns the provider turn exclusively. A second native
// owner is forbidden while active. When the turn settles,
// Claude opens via its guarded exact-transcript path and Codex
// navigates to the exact existing thread.
if (state === "running") return `<button class="ta-later" type="button" disabled aria-disabled="true" title="Available when this run finishes">Open in ${esc(runAppName(r.id))}</button>`;
if (r.ranOnClaude) return `<button class="ta-later" type="button" data-open-run="${esc(r.id)}">Open in ${esc(runAppName(r.id))}</button>`;
return `<button class="ta-later" type="button" data-open-in="${esc(r.id)}">Open in ${esc(runAppName(r.id))}</button>`;
})()}
<button type="button" class="ta-approve" data-steer="${esc(r.id)}">${live ? "Queue" : "Send"}</button>
</span>
</div>
</div>
</div>`;
}
// Parked is deferred, never dead: unfolding a parked request brings the dock
// back so Start stays one tap away — only "Not now" leaves (it already
// happened; the Parked group says so).
// The ROUTE RAIL is the dock's bottom line, inside the capsule — the
// destination read as a sentence (■ app · model · effort), each word a
// chip. It replaces the old "runs in your … · change" transparency line:
// the rail IS the transparency, and it is writable.
return idleRunDockHtml(r, {
inline,
draft,
label: state === "stopped" ? "Start again" : "Start task",
actions: scheduleActionsHtml(r),
});
}
function relayWorkDockHtml(r, { inline = false } = {}) {
const state = relayWorkState(r);
const draft = String(requestWorkDrafts.get(String(r.id)) || "");
const taggedWork = r.source?.host === "relay-agent-run";
const hasSession = taggedWork || requestHasNativeSession(r);
if (state !== "idle" && (hasSession || state === "running")) {
const live = state === "running";
const app = runAppName(r.id);
const starting = live && !hasSession;
return `
<div class="ta-dock${inline ? " inline" : ""}" data-stop="1">
<div class="qr ta-qr col">
${queuedRunMessagesHtml(r.id)}
<textarea rows="1" class="ta-note" data-steer-note="${esc(r.id)}" placeholder="${starting ? `Starting ${esc(app)}…` : live ? `Queue a follow-up for ${esc(app)}…` : `Ask ${esc(app)} for a follow-up…`}"${starting ? " disabled" : ""}>${esc(draft)}</textarea>
<div class="ta-rail">
${continuedRouteChipsHtml(r)}
<span class="rt-spacer"></span>
<span class="ta-acts">
${taggedWork && live ? `<button class="ta-later" type="button" data-chat-work-stop="${esc(r.id)}">Stop</button>` : ""}
${taggedWork && !live ? `<button class="ta-later" type="button" data-chat-work-retry="${esc(r.id)}">Retry</button>` : ""}
${live
? `<button class="ta-later" type="button" disabled aria-disabled="true">Open in ${esc(app)}</button>`
: r.ranOnClaude
? `<button class="ta-later" type="button" data-open-run="${esc(r.id)}">Open in ${esc(app)}</button>`
: `<button class="ta-later" type="button" data-open-in="${esc(r.id)}">Open in ${esc(app)}</button>`}
<button type="button" class="ta-approve" data-steer="${esc(r.id)}"${starting ? " disabled aria-disabled=\"true\"" : ""}>${starting ? "Starting" : live ? "Queue" : "Send"}</button>
</span>
</div>
</div>
</div>`;
}
return idleRunDockHtml(r, { inline, draft, action: "work", label: "Send" });
}
function requestControlsHtml(r, opts) { return requestDockHtml(r, opts) || taskStatusLine(r, opts); }
function wireRequestControls(scope, onDone) {
for (const button of scope.querySelectorAll("[data-work-provider]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const id = button.getAttribute("data-work-provider-relay") || "";
const provider = button.getAttribute("data-work-provider") || "";
if (!id || !["claude", "codex"].includes(provider) || button.disabled) return;
const prompt = workProviderPrompts.get(String(id)) || { requested:provider, busy:"", error:"" };
workProviderPrompts.set(String(id), { ...prompt, requested:provider, busy:provider, error:"" });
if (providerReadyForWork(provider)) {
chooseConnectedWorkProvider(id, provider);
return;
}
if (activeView === "reader" && String(readerId) === String(id)) renderReader();
await connectProviderFromSettings(provider, id);
});
for (const button of scope.querySelectorAll("[data-request-safety]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const id = button.getAttribute("data-request-safety");
if (!id || button.disabled) return;
button.disabled = true;
button.textContent = "Reviewing…";
const result = await window.relay.requestReviewSafety(id).catch((error) => ({ ok:false, error:String(error?.message || error) }));
if (result?.ok && result.review) requestSafetyReviews.set(String(id), result.review);
else setRowNote(id, result?.error || "The safety review could not be prepared.", "err");
renderAll();
});
for (const button of scope.querySelectorAll("[data-result-send]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const id = button.getAttribute("data-result-send");
if (!id || button.disabled) return;
button.disabled = true;
button.textContent = "Sending…";
const result = await window.relay.requestCompletionSend(id).catch((error) => ({ ok:false, error:String(error?.message || error) }));
if (!result?.ok) {
button.disabled = false;
button.textContent = "Send result";
setRowNote(id, result?.error || "The result could not be sent.", "err");
} else {
setRowNote(id, "Encrypted result sent.", "ok");
fadeRowNoteLater(id, 3000);
}
renderAll();
});
for (const button of scope.querySelectorAll("[data-run-queued-now]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const dock = button.closest(".ta-dock");
const id = dock?.querySelector("[data-steer]")?.getAttribute("data-steer") || "";
if (!id) return;
button.disabled = true;
await sendQueuedRunMessage(id, button.getAttribute("data-run-queued-now"), { steerNow: true });
});
for (const button of scope.querySelectorAll("[data-run-queued-delete]")) button.addEventListener("click", (event) => {
event.stopPropagation();
const dock = button.closest(".ta-dock");
const id = dock?.querySelector("[data-steer]")?.getAttribute("data-steer") || "";
if (!id) return;
removeQueuedRunMessage(id, button.getAttribute("data-run-queued-delete"));
renderAll();
});
for (const ch of scope.querySelectorAll("[data-change-app]")) ch.addEventListener("click", (e) => {
e.stopPropagation(); activeView = "settings"; commitNavigation();
});
// Enter starts, like Enter sends in every other composer; Shift+Enter breaks the line.
for (const inp of scope.querySelectorAll(".ta-note")) {
const dock = inp.closest(".ta-dock");
const action = dock?.querySelector("[data-steer]")
|| dock?.querySelector("[data-approve]")
|| dock?.querySelector("[data-work-start]");
const id = action?.getAttribute("data-steer")
|| action?.getAttribute("data-approve")
|| action?.getAttribute("data-work-start") || "";
if (id) inp.addEventListener("input", () => requestWorkDrafts.set(String(id), inp.value || ""));
// Capture the exact visible action. Enter and the button must never take
// different paths merely because a re-render changed nearby controls.
dressComposer(inp, () => action?.click());
}
// Open in {app} is a real, separate ownership mode. Relay never asks the
// native app and the headless runner to own one turn concurrently. While a
// Relay-owned turn is active the button is visibly disabled. Before a run
// it stages both Task documents plus the unsent composer text in a new
// native session; after a run it opens that exact settled native session.
for (const b of scope.querySelectorAll("[data-open-in]")) {
b.addEventListener("click", async (e) => {
e.stopPropagation();
if (b.disabled) return;
const id = b.getAttribute("data-open-in");
const rt = routeFor(id);
const requestedHost = b.getAttribute("data-open-in-host");
const host = requestedHost === "codex" || requestedHost === "claude"
? requestedHost
: hostKeyFor(rt.app);
const app = host === "codex" ? "Codex" : host === "claude" ? "Claude Code" : rt.app;
const note = String((b.closest(".ta-dock")?.querySelector(".ta-note") || {}).value || "").trim();
const ranAlready = requestHasNativeSession(taskRows().find((row) => row.id === id));
if (ranAlready && window.relay.openInCurrent) window.relay.openInCurrent(id, host);
else openRelayFromUI(id, "relay", "fresh", host, note);
// AFTER the open, never before: openRelayFromUI clears the row's note
// first (a stale failure must not outlive the retry), so a note written
// above this line vanished the same instant — the trap already found on
// the per-relay rows.
setOpeningNote(id, `Opening in ${app}…`);
if (onDone) onDone("open-in");
});
}
// A finished Claude run already has an exact canonical transcript. Open
// that session; never forge a second row and never attach while Relay still
// owns its worker.
for (const b of scope.querySelectorAll("[data-open-run]")) {
b.addEventListener("click", async (e) => {
e.stopPropagation();
const id = b.getAttribute("data-open-run");
b.disabled = true;
const res = await window.relay.openRunSession(id).catch((error) => ({ ok: false, error: String(error?.message || error) }));
b.disabled = false;
if (!res?.ok) setRowNote(id, res?.error || "That Claude session is not ready to open yet.", "err");
else { setRowNote(id, "Opened in Claude Code.", "ok"); fadeRowNoteLater(id, 3000); }
if (res?.ok && onDone) onDone("open-in");
});
}
// The route rail's chips: app / model / thinking, per request.
for (const c of scope.querySelectorAll("[data-route-menu]")) {
c.addEventListener("click", (e) => {
e.stopPropagation();
openRouteMenu(c, c.getAttribute("data-route-menu"), c.getAttribute("data-request"), () => renderReader());
});
}
// The status line ("Approved — your agent is on it" / "Parked — waiting
// in Tasks") is the ONE statement of state. A note appears only to echo a
// steering note you typed — never to say "Approved" a second time (Sven's
// double). Park says nothing extra anywhere.
for (const ap of scope.querySelectorAll("[data-approve]")) ap.addEventListener("click", async (e) => {
e.stopPropagation();
const id = ap.getAttribute("data-approve");
const noteBox = ap.closest(".ta-dock")?.querySelector(".ta-note");
const note = (noteBox || {}).value || "";
const staged = noteBox ? peekStagedFiles(noteBox) : [];
let payloads;
try { payloads = await composerFilePayloads(staged); }
catch (error) { setRowNote(id, String(error?.message || error), "err"); return; }
const rt = routeFor(id);
if (payloads.files.length && !/codex/i.test(String(rt.app))) {
setRowNote(id, "Work-run attachments are currently supported for Codex sessions.", "err");
return;
}
if (!await providerReadyBeforeStart(id, rt.app)) return;
// A retry is a new run. Never let the previous provider's cached feed
// repaint the new run's dock (Codex briefly replaced Cowork on screen).
if (activeView === "reader" && String(readerId) === String(id) && readerTab !== "work") {
workFaceTransitions.set(String(id), { fromTab:readerTab === "agent" ? "agent" : "you", until:Date.now() + 520 });
}
const optimistic = appendOptimisticUserTurn(id, note.trim(), true, payloads.attachments, { preserveHistory: false });
requestWorkDrafts.delete(String(id));
if (activeView === "reader" && String(readerId) === String(id)) {
readerTab = "work";
readerTabSwap = true;
workTabAnimateUntil.set(String(id), Date.now() + 500);
}
window.relay.ack(id); // acting on a request reads it — never bold after
// START IS REAL: ignite the run on the route this Task names (rail or
// Settings default), carrying the composer's steering note verbatim.
setRowNote(id, `Starting ${rt.app} on this Task…`, "ok");
if (window.relay.taskStart) {
window.relay.taskStart(id, {
host: hostKeyFor(rt.app),
model: modelIdFor(rt.app, rt.model),
effort: String(rt.effort || "high").toLowerCase(),
note,
// The Settings choice has to REACH the run, or the picker is a
// decoration that lies about what the agent may do.
permission: permMode(/claude/i.test(String(rt.app)) ? "claude" : "codex"),
files: payloads.files,
}).then((res) => {
runFeedPending.delete(String(id));
if (res && res.ok) {
if (noteBox) clearStagedFiles(noteBox);
setRowNote(id, note || payloads.files.length ? `Started on ${rt.app} — your message went with it.` : `Started on ${rt.app}.`, "ok");
// This is launch acknowledgement, not durable Task state. The
// Work header owns running/stopped/done truth; leaving this green
// note mounted after a fast completion creates a contradictory
// "Done / Started" footer.
fadeRowNoteLater(id, 3000);
}
else {
// Nothing is running, so the Task goes back to WAITING and Start
// returns. An optimistic "running" that outlives a failed launch is
// exactly the phantom run we just stopped creating.
taskLocal.delete(id);
if (optimistic.previous) runFeedSeen.set(String(id), optimistic.previous); else runFeedSeen.delete(String(id));
if (note) requestWorkDrafts.set(String(id), note);
if (res?.code === "provider_not_ready") showWorkProviderPrompt(id, res.provider || providerIdForApp(rt.app));
else setRowNote(id, (res && (res.error || res.runError)) || "Start failed — try again.", "err");
}
renderAll();
}).catch((e) => {
runFeedPending.delete(String(id));
taskLocal.delete(id);
if (optimistic.previous) runFeedSeen.set(String(id), optimistic.previous); else runFeedSeen.delete(String(id));
if (note) requestWorkDrafts.set(String(id), note);
setRowNote(id, String(e && e.message || e).slice(0, 120), "err");
renderAll();
});
}
onDone("approve", id);
});
for (const start of scope.querySelectorAll("[data-work-start]")) start.addEventListener("click", async (e) => {
e.stopPropagation();
const id = start.getAttribute("data-work-start");
const noteBox = start.closest(".ta-dock")?.querySelector(".ta-note");
const note = String(noteBox?.value || "").trim();
const staged = noteBox ? peekStagedFiles(noteBox) : [];
if (!note && !staged.length) { noteBox?.focus(); return; }
let payloads;
try { payloads = await composerFilePayloads(staged); }
catch (error) { setRowNote(id, String(error?.message || error), "err"); return; }
const rt = routeFor(id);
if (payloads.files.length && !/codex/i.test(String(rt.app))) {
setRowNote(id, "Work-run attachments are currently supported for Codex sessions.", "err");
return;
}
if (!await providerReadyBeforeStart(id, rt.app)) return;
if (activeView === "reader" && String(readerId) === String(id) && readerTab !== "work") {
workFaceTransitions.set(String(id), { fromTab:readerTab === "agent" ? "agent" : "you", until:Date.now() + 520 });
}
const optimistic = appendOptimisticUserTurn(id, note, true, payloads.attachments, { preserveHistory: false });
requestWorkDrafts.delete(String(id));
readerTab = "work";
readerTabSwap = true;
workTabAnimateUntil.set(String(id), Date.now() + 500);
start.disabled = true;
setRowNote(id, `Starting ${rt.app} here…`, "ok");
window.relay.relayWorkStart(id, {
host: hostKeyFor(rt.app),
model: modelIdFor(rt.app, rt.model),
effort: String(rt.effort || "high").toLowerCase(),
note,
permission: permMode(/claude/i.test(String(rt.app)) ? "claude" : "codex"),
files: payloads.files,
}).then((res) => {
runFeedPending.delete(String(id));
if (!res?.ok) {
taskLocal.delete(String(id));
if (optimistic.previous) runFeedSeen.set(String(id), optimistic.previous); else runFeedSeen.delete(String(id));
requestWorkDrafts.set(String(id), note);
if (res?.code === "provider_not_ready") showWorkProviderPrompt(id, res.provider || providerIdForApp(rt.app));
else {
readerTab = "agent";
setRowNote(id, res?.error || res?.runError || "It did not start.", "err");
}
} else {
if (noteBox) clearStagedFiles(noteBox);
clearRowNote(id);
}
renderAll();
}).catch((error) => {
runFeedPending.delete(String(id));
taskLocal.delete(String(id));
if (optimistic.previous) runFeedSeen.set(String(id), optimistic.previous); else runFeedSeen.delete(String(id));
requestWorkDrafts.set(String(id), note);
readerTab = "agent";
setRowNote(id, String(error?.message || error).slice(0, 120), "err");
renderAll();
});
onDone("work-start", id);
});
for (const button of scope.querySelectorAll("[data-chat-work-stop]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const id = String(button.getAttribute("data-chat-work-stop") || "");
if (!id || button.disabled) return;
button.disabled = true;
const result = await window.relay.chatAgentWorkStop?.(id).catch((error) => ({ ok:false, error:String(error?.message || error) }));
if (result?.ok) {
taskLocal.set(id, "stopped");
stopRunFeed(id);
setRowNote(id, "Work stopped.", "ok");
} else setRowNote(id, result?.error || "Work could not be stopped.", "err");
renderAll();
});
for (const button of scope.querySelectorAll("[data-chat-work-retry]")) button.addEventListener("click", async (event) => {
event.stopPropagation();
const id = String(button.getAttribute("data-chat-work-retry") || "");
if (!id || button.disabled) return;
button.disabled = true;
const result = await window.relay.chatAgentWorkRetry?.(id).catch((error) => ({ ok:false, error:String(error?.message || error) }));
if (result?.ok) {
taskLocal.set(id, "running");
runFeedSeen.delete(id);
runFeedEpoch.set(id, (runFeedEpoch.get(id) || 0) + 1);
setRowNote(id, result.session?.state === "waiting_for_device" ? "Waiting for your computer." : "Retry started.", "ok");
} else setRowNote(id, result?.error || "Work could not be retried.", "err");
renderAll();
});
for (const st of scope.querySelectorAll("[data-steer]")) st.addEventListener("click", async (e) => {
e.stopPropagation();
const id = st.getAttribute("data-steer");
const box = scope.querySelector(`[data-steer-note="${CSS.escape(id)}"]`);
const body = String((box && box.value) || "").trim();
const staged = box ? peekStagedFiles(box) : [];
if (!body && !staged.length) { if (box) box.focus(); return; }
st.disabled = true;
let payloads;
try {
payloads = await composerFilePayloads(staged);
} catch (error) {
st.disabled = false;
setRowNote(id, String(error?.message || error), "err");
return;
}
if (payloads.files.length && !/codex/i.test(runAppName(id))) {
st.disabled = false;
setRowNote(id, "Work-run attachments are currently supported for Codex sessions.", "err");
return;
}
const key = String(id);
const continuation = continuedRouteOptions(id);
if (taskBoardState(id) === "running") {
const rows = queuedRunMessages(key);
rows.push({ id: crypto.randomUUID(), body, files: payloads.files, attachments: payloads.attachments, ...continuation });
runQueuedMessages.set(key, rows);
if (box) { box.value = ""; clearStagedFiles(box); }
requestWorkDrafts.delete(key);
setRowNote(id, "Queued for when the current turn finishes.", "ok");
fadeRowNoteLater(id, 5000);
renderAll();
return;
}
const previousFeed = runFeedSeen.get(key) || null;
const previousState = taskLocal.has(key) ? taskLocal.get(key) : undefined;
appendOptimisticUserTurn(id, body, true, payloads.attachments);
const res = await window.relay.runSteer(id, body, { newTurn: true, files: payloads.files, ...continuation }).catch((err) => ({ ok: false, error: String(err && err.message || err) }));
st.disabled = false;
runFeedPending.delete(key);
if (res && res.ok) {
if (box) { box.value = ""; clearStagedFiles(box); }
requestWorkDrafts.delete(key);
setRowNote(id, "Follow-up sent into the session.", "ok");
fadeRowNoteLater(id, 3000);
renderAll();
}
else {
stopRunFeed(key);
if (previousFeed) runFeedSeen.set(key, previousFeed); else runFeedSeen.delete(key);
if (previousState === undefined) taskLocal.delete(key); else taskLocal.set(key, previousState);
setRowNote(id, (res && res.error) || "Could not reach the session.", "err");
renderAll();
}
});
for (const lt of scope.querySelectorAll("[data-later]")) lt.addEventListener("click", (e) => {
e.stopPropagation();
const id = lt.getAttribute("data-later");
taskLocal.set(id, "parked");
window.relay.ack(id); // parked ≠ unread: you saw it and deferred it
clearRowNote(id);
onDone("later", id);
});
}
function requestsWaitingCount() { return taskRows().filter((r) => taskBoardState(r.id) === "waiting").length; }
function requestsUnreadCount() { return taskRows().filter((r) => r.unread).length; }
let chatExpanded = false; // the conversation borrowing the reader's frame (⤢)
let readerTab = "you"; // which face of the Task folder is open ("you" | "agent" | "work")
let readerTabSwap = false; // true only for the render caused by a tab switch (drives the pane animation)
let readerWorkVisible = false; // detects the two-tabs -> three-tabs transition without confusing an existing run
const workTabAnimateUntil = new Map(); // request id -> animation deadline across the immediate Start repaint
const workFaceTransitions = new Map(); // request id -> source face retained while Work becomes ready
const requestWorkDrafts = new Map(); // switching documents never eats an unsent Start/Steer note
const readerHumanDrafts = new Map(); // For-you replies survive source/Work tab hops independently
// Sent relays live in the API-backed sent cache rather than the inbound
// packet store. Project one into the reader's deliberately small row shape
// so the same document page can read either side of a conversation.
function readerRow(id) {
const inbound = (payload.relays || []).find((row) => row.id === id);
if (inbound) return inbound;
for (const chat of canonicalChatDetails.values()) {
const item = (chat?.items || []).find((row) => String(row.relayId || "") === String(id || ""));
if (!item) continue;
const outbound = item.direction === "outbound";
const directAnchor = directContactAnchorForChatId(chat.chatId);
const channelName = directAnchor?.name || chat.channel?.name || chat.title || "Channel";
return {
id: item.relayId,
title: String(item.title || "Relay"),
displayTitle: String(item.title || "Relay"),
forHuman: String(item.forHuman || ""),
forAgent: String(item.forAgent || ""),
senderName: outbound ? `You → ${channelName}` : String(item.sender?.name || directAnchor?.name || "Relay contact"),
createdAt: item.createdAt || item.updatedAt,
threadId: item.threadId || item.relayId,
relayNotificationKind: "plain_relay",
unread: !outbound && item.state === "delivered",
outbound,
attachments: item.attachments || [],
source: item.origin === "slack" ? { host:"slack" } : { host:"relay-pill" },
provider: item.provider || (item.origin === "slack" ? { name:"slack" } : null),
materializedCodex: Boolean(item.materializedCodex),
materializedClaude: Boolean(item.materializedClaude),
reactions: item.reactions || { aggregates:[], events:[] },
};
}
const sent = (payload.sent || []).find((row) => String(row.relayId || row.id || "") === String(id || ""));
if (!sent) return null;
// A group send fans out to one relay per member. The reader opened ONE of
// those copies, but the message was addressed to the room — name the group
// (or its members, if the roster is gone), never the sibling that was opened.
const siblings = sent.groupSendId
? (payload.sent || []).filter((row) => row.groupSendId === sent.groupSendId)
: [];
const recipient = siblings.length > 1
? sentGroupLabel(siblings)
: sent.recipientGroupName || sentRecipient(sent);
return {
id: sent.relayId || sent.id,
title: sentSubject(sent),
displayTitle: sent.displayTitle || sent.title || "Relay",
forHuman: sent.forHuman || sent.preview || "",
forAgent: sent.forAgent || "",
senderName: `You → ${recipient}`,
createdAt: sent.createdAt || sent.updatedAt,
threadId: sent.threadId || sent.relayId || sent.id,
relayNotificationKind: "plain_relay",
unread: false,
outbound: true,
materializedCodex: Boolean(sent.materializedCodex),
materializedClaude: Boolean(sent.materializedClaude),
reactions: sent.reactions || { aggregates: [], events: [] },
};
}
const RX_PRIMARY = ["👍", "❤️", "😂", "🎉", "👀"];
const RX_MORE = ["😮", "😢", "🔥", "✅", "🙏", "💯"];
// Reactions remain implemented and durable, but are deliberately dark.
// This is a reversible product gate—not a data/schema deletion.
const REACTIONS_ENABLED = false;
const reactionNotes = new Map();
function messageReactionPickerHtml(id) {
if (!REACTIONS_ENABLED) return "";
return `<div class="rx-colo rx-msg-picker" data-rx-colo="${esc(id)}" data-stop="1">
<button type="button" class="rx-face" data-rx-face="${esc(id)}" title="React to this message" aria-label="React to this message">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9.2"/><path d="M8.4 14.2c.9 1.2 2.1 1.9 3.6 1.9s2.7-.7 3.6-1.9"/><circle cx="9" cy="9.8" r=".7" fill="currentColor" stroke="none"/><circle cx="15" cy="9.8" r=".7" fill="currentColor" stroke="none"/></svg>
</button>
<div class="rx-bar rx-unfurl" data-rx-bar="${esc(id)}">
${RX_PRIMARY.map((emoji, i) => `<button type="button" class="rx-em" style="animation-delay:${i * 28}ms" data-rx-pick="${esc(id)}" data-emoji="${emoji}" title="React ${emoji}">${emoji}</button>`).join("")}
${RX_MORE.map((emoji) => `<button type="button" class="rx-em more" data-rx-pick="${esc(id)}" data-emoji="${emoji}" title="React ${emoji}">${emoji}</button>`).join("")}
<button type="button" class="rx-more" data-rx-more="${esc(id)}" title="More reactions">+</button>
</div>
</div>`;
}
function reactionConfirmationHtml(id) {
if (!REACTIONS_ENABLED) return "";
const note = reactionNotes.get(id);
return note ? `<div class="rx-confirm">you reacted <span class="emoji">${esc(note.emoji)}</span> — sent to the conversation · <button type="button" class="rx-undo" data-rx-undo="${esc(id)}" data-emoji="${esc(note.emoji)}">undo</button></div>` : "";
}
async function commitReaction(id, emoji, action) {
if (!window.relay.react) return { ok:false, error:"Reactions need a newer Relay companion." };
const result = await window.relay.react(id, emoji, action).catch((error) => ({ ok:false, error:String(error && error.message || error) }));
if (!result || !result.ok) setRowNote(id, (result && result.error) || "Reaction did not send.", "err");
return result;
}
function dismissReactionPickers(root = document, except = null) {
for (const picker of root.querySelectorAll("[data-rx-colo].open")) {
if (picker === except) continue;
picker.classList.remove("open");
picker.querySelector("[data-rx-bar]")?.classList.remove("grid");
}
}
function openReactionPicker(picker) {
if (!picker) return;
// One colophon at a time. Replacing the active picker is part of opening
// the new one, so mouse and touch share exactly the same lifecycle.
dismissReactionPickers(document, picker);
picker.classList.add("open");
}
function installReactionPickerDismissals(root = document) {
// pointerdown catches mouse, pen and touch before a later click changes
// rooms/views. Interactions inside the colophon keep it open.
root.addEventListener("pointerdown", (event) => {
const target = event.target;
if (target && typeof target.closest === "function" && target.closest("[data-rx-colo]")) return;
dismissReactionPickers(root);
}, true);
root.addEventListener("keydown", (event) => {
if (event.key === "Escape") dismissReactionPickers(root);
}, true);
// scroll does not bubble, so capture is essential for the outer card, the
// expanded room pane, and any nested history scroller.
root.addEventListener("scroll", () => dismissReactionPickers(root), true);
// Clicking another app never reaches the document. Losing the overlay
// window is therefore the desktop equivalent of an outside pointerdown.
root.defaultView?.addEventListener("blur", () => dismissReactionPickers(root));
}
installReactionPickerDismissals();
function wireReactionControls(scope) {
if (!REACTIONS_ENABLED) return;
for (const face of scope.querySelectorAll("[data-rx-face]")) face.addEventListener("click", (event) => {
event.stopPropagation();
openReactionPicker(face.closest("[data-rx-colo]"));
});
for (const more of scope.querySelectorAll("[data-rx-more]")) more.addEventListener("click", (event) => {
event.stopPropagation();
more.closest("[data-rx-bar]")?.classList.add("grid");
});
for (const pick of scope.querySelectorAll("[data-rx-pick]")) pick.addEventListener("click", async (event) => {
event.stopPropagation();
const id = pick.getAttribute("data-rx-pick");
const emoji = pick.getAttribute("data-emoji");
dismissReactionPickers();
pick.disabled = true;
const result = await commitReaction(id, emoji, "add");
if (result && result.ok) reactionNotes.set(id, { emoji });
renderAll();
});
for (const undo of scope.querySelectorAll("[data-rx-undo]")) undo.addEventListener("click", async (event) => {
event.stopPropagation();
const id = undo.getAttribute("data-rx-undo");
const emoji = undo.getAttribute("data-emoji");
undo.disabled = true;
const result = await commitReaction(id, emoji, "remove");
if (result && result.ok) reactionNotes.delete(id);
renderAll();
});
}
// The cancel control lives on the composer's reply face, which is inserted
// both by a full shell paint and by an in-place face swap. One wiring, marked
// on the node, so neither path can double-bind it.
function wireReplyCancel(root, stateKey) {
for (const b of root.querySelectorAll("[data-reply-cancel]")) {
if (b.dataset.wired) continue;
b.dataset.wired = "1";
b.addEventListener("click", (e) => {
e.stopPropagation();
threadReplyTargets.delete(stateKey);
renderThreadDetail();
document.getElementById("thQrInput")?.focus();
});
}
}
function roomScrollElement() {
// In the expanded split, the conversation and its rail are siblings. The
// room owns scrolling so following the newest message cannot drag every
// conversation in the rail above the card's viewport.
return activeView === "threads" && chatExpanded ? thDetailEl : scrollEl;
}
function beginThreadEntryFollow(threadId) {
const token = ++threadDetailEntryFollowSeq;
threadDetailEntryFollow = { token, threadId:String(threadId || "") };
return token;
}
function threadEntryFollowToken() {
if (!threadDetailEntryFollow || activeView !== "threads") return 0;
return threadDetailEntryFollow.threadId === String(threadDetailId || "")
? threadDetailEntryFollow.token
: 0;
}
function scrollRoomToNewest(chatShaped, { smooth = false } = {}) {
const roomScrollEl = roomScrollElement();
if (!roomScrollEl) return;
const top = chatShaped ? roomScrollEl.scrollHeight : 0;
if (smooth && roomScrollEl.scrollTo) roomScrollEl.scrollTo({ top, behavior:"smooth" });
else roomScrollEl.scrollTop = top;
}
function settleThreadEntryFollow(token) {
// refreshSent resolves after main has pushed the hydrated payload. Give the
// renderer and font/layout work two frames, perform one final bottom pin,
// then release the follow latch so ordinary polling preserves the reader's
// position. A stale room's promise can never move the room opened after it.
requestAnimationFrame(() => requestAnimationFrame(() => {
if (threadEntryFollowToken() !== token) return;
scrollRoomToNewest(chatOrder() === "chat");
threadDetailEntryFollow = null;
}));
}
function hydrateThreadEntry(token, { includeSent = true } = {}) {
const sentReady = includeSent && window.relay.refreshSent
? window.relay.refreshSent().catch(() => null)
: Promise.resolve(null);
const fontsReady = document.fonts && document.fonts.ready
? document.fonts.ready.catch(() => null)
: Promise.resolve(null);
Promise.allSettled([sentReady, fontsReady]).then(() => settleThreadEntryFollow(token));
}
function interruptThreadEntryFollow(event) {
if (!threadEntryFollowToken()) return;
if (event.type === "keydown") {
const target = event.target;
if (target && (target.matches?.("input, textarea, select") || target.isContentEditable)) return;
if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) return;
}
// A pointer press only means scrolling when it lands on the scrollbar's
// owning element. Bubble/button presses must not cancel hydration follow.
if (event.type === "pointerdown" && event.target !== roomScrollElement()) return;
threadDetailEntryFollow = null;
}
for (const roomScroller of [scrollEl, thDetailEl]) {
roomScroller.addEventListener("wheel", interruptThreadEntryFollow, { capture:true, passive:true });
roomScroller.addEventListener("touchstart", interruptThreadEntryFollow, { capture:true, passive:true });
roomScroller.addEventListener("pointerdown", interruptThreadEntryFollow, { capture:true, passive:true });
roomScroller.addEventListener("keydown", interruptThreadEntryFollow, { capture:true });
}
function captureRoomScroll() {
const roomScrollEl = roomScrollElement();
if (activeView !== "threads" || !roomScrollEl) return null;
const viewportTop = roomScrollEl.getBoundingClientRect().top;
const messages = [...thHistoryEl.querySelectorAll("[data-msg]")];
const anchor = messages.find((message) => message.getBoundingClientRect().bottom > viewportTop) || null;
return {
top: roomScrollEl.scrollTop,
anchorId: anchor ? anchor.getAttribute("data-msg") : null,
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewportTop : 0,
};
}
function restoreRoomScroll(saved) {
const roomScrollEl = roomScrollElement();
if (!saved || !roomScrollEl) return;
// Restore the numeric position first as a safe fallback. Then pin the same
// visible message to the same viewport offset, so a refresh while reading
// cannot shift the user when older/newer messages changed the room height.
roomScrollEl.scrollTop = Number(saved.top) || 0;
if (!saved.anchorId) return;
const anchor = [...thHistoryEl.querySelectorAll("[data-msg]")]
.find((message) => message.getAttribute("data-msg") === saved.anchorId);
if (!anchor) return;
const viewportTop = roomScrollEl.getBoundingClientRect().top;
const currentOffset = anchor.getBoundingClientRect().top - viewportTop;
roomScrollEl.scrollTop += currentOffset - (Number(saved.anchorOffset) || 0);
}
function openReader(id, source) {
// A notification banner remains `peeking` even after the user moves into
// Tasks. Both prepareReaderMorph() and syncCardSize() deliberately
// refuse to resize in that state, so opening a row could paint the full
// reader into the 400x118 banner window. The click explicitly asks for the
// full surface: retire the transient banner before taking the snapshot.
if (peeking) {
clearPeek();
presented = true;
}
const morphFromList = prepareReaderMorph(activeView);
readerId = id;
const opened = readerRow(id);
const openedState = opened ? relayWorkState(opened) : "idle";
readerWorkVisible = Boolean(opened && (
opened.source?.host === "relay-agent-run"
|| workProviderPrompts.has(String(id))
|| !["idle", "waiting", "parked"].includes(openedState)
));
readerTab = readerWorkVisible ? "work" : "you";
// Remember the WHOLE place, not just the view: a room is a person, so
// coming back without the party hint (or without the split) left the rail
// standing beside an empty pane (David, live).
if (activeView !== "reader") {
readerReturn = {
view: activeView,
threadId: threadDetailId,
party: threadDetailPartyHint,
expanded: chatExpanded,
source: threadsSource,
roomScroll: captureRoomScroll(),
outerScrollTop: scrollEl ? scrollEl.scrollTop : 0,
};
}
readerSource = source || (activeView === "threads" || activeView === "reader" ? readerSource : activeView);
activeView = "reader";
commitNavigation({ outerScrollTop: 0 });
if (morphFromList) startReaderMorph();
}
function closeReader() {
cleanReaderMorph();
const back = readerReturn || { view: readerSource === "tasks" ? "tasks" : "relays", threadId: null };
if (readerId) destroyWorkScroller(readerId);
scrollEl.classList.remove("work-scroll-active"); readerViewEl.classList.remove("work-view"); readerBodyEl.classList.remove("work-mode");
readerId = null;
if (back.view === "threads" && back.threadId) {
// Re-ENTER the room through the same door that opened it. Restoring the
// fields by hand looked equivalent and was not: the pane came back blank
// (David, live). One entry point, one state.
const wasExpanded = Boolean(back.expanded);
openThreadDetail(back.threadId, back.party || "", back.source || "chat", { expanded: wasExpanded });
requestAnimationFrame(() => {
if (activeView === "threads" && threadDetailId === back.threadId) restoreRoomScroll(back.roomScroll);
});
return;
}
activeView = back.view || "relays";
commitNavigation({ outerScrollTop: back.outerScrollTop });
}
// Paragraph-level rendering only: escaped text, blank-line splits.
// The letter's markdown. Agents write the way agents write — bold, code,
// lists, tables, headings — and this rendered a raw string with the asterisks
// still in it (David, live: "the most terrible thing ever"). Rendering stays
// inside the letter's voices: serif prose, mono for machine text only.
// Only http(s) and mailto survive; anything else (javascript:, data:) is shown
// as plain text, because a relay's body is written by somebody else.
function safeHref(raw) {
const url = String(raw || "").trim();
return /^(https?:\/\/|mailto:)/i.test(url) ? url : "";
}
function inlineMd(text) {
const codes = [];
// Code spans are literal: pull them out before any other rule can touch them.
let s = String(text || "").replace(/`([^`]+)`/g, (_m, code) => {
codes.push(code);
return ` C${codes.length - 1} `;
});
s = esc(s);
// Images before links — the syntaxes differ by one leading character.
s = s.replace(/!\[([^\]]*)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"[^&]*")?\)/g, (m, alt, src) => {
const href = safeHref(src);
return href ? `<img class="md-img" src="${esc(href)}" alt="${alt}">` : esc(alt);
});
s = s.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()]*\))+)(?:\s+"[^&]*")?\)/g, (m, label, url) => {
const href = safeHref(url);
return href ? `<a href="${esc(href)}" data-md-link="1">${label}</a>` : label;
});
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, (m, pre, url) => `${pre}<a href="${esc(url)}" data-md-link="1">${esc(url)}</a>`);
s = s.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, "$1<em>$2</em>");
s = s.replace(/(^|[^_\w])__([^_]+)__(?!\w)/g, "$1<strong>$2</strong>");
s = s.replace(/(^|[^_\w])_([^_\n]+)_(?!\w)/g, "$1<em>$2</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
return s.replace(/ C(\d+) /g, (_m, i) => `<code class="md-code">${esc(codes[Number(i)])}</code>`);
}
function listItemsHtml(lines, ordered) {
// One level of nesting is honoured; deeper indents fold into their parent so
// a deep tree never grows a staircase of boxes inside the measure.
const out = [];
let sub = [];
let subOrdered = false;
const flush = () => {
if (!sub.length) return;
const nested = `<${subOrdered ? "ol" : "ul"} class="md-list">${sub.join("")}</${subOrdered ? "ol" : "ul"}>`;
out[out.length - 1] = out[out.length - 1].replace(/<\/li>$/, `${nested}</li>`);
sub = [];
};
for (const line of lines) {
const indented = /^(\s{2,}|\t)/.test(line);
const text = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "");
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
const body = task ? `<span class="md-task${task[1] === " " ? "" : " on"}" aria-hidden="true"></span>${inlineMd(task[2])}` : inlineMd(text);
if (indented && out.length) {
subOrdered = /^\s*\d+[.)]\s/.test(line);
sub.push(`<li>${body}</li>`);
continue;
}
flush();
out.push(`<li>${body}</li>`);
}
flush();
return `<${ordered ? "ol" : "ul"} class="md-list">${out.join("")}</${ordered ? "ol" : "ul"}>`;
}
function tableHtml(rows) {
const cells = (row) => row.replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const head = cells(rows[0]);
const body = rows.slice(2).map(cells);
return `<div class="md-tablewrap"><table class="md-table"><thead><tr>${head
.map((c) => `<th>${inlineMd(c)}</th>`)
.join("")}</tr></thead><tbody>${body
.map((r) => `<tr>${r.map((c) => `<td>${inlineMd(c)}</td>`).join("")}</tr>`)
.join("")}</tbody></table></div>`;
}
function mdToHtml(md) {
const src = String(md || "").replace(/\r\n?/g, "\n");
const lines = src.split("\n");
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i += 1; continue; }
// Fenced code: the machine voice, kept verbatim and never marked up.
const fence = line.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)\s*$/);
if (fence) {
const marker = fence[1][0];
const buf = [];
i += 1;
while (i < lines.length && !new RegExp(`^\\s*${marker}{3,}\\s*$`).test(lines[i])) { buf.push(lines[i]); i += 1; }
i += 1;
const language = fence[2] || "code";
out.push(`<figure class="md-codeblock"><figcaption class="md-codebar"><span class="md-lang">${esc(language)}</span><span class="spacer"></span><button type="button" data-code-wrap>Wrap</button><button type="button" data-code-copy>Copy</button></figcaption><pre class="md-pre"${fence[2] ? ` data-lang="${esc(fence[2])}"` : ""}><code>${esc(buf.join("\n"))}</code></pre></figure>`);
continue;
}
const heading = line.match(/^\s*(#{1,6})\s+(.*)$/);
if (heading) {
const level = Math.min(6, heading[1].length);
out.push(`<div class="md-h md-h${level}">${inlineMd(heading[2].replace(/\s+#+\s*$/, ""))}</div>`);
i += 1;
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { out.push('<div class="md-rule"></div>'); i += 1; continue; }
if (/^\s*>/.test(line)) {
const buf = [];
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, "")); i += 1; }
out.push(`<blockquote class="md-quote">${mdToHtml(buf.join("\n"))}</blockquote>`);
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1] || "")) {
const buf = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; }
out.push(tableHtml(buf));
continue;
}
const bullet = /^\s*[-*+]\s+/.test(line);
const numbered = /^\s*\d+[.)]\s+/.test(line);
if (bullet || numbered) {
const buf = [];
const same = (l) => (bullet ? /^\s*[-*+]\s+/.test(l) : /^\s*\d+[.)]\s+/.test(l)) || /^(\s{2,}|\t)\S/.test(l);
while (i < lines.length && same(lines[i])) { buf.push(lines[i]); i += 1; }
out.push(listItemsHtml(buf, numbered));
continue;
}
const buf = [];
while (
i < lines.length &&
lines[i].trim() &&
!/^\s*(#{1,6})\s+/.test(lines[i]) &&
!/^\s*>/.test(lines[i]) &&
!/^\s*[-*+]\s+/.test(lines[i]) &&
!/^\s*\d+[.)]\s+/.test(lines[i]) &&
!/^\s*(`{3,}|~{3,})/.test(lines[i])
) { buf.push(lines[i]); i += 1; }
out.push(`<p>${inlineMd(buf.join("\n")).replace(/\n/g, "<br>")}</p>`);
}
return out.join("");
}
function readerParagraphs(md) { return mdToHtml(md); }
// The agent kicker carries METADATA, not a claim — the same grammar as the
// human kicker above it (sender · time · read). The tab already says who the
// document is for; the only fact the line can add is how much document there
// is. Counted on the source markdown with pure-syntax tokens dropped, so a
// bulleted list is not inflated by its dashes.
function agentWordCount(md) {
return String(md || "")
.trim()
.split(/\s+/)
.filter((token) => /[\p{L}\p{N}]/u.test(token))
.length;
}
function agentWeightLabel(md) {
const n = agentWordCount(md);
if (!n) return "";
return ` · ${n.toLocaleString("en-US")} ${n === 1 ? "WORD" : "WORDS"}`;
}
// A letter that fits its page must not scroll — the frosted pane exists
// only when there is genuinely more letter beneath it (David's rule).
// Measured against the COMPACT layout (pane relaxed): if that fits, keep
// it relaxed; if not, the pane pins and the overflow reads out of the
// blur. The card's height is still springing when the reader renders, so
// the verdict waits for the spring to settle — during the morph the pane
// rides pinned, then relaxes if the page turns out to fit. Re-runs on
// every reader render, so opening the agent lane re-judges the page.
function assessReaderFit() {
const tick = () => {
if (activeView !== "reader") return;
if (readerBodyEl.classList.contains("work-mode")) { readerBodyEl.classList.remove("rd-fit"); return; }
if (W.v !== W.t || H.v !== H.t) { requestAnimationFrame(tick); return; }
readerBodyEl.classList.add("rd-fit");
const fits = scrollEl.scrollHeight <= scrollEl.clientHeight + 1;
readerBodyEl.classList.toggle("rd-fit", fits);
};
tick();
}
function renderReader() {
const r = readerRow(readerId);
if (!r) { readerBodyEl.innerHTML = '<div class="cv-empty">This relay is gone.</div>'; return; }
const request = isTaskRow(r);
const sender = relaySender(r);
const chatOwnedWork = r.source?.host === "relay-agent-run";
const triggerRow = chatOwnedWork && r.inReplyToRelayId ? relayById(r.inReplyToRelayId) : null;
const subject = chatOwnedWork && triggerRow
? (String(triggerRow.forHuman || "").trim() || relaySubject(triggerRow))
: request ? (String(r.title || "").trim() || relaySubject(r)) : relaySubject(r);
const headlineClass = subject.length > 120 ? " long very-long" : subject.length > 70 ? " long" : "";
const note = rowNotes.get(r.id);
let app = request ? routeFor(r.id).app : agentAppName();
const providerPrompt = request ? workProviderPrompts.get(String(r.id)) : null;
const status = request && !providerPrompt ? taskStatusLine(r, { inline: true }) : "";
// The FOLDER: tabs cut from the paper, one per document — only when the
// relay actually carries two. Orange square = you, blue = your agent app.
const agentText = String(r.forAgent || "").trim();
// A Task always keeps its two immutable source documents. Once any
// provider turn exists, Work joins them as a third face and owns the live
// transcript and final answer. Each face still owns its composer: human
// reply on For you, provider Start/follow-up on For <agent> and Work.
const runState = relayWorkState(r);
// RELAY WORK IS REQUESTS SUBSTRATE (Sven, 2026-08-17): a plain relay grows
// a Work face only where the product row allows it (local builds). A
// Task keeps its own — that surface is gated by features.requests.
const workOn = payload.features?.relayWork === true;
const legacyCompletedWithoutNative = request && runState === "done" && !requestHasNativeSession(r);
const hasWork = chatOwnedWork || ((request || workOn) && (Boolean(providerPrompt) || !["idle", "waiting", "parked"].includes(runState)));
if (hasWork && !providerPrompt && !legacyCompletedWithoutNative) app = runAppName(r.id); // an existing run names the provider that actually owns it
if (!hasWork && readerTab === "work") readerTab = "you";
const workAppearing = hasWork && !readerWorkVisible;
const transition = workFaceTransitions.get(String(r.id));
const transitioningToWork = Boolean(hasWork && transition && Date.now() < transition.until);
if (workAppearing) {
readerTab = "work";
workTabAnimateUntil.set(String(r.id), Date.now() + 500);
}
readerWorkVisible = Boolean(hasWork);
const onWork = hasWork && readerTab === "work";
const onAgent = Boolean(agentText || request) && readerTab === "agent";
const requestActionable = request && taskClaimAllowsStart(r) && ["waiting", "parked", "stopped"].includes(taskBoardState(r.id));
const animateWork = hasWork && Date.now() < (workTabAnimateUntil.get(String(r.id)) || 0);
const agentWords = agentWordCount(agentText);
const onHuman = !onAgent && !onWork;
const documentList = (request || agentText) ? `
<nav class="relay-contents" data-stop="1" aria-label="Relay contents">
<span class="relay-contents-label">This Relay contains</span>
<span class="relay-contents-list">
<button type="button" class="relay-contents-row${onHuman ? " on" : ""}" data-rtab="you" aria-current="${onHuman ? "page" : "false"}">
<span class="sq you"></span><span class="relay-contents-name">Message for you</span><span class="relay-contents-state">${onHuman ? "reading now" : "view ›"}</span>
</button>
<button type="button" class="relay-contents-row${onAgent ? " on" : ""}" data-rtab="agent" aria-current="${onAgent ? "page" : "false"}">
<span class="sq ag"></span><span class="relay-contents-name">Message for your agent</span><span class="relay-contents-state">${onAgent ? "reading now" : agentWords ? `${agentWords} ${agentWords === 1 ? "word" : "words"} ›` : "view ›"}</span>
</button>
${hasWork ? `<button type="button" class="relay-contents-row work${onWork ? " on" : ""}${animateWork ? " work-new" : ""}" data-rtab="work" aria-current="${onWork ? "page" : "false"}"><span class="sq ag"${providerPrompt || legacyCompletedWithoutNative ? "" : ` style="background:${vendorSq(app)}"`}></span><span class="relay-contents-name">Work</span><span class="relay-contents-state">${onWork ? "reading now" : "view ›"}</span></button>` : ""}
</span>
</nav>` : "";
const agentDoc = `
<div class="rd-kicker"><span class="ksq"></span>MESSAGE FOR YOUR AGENT${agentWeightLabel(agentText)}</div>
<div class="rd-rule"></div>
<div class="rd-agentcopy">${readerParagraphs(agentText)}</div>`;
const humanDoc = `
<div class="rd-kicker">${request ? `Task · Created by ${esc(sender)}` : esc(sender)}${r.e2ee ? " · end-to-end encrypted" : ""} · ${esc(timeAgo(r.createdAt))}${r.unread ? "" : " · read"}</div>
<div class="rd-headline${headlineClass}">${esc(subject)}</div>
<div class="rd-rule"></div>
<div class="rd-body">${readerParagraphs(r.forHuman)}</div>
${scheduleContractHtml(r)}`;
const workDoc = `<div class="work-context">
<div class="rd-kicker" data-run-kicker="${esc(r.id)}">${legacyCompletedWithoutNative ? "Task · done" : `${request || chatOwnedWork ? `Task · Created by ${esc(sender)}` : `work · ${esc(sender)}`} · ${esc(timeAgo(r.createdAt))}`}</div>
<div class="rd-headline${headlineClass}">${esc(subject)}</div></div>
${providerPrompt
? workProviderPromptHtml(r)
: legacyCompletedWithoutNative
? `<div class="work-conversation" role="status"><div class="rd-final"><p>This Task is complete. Live Work history is unavailable for this older run.</p></div></div>`
: `<div class="work-conversation${runState === "running" ? " work-streaming" : ""}" data-run-stream="${esc(r.id)}" aria-live="polite" aria-relevant="additions text"><div class="work-starting" role="status"><span class="rd-live-tail-dot" aria-hidden="true"></span><span>Starting ${esc(app)} on this Task…</span></div></div>`}`;
const doc = onWork ? workDoc : onAgent ? agentDoc : humanDoc;
// The whisper explains what the Relay Work dock will send; without that
// dock the row names where it goes (H2), and nothing else needs saying.
const bothNote = onAgent && workOn ? `<div class="rd-dest">your agent receives both the “for you” and “for your agent” parts</div>` : "";
// The open folder owns the addressee. For you replies to the human sender;
// For <agent> starts private local work; Work continues that same native
// session. Switching documents must never silently keep Shane as the
// recipient of a message typed while looking at the Codex document.
// A composer that throws must never take the page down with it: the reader
// is the surface, and it renders even if its dock cannot.
const composer = (() => { try { return composerHtml(); } catch (error) { console.error("[reader] composer failed:", error); return ""; } })();
const sharedShelf = relaySharedShelf(r);
const claimControl = request && !onWork ? taskClaimControlHtml(r, { surface: "reader" }) : "";
function composerHtml() {
// An unstarted Task is actionable from either immutable source document:
// both For you and For <agent> expose the same Start task dock. Once it
// starts, each visible source document owns its usual addressee and Work
// continues the provider turn. Previously any
// existing Work hid the entire source-document footer, which left both
// immutable documents looking like dead pages after a run completed.
if (request && onWork && providerPrompt) return workProviderPromptDockHtml();
if (request && taskClaimAllowsStart(r) && (onAgent || onWork || requestActionable)) return requestDockHtml(r, { inline: true });
// TWO OUTCOMES, NO IN-BETWEEN (Sven): a message is either replied to
// or opened in your agent. On the agent document the reply belongs to
// the other face; here the one verb is the open — the same row the room
// paints under the bubble, so the two surfaces cannot drift. The
// composer that talks to your own agent ("Tell Claude Code anything…")
// is Relay Work — the Tasks layer wearing a composer — and mounts
// only where the product row allows it.
if (onAgent && !workOn) return `<div class="rd-host-actions" data-stop="1">${relayHostActionsHtml({
id: r.id,
direction: r.outbound ? "out" : "in",
materializedCodex: Boolean(r.materializedCodex),
materializedClaude: Boolean(r.materializedClaude),
}, { persistent: true })}</div>`;
if (onAgent || onWork) return relayWorkDockHtml(r, { inline: true });
return `
<div class="ta-dock inline" data-stop="1"><div class="qr col">
<textarea id="qrInput" rows="1" placeholder="Reply…">${esc(readerHumanDrafts.get(String(r.id)) || "")}</textarea>
<div class="ta-rail">
${replyRail((() => {
const tid = r.threadId || r.id;
const ps = [...new Set(threadMessages().filter((m) => m.threadId === tid && m.direction !== "out").map((m) => m.party).filter(Boolean))];
return ps.length ? ps : [sender];
})(), null)}
<span class="rt-spacer"></span>
<span class="ta-acts">
<button class="ta-later" type="button" data-open-in="${esc(r.id)}" data-open-in-host="codex">Open in Codex</button>
<button class="ta-later" type="button" data-open-in="${esc(r.id)}" data-open-in-host="claude">Open in Claude Code</button>
<button type="button" id="qrSend">Send</button>
</span>
</div>
</div></div>`;
}
readerBodyEl.classList.toggle("work-mode", onWork); readerViewEl.classList.toggle("work-view", onWork);
scrollEl.classList.toggle("work-scroll-active", onWork); if (!onWork) destroyWorkScroller(r.id);
readerBodyEl.innerHTML = `
<div class="reader-bar">
<button class="th-back" type="button" id="readerBack" aria-label="Back">
<svg width="8" height="13" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7 1L1 7l6 6"/></svg>
</button>
<span class="reader-name">${esc(sender)}</span>
${request ? `<button class="reader-delete" type="button" data-reader-delete="${esc(r.id)}" aria-label="Move task to Recently Deleted" title="Move task to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : ""}
</div>
${documentList}
${onWork ? `<div class="work-surface${transitioningToWork ? " work-entering" : ""}" data-work-active="${runState === "running"}">
${transitioningToWork ? `<div class="work-transition-source" aria-hidden="true" inert><div class="rd-col">${transition.fromTab === "agent" ? agentDoc : humanDoc}</div></div>` : ""}
<div class="work-scroll" data-work-scroll="${esc(r.id)}" tabindex="0" role="log" aria-label="Work conversation">
<div class="work-scroll-inner">${workDoc}<div class="work-response-spacer" aria-hidden="true"></div></div></div>
<div class="work-footer" data-work-footer="${esc(r.id)}"><div class="work-footer-inner"><div class="rd-col run">
${status}${composer}<div class="row-err${note && note.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}" data-stop="1">${esc((note && note.text) || "")}</div>
</div></div></div></div>` : `<div class="rd-col${readerTabSwap ? " swap" : ""}">${doc}</div>
<div class="rd-foot"><div class="rd-col">${sharedShelf}${status}${bothNote}${claimControl}${composer}
<div class="row-err${note && note.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}" data-stop="1">${esc((note && note.text) || "")}</div>
</div></div>`}`;
if (onWork) bindWorkScroller(r.id);
if (transitioningToWork) setTimeout(() => {
const current = workFaceTransitions.get(String(r.id));
if (current === transition) workFaceTransitions.delete(String(r.id));
readerBodyEl.querySelector(".work-transition-source")?.remove();
readerBodyEl.querySelector(".work-surface")?.classList.remove("work-entering");
}, Math.max(0, transition.until - Date.now()) + 20);
document.getElementById("readerBack").addEventListener("click", (e) => { e.stopPropagation(); closeReader(); });
const readerDelete = readerBodyEl.querySelector("[data-reader-delete]");
if (readerDelete) readerDelete.addEventListener("click", async (event) => {
event.stopPropagation();
if (readerDelete.disabled) return;
readerDelete.disabled = true;
try {
const res = await window.relay.deleteRelay(r.id);
if (!res || res.ok === false) {
setRowNote(r.id, (res && res.error) || "Could not move this task to Recently Deleted.", "err");
readerDelete.disabled = false;
renderReader();
return;
}
closeReader();
} catch (error) {
setRowNote(r.id, (error && error.message) || "Could not move this task to Recently Deleted.", "err");
readerDelete.disabled = false;
renderReader();
}
});
for (const b of readerBodyEl.querySelectorAll("[data-rtab]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
const t = b.getAttribute("data-rtab");
if (t === readerTab) return;
const draftBox = readerBodyEl.querySelector(".ta-note");
if ((request || agentText) && draftBox) requestWorkDrafts.set(String(r.id), draftBox.value || "");
const humanDraftBox = readerBodyEl.querySelector("#qrInput");
if (humanDraftBox) readerHumanDrafts.set(String(r.id), humanDraftBox.value || "");
readerTab = t;
readerTabSwap = true;
renderReader();
readerTabSwap = false;
if (t === "work") syncRunMirrors();
});
}
wireHostOpen(readerBodyEl);
wireTaskClaimControls(readerBodyEl, () => { renderReader(); renderAll(); });
wireAttachmentChips(readerBodyEl);
for (const button of readerBodyEl.querySelectorAll("[data-code-wrap]")) {
button.addEventListener("click", () => {
const block = button.closest(".md-codeblock");
const wrapped = block?.classList.toggle("wrap");
button.textContent = wrapped ? "Scroll" : "Wrap";
});
}
for (const button of readerBodyEl.querySelectorAll("[data-code-copy]")) {
button.addEventListener("click", async () => {
const code = button.closest(".md-codeblock")?.querySelector("code")?.textContent || "";
try {
await navigator.clipboard.writeText(code);
button.textContent = "Copied";
setTimeout(() => { if (button.isConnected) button.textContent = "Copy"; }, 1400);
} catch { button.textContent = "Select"; }
});
}
assessReaderFit();
if (request || onAgent || onWork || readerBodyEl.querySelector("[data-open-in]")) {
wireRequestControls(readerBodyEl, (action) => {
if (action === "later") closeReader();
else {
renderReader();
renderAll();
readerTabSwap = false;
}
});
}
if (!onAgent && !onWork) {
const send = document.getElementById("qrSend");
const input = document.getElementById("qrInput");
// The agent addressee uses the Task-style dock, so it deliberately has
// no human-reply #qrSend/#qrInput pair. Switching the folder to "For
// Claude Code" used to throw here and strand every control wired below.
if (send && input) {
let readerReplySending = false;
input.addEventListener("input", () => readerHumanDrafts.set(String(r.id), input.value || ""));
const doSend = async () => {
if (readerReplySending) return;
const text = (input.value || "").trim();
if (!text) return;
readerReplySending = true;
send.disabled = true;
const idempotencyKey = `reader_${r.id}_${Date.now()}`;
optimisticChatReplies.set(idempotencyKey, {
id:`optimistic:${idempotencyKey}`, relayId:"", threadId:r.threadId || r.id,
inReplyToRelayId:r.id, explicitReply:true,
direction:"out", seen:false, seenAt:null, title:text, body:text,
party:sender, partyKey:(r.senderEmail || r.fromEmail)
? `email:${String(r.senderEmail || r.fromEmail).trim().toLowerCase()}`
: `name:${String(sender).trim().toLowerCase()}`,
at:new Date().toISOString(), isGroup:false, groupName:"", unread:false,
textLike:true, request:false, pending:true,
reactions:{ aggregates:[], events:[] }, preview:"",
});
// Same contract as the room composer: Send commits the reply to this
// device's outbox and answers immediately. The queue owes the
// delivery, so a dead connection no longer ends with the words back
// under the cursor and a red note.
const res = await window.relay.sendReply({
text,
agentMentions: agentMentionSpans(text),
inReplyToRelayId: r.id,
idempotencyKey,
chat: {
threadId: r.threadId || r.id,
party: sender,
partyKey: (r.senderEmail || r.fromEmail)
? `email:${String(r.senderEmail || r.fromEmail).trim().toLowerCase()}`
: `name:${String(sender).trim().toLowerCase()}`,
isGroup: false,
groupName: "",
},
}).catch((error) => ({ ok:false, error:String(error?.message || error) }));
readerReplySending = false;
send.disabled = false;
if (!res?.ok) {
// Only a device that cannot hold the message at all gets here.
optimisticChatReplies.delete(idempotencyKey);
setRowNote(r.id, res?.error || "Reply did not send.", "err");
input.focus();
return;
}
const optimistic = optimisticChatReplies.get(idempotencyKey);
if (optimistic && res.entry) {
optimistic.fromOutbox = true;
optimistic.outboxId = String(res.entry.id || idempotencyKey);
optimistic.outboxState = String(res.entry.state || "queued");
optimistic.outboxAttempts = Number(res.entry.attempts || 0);
}
input.value = "";
readerHumanDrafts.delete(String(r.id));
// "Queued" would be a lie the instant the network is fine, and "Sent"
// would be one while it is not. The message's own bubble carries the
// state; this note only confirms the composer let go of it.
setRowNote(r.id, `Sent to ${sender}.`, "ok");
fadeRowNoteLater(r.id, 3000);
renderReader();
};
send.addEventListener("click", doSend);
dressComposer(input, doSend);
}
const oc = readerBodyEl.querySelector("[data-open-current]");
if (oc) oc.addEventListener("click", (e) => {
clearRowNote(r.id);
const whole = e.altKey && r.threadId;
const n = whole ? threadMessages().filter((m) => m.threadId === r.threadId).length : 0;
// Continuity is judged BEFORE the hand-off marks the conversation.
const knows = threadHanded(r.threadId || r.id);
// An in-chat open answers with its own note (staged / delivered / woken),
// which supersedes this one; the paths that answer with nothing used to
// leave this line up for good, so it retires with the open too.
openRelayFromUI(r.id, r.outbound ? "sent" : "relay", "current");
setOpeningNote(r.id, whole
? (knows ? `Opening the whole conversation (${n} relays) in the ${agentAppName()} chat that knows it — staged…`
: `Opening ${agentAppName()} on this whole conversation (${n} relays) — staged, nothing sends until you do…`)
: (knows ? `Reopening the ${agentAppName()} chat that knows this conversation…`
: `Opening ${agentAppName()} on this conversation — staged, nothing sends until you do…`));
renderReader();
});
// The reader's old "open in a window" hatch pointed at preview.html too;
// there is nothing on that surface the reader does not already show.
const ow = readerBodyEl.querySelector("[data-open-window]");
if (ow) ow.remove();
}
for (const isl of readerBodyEl.querySelectorAll("[data-sch-choice]")) {
for (const opt of isl.querySelectorAll("[data-auto]")) {
opt.addEventListener("click", async (e) => {
e.stopPropagation();
const autoSend = opt.getAttribute("data-auto") === "1";
// Slide FIRST, persist after: the thumb must move with the finger,
// not after a round trip (David: "a beautiful, sliding one with
// extremely elegant physics", never a discrete jump).
isl.classList.toggle("auto", autoSend);
await saveSchedule({ id: isl.getAttribute("data-sch-choice"), autoSend });
renderReader();
});
}
}
for (const sb of readerBodyEl.querySelectorAll("[data-sch]")) {
sb.addEventListener("click", async (e) => {
e.stopPropagation();
const verb = sb.getAttribute("data-sch");
const id = sb.getAttribute("data-sch-id");
const state = { accept: "accepted", decline: "declined", pause: "paused", resume: "accepted", end: "ended" }[verb];
if (!state) return;
sb.disabled = true;
await saveSchedule({ id, state, ...(verb === "accept" ? { acceptedAt: new Date().toISOString() } : {}) });
renderReader();
});
}
if (onWork) syncRunMirrors();
}
// ---------- the Tasks board: waiting / running / parked ----
// The board is the same pill filtered to obligations, so its rows use the
// RELAY anatomy — .th-title with the read/unread system (never hardcoded
// ink), preview when folded — and the same interaction as every bubble:
// tap unfolds in place to the body + inline dock; nothing navigates.
const expandedRequestIds = new Set(); // board rows currently unfolded
let completedTasksExpanded = false;
let taskInboxZero = false;
function renderTasksBoard() {
const rows = taskRows();
tasksEmptyEl.classList.toggle("gone", rows.length > 0);
const groups = { unclaimed: [], waiting: [], claimed: [], running: [], review: [], stopped: [], done: [], parked: [], scheduled: [] };
for (const r of rows) {
const sc = scheduleFor(r.id);
const state = taskBoardState(r.id);
// An accepted or paused cadence OWNS the row between runs.
if (sc && (sc.state === "accepted" || sc.state === "paused") && state !== "running") {
groups.scheduled.push(r);
continue;
}
const claim = taskClaimFor(r);
if (claim?.state === "unclaimed" && claim.workState !== "completed") groups.unclaimed.push(r);
else if (state === "waiting" && claim?.claimant && !claim.claimant.self) groups.claimed.push(r);
else groups[state].push(r);
}
const rowsHtml = (items, renderRight) => items.map((r) => {
const sender = relaySender(r);
const title = String(r.title || "").trim() || relaySubject(r);
const claim = r.taskClaim;
const channel = claim?.channel?.name || r.recipientGroupName || "";
const creator = claim?.createdBy?.name || sender;
// A request row is EXACTLY a list row: disc, name + time, serif line,
// one quiet control. No topic label, no preview, no unfold — tapping
// opens the reader (the letter ontology), where reading happens.
return `
<div class="tb-row${r.unread ? " unread" : ""}" role="button" tabindex="0" data-request="${esc(r.id)}">
<span class="avatar sm" style="--av-h:${avatarHue(sender)}" aria-hidden="true">${esc(avatarInitials(sender))}</span>
<div class="tb-main">
<div class="tb-sub">${channel ? `${esc(channel)} · ` : ""}Created by ${esc(creator)} · ${esc(timeAgo(r.createdAt))}</div>
<div class="rk-line"><span class="th-title">${esc(title)}</span><span class="tb-actions">${renderRight(r)}<button class="tb-delete" type="button" data-task-delete="${esc(r.id)}" data-stop="1" aria-label="Move task to Recently Deleted" title="Move task to Recently Deleted"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button></span></div>
<div class="row-err${rowNotes.get(r.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}" data-stop="1">${esc(rowNotes.get(r.id)?.text || "")}</div>
</div>
${taskClaimControlHtml(r, { surface: "tasks" })}
</div>`;
}).join("");
const section = (label, items, renderRight) => items.length ? `
<div class="tb-group">${label}<span class="n">${items.length}</span></div>
${rowsHtml(items, renderRight)}` : "";
// Group membership IS the status: a "parked" chip on a row under a
// "Parked" header, plus a note saying it again, said one thing three
// times (Sven). Rows carry no status chips and no notes — moving between
// groups is the feedback. Waiting rows carry a direct Start task verb;
// every row carries the quiet, recoverable Recently Deleted affordance.
const unfinishedHtml =
section("Working", groups.running, () => `<span class="tb-working"><span class="tb-working-orb" aria-hidden="true"></span>Working</span>`)
+ section("Unclaimed", groups.unclaimed, () => "")
+ section("Waiting on you", groups.waiting, (r) => taskClaimAllowsStart(r) ? `<button class="tb-go" type="button" data-approve="${esc(r.id)}">Start task</button>` : "")
+ section("Claimed", groups.claimed, () => "")
+ section("On a schedule", groups.scheduled, (r) => {
const sc = scheduleFor(r.id) || {};
const phrase = sc.state === "paused"
? `paused · ${esc(sc.cadence || "")}`
: `${esc(sc.cadence || "")}${sc.countdown ? ` · ${esc(sc.countdown)}` : ""}`;
return `<span class="tb-when">${phrase}</span>`;
})
+ section("Ready for your review", groups.review, () => "")
// The state's name is the fact: it began, the agent is gone, and nothing
// came back. "Stopped" named nothing and told the reader nothing to do
// (David). This one says what happened and implies the next move — the
// dock is back, with Start on it.
+ section("Didn't finish", groups.stopped, (r) => `<button class="tb-go" type="button" data-request-again="${esc(r.id)}">start again</button>`)
+ section("Parked", groups.parked, () => "");
const unfinishedCount = groups.running.length + groups.unclaimed.length + groups.waiting.length + groups.claimed.length + groups.scheduled.length
+ groups.review.length + groups.stopped.length + groups.parked.length;
const inboxZeroNow = groups.done.length > 0 && unfinishedCount === 0;
const celebrateNow = inboxZeroNow && !taskInboxZero;
taskInboxZero = inboxZeroNow;
const zeroHtml = inboxZeroNow ? `
<div class="task-zero${completedTasksExpanded ? " compact" : ""}${celebrateNow ? " celebrate" : ""}" data-task-zero>
<span class="tz-glow" aria-hidden="true"></span>
<span class="tz-mark" aria-hidden="true"><span class="tz-tile peach"></span><span class="tz-tile cream"></span><span class="tz-tile violet"></span></span>
<div class="tz-title">Nicely done.</div>
<div class="tz-sub">Your task list is clear.</div>
</div>` : "";
const completedHtml = groups.done.length ? `
<button class="tb-completed-toggle" type="button" data-completed-toggle aria-expanded="${completedTasksExpanded}" aria-controls="completedTasksList">
<span>Completed</span>
<span class="tb-completed-meta"><span>${groups.done.length}</span><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 4.5 3 3 3-3"/></svg></span>
</button>
<div class="tb-completed-reveal${completedTasksExpanded ? " open preexpanded" : ""}" id="completedTasksList" aria-hidden="${!completedTasksExpanded}"${completedTasksExpanded ? "" : " inert"}>
<div class="tb-completed-clip">${rowsHtml(groups.done, () => "")}</div>
</div>` : "";
tasksListEl.innerHTML = zeroHtml + unfinishedHtml + completedHtml;
const completedToggle = tasksListEl.querySelector("[data-completed-toggle]");
if (completedToggle) {
completedToggle.addEventListener("click", () => {
completedTasksExpanded = completedToggle.getAttribute("aria-expanded") !== "true";
completedToggle.setAttribute("aria-expanded", String(completedTasksExpanded));
tasksListEl.querySelector("[data-task-zero]")?.classList.toggle("compact", completedTasksExpanded);
const reveal = tasksListEl.querySelector("#completedTasksList");
if (!reveal) return;
if (reveal.classList.contains("preexpanded")) {
reveal.classList.remove("preexpanded");
void reveal.offsetHeight;
}
reveal.classList.toggle("open", completedTasksExpanded);
reveal.setAttribute("aria-hidden", String(!completedTasksExpanded));
if (completedTasksExpanded) reveal.removeAttribute("inert");
else reveal.setAttribute("inert", "");
});
}
for (const z of tasksListEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
for (const el of tasksListEl.querySelectorAll(".tb-row")) {
el.addEventListener("click", () => {
const id = el.getAttribute("data-request");
// A request follows the letter ontology (David's round): tap → the pill
// expands into the reader, where the dock is the composer. Opening
// reads it. The row's ghost Start stays for one-tap approval.
const r = taskRows().find((x) => x.id === id);
const shouldAck = Boolean(r && r.unread);
if (r && r.unread) {
r.unread = false; // the badge follows the open immediately, not the next daemon poll
}
openReader(id, "tasks");
if (shouldAck) persistReadIds([id], { afterPaint: true });
});
}
for (const again of tasksListEl.querySelectorAll("[data-request-again]")) {
again.addEventListener("click", (e) => {
e.stopPropagation();
// A state without a remedy is just bad news. The fix is one tap, from
// the board, on the route the Task already names (David).
const id = again.getAttribute("data-request-again");
const rt = routeFor(id);
again.disabled = true;
again.textContent = "starting…";
resetRunFeedForNewRun(id);
taskLocal.set(id, "running");
window.relay.taskStart(id, {
host: hostKeyFor(rt.app),
model: modelIdFor(rt.app, rt.model),
effort: String(rt.effort || "high").toLowerCase(),
note: "",
permission: permMode(/claude/i.test(String(rt.app)) ? "claude" : "codex"),
}).then((res) => {
if (!res || !res.ok) { taskLocal.delete(id); setRowNote(id, (res && (res.error || res.runError)) || "It did not start.", "err"); }
renderAll();
}).catch((err) => { taskLocal.delete(id); setRowNote(id, String(err && err.message || err).slice(0, 120), "err"); renderAll(); });
});
}
for (const btn of tasksListEl.querySelectorAll("[data-task-delete]")) {
btn.addEventListener("click", async (event) => {
event.stopPropagation();
if (btn.disabled) return;
const id = btn.getAttribute("data-task-delete");
btn.disabled = true;
clearRowNote(id);
try {
const res = await window.relay.deleteRelay(id);
if (!res || res.ok === false) {
setRowNote(id, (res && res.error) || "Could not move this task to Recently Deleted.", "err");
btn.disabled = false;
renderTasksBoard();
}
} catch (error) {
setRowNote(id, (error && error.message) || "Could not move this task to Recently Deleted.", "err");
btn.disabled = false;
renderTasksBoard();
}
});
}
wireRequestControls(tasksListEl, () => { renderTasksBoard(); renderAll(); });
wireTaskClaimControls(tasksListEl, () => { renderTasksBoard(); renderAll(); });
}
// Grow the window as the user scrolls toward the end. Cheap: each extension
// renders one more page rather than the whole inbox.
const RENDER_WINDOW = 25;
let renderedThreadCount = 0;
let hasMoreThreads = false;
scrollEl.addEventListener("scroll", () => {
if (!hasMoreThreads || activeView !== "relays") return;
const nearEnd = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 240;
if (!nearEnd) return;
renderedThreadCount += RENDER_WINDOW;
renderRelays();
}, { passive: true });
// Preview plus the two host-open actions, as one small text-first menu.
// Preview is the primary, non-navigation path; the other actions hand the
// relay to a current or fresh host session.
function openActionsHtml(id, { thread, previewable = true, threadId = "", messageCount = 0, sent = false, task = false, shareLinkUrl = "" } = {}) {
// Sent rows use their own data-* namespace so the Sent list's wiring stays
// independent of the Relays list (same menu, same three actions, own IPC).
const preview = sent ? `data-sent-preview="${esc(id)}"` : thread ? `data-thread-preview="${esc(id)}"` : `data-preview="${esc(id)}"`;
const cur = sent ? `data-sent-open-current="${esc(id)}"` : thread ? `data-thread-open-current="${esc(id)}"` : `data-open-current="${esc(id)}"`;
const fresh = sent ? `data-sent-open-fresh="${esc(id)}"` : thread ? `data-thread-open-fresh="${esc(id)}"` : `data-open-fresh="${esc(id)}"`;
// Labels name the destination and nothing else — "Current chat" / "New
// chat". (Host-branded labels were tried and cut: the pill already opens
// into the right app; saying so per-row was noise. Sven, 2026-08-06.)
const curLabel = "Current chat";
const freshLabel = "New chat";
const previewItem = previewable
? `<button class="oa-item oa-primary" type="button" role="menuitem" ${preview}>Preview</button><div class="oa-sep" role="separator"></div>`
: "";
// A task's verbs are Preview and Start — "Current chat"/"New chat" describe
// reading surfaces, but a task is a job you ignite. Start currently rides
// the fresh-open wiring; the full start flow (note, model, receipt) lands
// with the task preview face.
if (task) {
// Start from the tray = the REAL start flow (receipt, background run,
// sender reply) with the default runtime — never a bare fresh-open,
// which forges a chat that sits idle.
return `
<div class="open-actions row-open-card" data-stop="1" role="menu">
${previewItem}
<button class="oa-item${previewable ? "" : " oa-primary"}" type="button" role="menuitem" data-task-start="${esc(id)}">Start</button>
${threadId ? `<div class="oa-sep" role="separator"></div>
<button class="oa-item" type="button" role="menuitem" data-thread-history="${esc(threadId)}">Read conversation${messageCount ? ` · ${messageCount}` : ""}</button>` : ""}
</div>`;
}
// A link that still resolves is pasted, not opened: copying it is the only
// action on this row that sends anything.
const copyLink = shareLinkUrl
? `<div class="oa-sep" role="separator"></div>
<button class="oa-item" type="button" role="menuitem" data-sent-copy-link="${esc(id)}" data-share-url="${esc(shareLinkUrl)}">Copy link</button>`
: "";
return `
<div class="open-actions row-open-card" data-stop="1" role="menu">
${previewItem}
<button class="oa-item${previewable ? "" : " oa-primary"}" type="button" role="menuitem" ${cur}>${esc(curLabel)}</button>
<div class="oa-sep" role="separator"></div>
<button class="oa-item" type="button" role="menuitem" ${fresh}>${esc(freshLabel)}</button>
${copyLink}
${threadId ? `<div class="oa-sep" role="separator"></div>
<button class="oa-item" type="button" role="menuitem" data-thread-history="${esc(threadId)}">Read conversation${messageCount ? ` · ${messageCount}` : ""}</button>` : ""}
</div>`;
}
// Conversations get the same trash affordance as single relays — the unified
// list made that inconsistent (a 1-message conversation renders as a relay row
// WITH delete; a 2-message one rendered as a conversation row without). One
// click removes every inbound relay in the conversation; outbound copies are
// the recipient's to delete, so a sent-only conversation has nothing to trash.
function threadDeleteButton(t) {
const ids = (t.msgs || []).filter((m) => m.direction === "in").map((m) => m.id).filter(Boolean);
if (!ids.length) return "";
return `<button class="row-delete" type="button" data-delete-thread="${esc(ids.join(","))}" data-stop="1" aria-label="Move this conversation to Recently Deleted" title="Move this conversation to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>`;
}
// The classic relay row (used flat while peeking, and for single-message
// conversations in the unified list). Row click toggles the open-actions
// card; the buttons carry the actual opens.
function relayRowHtml(r, seen) {
const fresh = seen.size > 0 && !seen.has(r.id);
const kind = r.relayNotificationKind;
const isTask = kind === "task";
const subject = relaySubject(r);
const sender = relaySender(r);
const actions = relayActionsHtml(r);
// Cards show the TITLE ONLY. The old truncated body preview leaked the
// agent-oriented detail layer — often unreadable out of context — and cost a
// third of the row to say little. Titles now carry what/why/ask (see
// relay_send's title guidance), so the detail waits for Preview or an open.
const previewHtml = "";
// "human_question" stays deletable-only compat: the retired question flow no
// longer renders or answers, but a row staged by an old build must still be
// removable from the list.
const canDelete = kind === "plain_relay" || kind === "task" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
const deleteButton = canDelete ? `<button class="row-delete" type="button" data-delete="${esc(r.id)}" data-stop="1" aria-label="Move to Recently Deleted" title="Move to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : "";
const expanded = r.id === expandedRelayId;
const openCard = expanded ? openActionsHtml(r.id, { thread: false, task: isTask }) : "";
return `
<div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}${canDelete ? " deletable" : ""}${expanded ? " expanded" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
${deleteButton}
<span class="avatar" style="--av-h:${avatarHue(sender)}" aria-hidden="true">${esc(avatarInitials(sender))}</span>
<div class="rk-body">
<div class="rk-top">
<span class="rk-sender">${esc(sender)}</span>
${isTask ? `<span class="kchip">Task</span>` : ""}
${r.e2ee ? `<span class="echip" title="Only the sender's and recipient's devices can read this message">Encrypted</span>` : ""}
<span class="rk-time">${timeAgo(r.createdAt)}</span>
</div>
<div class="rk-subject">${esc(subject)}</div>
${previewHtml}
${expanded ? attachmentPlates(r.attachments, { relayId: r.id }) : ""}
${actions}
${openCard}
<div class="row-err${rowNotes.get(r.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}">${esc(rowNotes.get(r.id)?.text || "")}</div>
</div>
</div>`;
}
// The white up-arrow inside the round prussian Send button.
const SEND_ARROW_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
// The inline action affordances for each relay kind. task_request / share_approval
// never reach the Relays list while the Tasks product surface is hidden.
function relayActionsHtml(r) {
const k = r.relayNotificationKind;
if (k === "connector_reauth") {
return `
<div class="rk-actions" data-stop="1">
<button class="act-btn accept" type="button" data-review="${esc(r.id)}">Reconnect</button>
</div>`;
}
// task_completed / task_cancelled / task_failed and anything else: plain attention row.
return "";
}
// Sender discs: initials on a hue seeded from the name — stable per contact,
// day and night (the theme tokens set disc lightness, the hue never moves).
function avatarHue(name) {
const str = String(name || "?");
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) % 360;
return h;
}
function avatarInitials(name) {
// Four words that are not a person's name. "SL" reads as somebody's
// initials; ? is this function's own glyph for a person it cannot name.
if (String(name || "") === "Someone with the link") return "?";
const parts = String(name || "?").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).replace(/^./, (c) => c.toUpperCase());
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function relayById(id) { return (payload.relays || []).find((r) => r.id === id) || null; }
function showRowError(id, message) {
const el = relaysListEl.querySelector('[data-err="' + cssId(id) + '"]');
if (el) el.textContent = message || "";
}
function cssId(id) { return String(id).replace(/"/g, '\\"'); }
// Injection hand-off notes are STATE, not one-shot DOM writes: the relay list is
// rebuilt via innerHTML on every payload push, which used to wipe the note within
// seconds — precisely while the user was staring at a click that looked dead.
// relayRowHtml renders the current note into every rebuild; timers only ever
// remove entries from this map. id -> { text, cls }
const rowNotes = new Map();
function setRowNote(id, text, cls) {
rowNotes.set(id, { text, cls });
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
el.textContent = text;
el.classList.toggle("ok", cls === "ok");
}
}
// The .row-err slot carries both inline errors and the green injection note, and one
// relay can be rendered into more than one list, so wipe every copy of the row's slot.
function clearRowNote(id) {
rowNotes.delete(id);
for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
el.textContent = "";
el.classList.remove("ok");
}
}
// A GREEN NOTE IS TRANSIENT; AN ERROR IS NOT. Every "ok" note narrates an
// action the user just took, so it retires when that action lands — main
// reports the open home (openDone), or a few seconds pass. An error stays: it
// is the only record of a click that went nowhere. Both timers are guarded by
// the text that scheduled them, so anything written over the note since is
// the newer truth and survives (David, live, on a finished open: the green
// line just sat there).
const noteTimers = new Map(); // id -> { kind, text, shownAt, timer }
const OPENING_NOTE_FALLBACK_MS = 6000; // the safety the row spinner already keeps
const OPENING_NOTE_MIN_MS = 900; // a note nobody can read is worse than none
function noteStillReads(id, text) { return rowNotes.get(id)?.text === text; }
function cancelNoteTimer(id) {
const entry = noteTimers.get(id);
if (!entry) return;
clearTimeout(entry.timer);
noteTimers.delete(id);
}
function scheduleNoteClear(id, { kind, text, shownAt, ms }) {
cancelNoteTimer(id);
noteTimers.set(id, { kind, text, shownAt, timer: setTimeout(() => {
cancelNoteTimer(id);
if (noteStillReads(id, text)) clearRowNote(id);
}, ms) });
}
// "Opening in {app}…" — up until the open lands, never longer.
function setOpeningNote(id, text) {
setRowNote(id, text, "ok");
scheduleNoteClear(id, { kind: "opening", text, shownAt: performance.now(), ms: OPENING_NOTE_FALLBACK_MS });
}
// The open landed: let the line finish being readable, then take it away.
function retireOpeningNote(id) {
const entry = noteTimers.get(id);
if (!entry || entry.kind !== "opening" || !noteStillReads(id, entry.text)) return;
scheduleNoteClear(id, { ...entry, ms: Math.max(0, OPENING_NOTE_MIN_MS - (performance.now() - entry.shownAt)) });
}
// A note that reports a FINISHED action: read it, then it goes.
function fadeRowNoteLater(id, ms) {
scheduleNoteClear(id, { kind: "fade", text: rowNotes.get(id)?.text, shownAt: performance.now(), ms });
}
async function runRowAction(id, btn, fn) {
if (!btn || btn.disabled) return;
btn.disabled = true;
showRowError(id, "");
try {
const res = await fn();
if (res && res.ok === false) {
showRowError(id, res.conflict ? "This Task already moved on. " + (res.error || "") : (res.error || "Could not complete that."));
} else {
window.relay.ack(id);
}
} catch (err) {
showRowError(id, (err && err.message) ? err.message : "Could not complete that.");
} finally {
btn.disabled = false;
}
}
function wireRelayRows() {
// stop clicks inside action zones from triggering the row open
for (const z of relaysListEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
wireAttachmentChips(relaysListEl);
for (const ta of relaysListEl.querySelectorAll(".qr-input")) {
ta.addEventListener("blur", () => {
// The blur may have cleared the only active quick reply — apply any inbox refresh that
// was deferred while the user was typing. Defer one tick so a focus moving to the Send
// button (which we never want to interrupt) settles first.
setTimeout(flushPendingRelaysRender, 0);
});
ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(84, ta.scrollHeight) + "px"; });
ta.addEventListener("mousedown", () => setInteractive(true));
}
for (const btn of relaysListEl.querySelectorAll("[data-delete-thread]")) {
const ids = (btn.getAttribute("data-delete-thread") || "").split(",").filter(Boolean);
btn.addEventListener("click", async (event) => {
event.stopPropagation();
if (btn.disabled || !ids.length) return;
btn.disabled = true;
try {
for (const id of ids) await window.relay.deleteRelay(id);
} catch {
btn.disabled = false;
}
});
}
for (const btn of relaysListEl.querySelectorAll("[data-delete]")) {
const id = btn.getAttribute("data-delete");
btn.addEventListener("click", async (event) => {
event.stopPropagation();
if (btn.disabled) return;
btn.disabled = true;
showRowError(id, "");
try {
const res = await window.relay.deleteRelay(id);
if (!res || res.ok === false) {
showRowError(id, (res && res.error) || "Could not move this relay to Recently Deleted.");
btn.disabled = false;
}
} catch (err) {
showRowError(id, (err && err.message) || "Could not move this relay to Recently Deleted.");
btn.disabled = false;
}
});
}
// accept/reject are hidden with the Tasks surface. Only connector_reauth keeps a
// [data-review] "Reconnect" affordance here.
for (const btn of relaysListEl.querySelectorAll("[data-review]")) {
const id = btn.getAttribute("data-review");
btn.addEventListener("click", () => {
const r = relayById(id); if (!r) return;
window.relay.open(id); // opens the web detail / connectors page + acks
});
}
// Preview opens an independent, focusable render window. Main marks the
// relay read only after that renderer confirms a successful body paint.
for (const btn of relaysListEl.querySelectorAll("[data-preview]")) {
const id = btn.getAttribute("data-preview");
btn.addEventListener("click", () => {
// The pill IS the surface. Preview used to open preview.html — the
// PRE-REDESIGN window — so a tap here dropped the reader back into the
// old design mid-session. It opens the reader in place now.
expandedRelayId = null;
openReader(id, "relays");
});
}
// Host-open actions mark the relay read exactly like a normal open (main
// acks on completion) and ride the existing opening spinner.
for (const btn of relaysListEl.querySelectorAll("[data-open-current]")) {
const id = btn.getAttribute("data-open-current");
btn.addEventListener("click", () => {
expandedRelayId = null;
openRelayFromUI(id, "relay", "current");
renderRelays();
});
}
for (const btn of relaysListEl.querySelectorAll("[data-open-fresh]")) {
const id = btn.getAttribute("data-open-fresh");
btn.addEventListener("click", () => {
expandedRelayId = null;
openRelayFromUI(id, "relay", "fresh");
renderRelays();
});
}
for (const btn of relaysListEl.querySelectorAll("[data-task-start]")) {
const id = btn.getAttribute("data-task-start");
btn.addEventListener("click", () => {
expandedRelayId = null;
// Start carries the ROUTE: the app/model/effort the rail (or Settings)
// named for this Task — never a hardcoded runtime.
const rt = routeFor(id);
runRowAction(id, btn, () => window.relay.taskStart(id, {
host: hostKeyFor(rt.app), model: modelIdFor(rt.app, rt.model), effort: String(rt.effort || "high").toLowerCase(),
}));
renderRelays();
});
}
for (const el of relaysListEl.querySelectorAll(".row")) {
const id = el.getAttribute("data-id");
if (openingIds.has(id)) el.classList.add("opening");
// Clicking a relay row EXPANDS its open-actions card in place (a second
// click collapses). The actual open — and the read/ack — moved onto the
// card's two buttons, so expansion alone never marks anything read.
el.addEventListener("click", () => {
expandedRelayId = expandedRelayId === id ? null : id;
renderRelays();
});
}
}
// ---------- Sent view ----------
function sentRecipient(r) {
const rec = r && r.recipient ? r.recipient : {};
return personName(rec.name, rec.email) || "Recipient";
}
function sentSubject(r) {
const title = String((r && (r.title || r.displayTitle)) || "").trim();
return title || firstLine((r && r.forHuman) || "", 90) || "Relay";
}
// Product species, never a title heuristic: a Relay has both a human and an
// agent document; human-only correspondence is a text message. The title is
// still present on the wire for notifications/email and may be shortened, so
// comparing it with the body made long texts render as duplicate Relay cards.
function relayTextLike(body, _subject, forAgent) {
return Boolean(String(body || "").trim()) && !String(forAgent || "").trim();
}
// The relay's own lifecycle state ("read"/"acknowledged" = the recipient read it in-app)
// outranks the delivery channel state, which never advances past "sent"/"opened".
function sentIsRead(r) {
const state = String((r && r.state) || "").toLowerCase();
return state === "read" || state === "acknowledged";
}
// The rung below Read. The server's relay state is a ladder — pending (the
// API has it) → delivered (it has been routed to the recipient's device, or
// to the email that stands in for one) → read — and "delivered" is the fact
// a sender wants when the recipient has not opened it yet.
function sentIsDelivered(r) {
return String((r && r.state) || "").toLowerCase() === "delivered" || sentIsRead(r);
}
function sentDeliveryLabel(r) {
// A link's ladder is its own. SCOPED to channel === "link" so every other
// channel keeps its exact current precedence: the onRelay shortcut must
// still outrank `opened`, because an on-Relay recipient with NO paired
// device gets an EMAIL delivery that markDeliveryOpened stamps `opened`
// when they click the emailed link, and those rows have read "Sent" since
// the receipt ladder shipped.
if (String(((r && r.delivery) || {}).channel || "").toLowerCase() === "link") {
if (sentIsRead(r)) return "Read";
return String(((r && r.delivery) || {}).state || "").toLowerCase() === "opened" ? "Opened" : "Sent";
}
const d = (r && r.delivery) || {};
const state = String(d.state || r.state || "").toLowerCase();
const channel = String(d.channel || "").toLowerCase();
if (sentIsRead(r)) return "Read";
if (r && r.recipient && r.recipient.onRelay) return "Sent";
if (state === "opened") return "Opened";
if (state === "failed") return "Failed";
if (state === "pending") return "Pending";
if (channel === "email") return "Emailed";
return "Sent";
}
// A sent task's receipt is a LADDER, not a pair: Sent → Seen → Started →
// Done. Same word-with-a-clock recipe as Read receipts; Started keeps the
// accent, Done is the one green word in the tab. Returns null for non-tasks
// so the ordinary Sent/Read path stays untouched.
function taskReceipt(r) {
if (!r || r.kind !== "task") return null;
if (r.taskCompletedAt) return { label: "Done", at: r.taskCompletedAt, cls: "done" };
if (r.taskStartedAt) return { label: "Started", at: r.taskStartedAt, cls: "read" };
if (sentIsRead(r)) return { label: "Seen", at: r.readAt || r.createdAt, cls: "read" };
const base = sentDeliveryLabel(r);
return { label: base === "Read" ? "Seen" : base, at: null, cls: "" };
}
// The state word carries the only clock; the note holds who and what.
function sentDetail(r) {
const parts = [];
const rec = (r && r.recipient) || {};
const link = (r && r.shareLink) || null;
// A guest mailbox is an internal key, never an address to show. Suppress it
// even when the API still sends it, so a stale server cannot leak it here.
if (rec.email && !/@guests\.sendrelays\.com$/i.test(String(rec.email))) parts.push(rec.email);
// After a claim this is an ordinary relay and its provenance is history, not
// status: the real address is the note, exactly as for any other row.
if (link && link.state === "revoked") parts.push("link revoked");
else if (link && link.state !== "claimed") parts.push("shared by link");
const atts = Array.isArray(r && r.attachments) ? r.attachments : [];
if (atts.length === 1) {
const a = atts[0];
const size = fmtBytes(a.bytes);
parts.push(`${a.name || "attachment"}${size ? " · " + size : ""}`);
} else if (atts.length > 1) {
parts.push(`${atts.length} attachments`);
}
return parts.join(" · ");
}
function sentClass(r) {
if (r && r.recipient && r.recipient.onRelay) return "";
const state = String(((r && r.delivery) || {}).state || "").toLowerCase();
if (state === "failed") return "failed";
if (state === "pending") return "pending";
return "";
}
// One logical group send = ONE row. A fan-out writes one sibling relay per
// member (shared groupSendId), so without this a reply into a two-person
// chat showed as two duplicated sends. Same collapse as the web Sent page.
function sentListRows(rows) {
const out = [];
const byGroupSend = new Map();
for (const r of rows) {
const gid = r && r.groupSendId;
if (!gid) { out.push([r]); continue; }
const members = byGroupSend.get(gid);
if (members) { members.push(r); continue; }
const fresh = [r];
byGroupSend.set(gid, fresh);
out.push(fresh);
}
return out;
}
// The row's "who": the roster name while the group still exists, otherwise
// the members themselves — never just the first sibling's recipient.
function sentGroupLabel(members) {
const named = members.find((m) => m && m.recipientGroupName);
if (named) return named.recipientGroupName;
return members.map(sentRecipient).join(", ");
}
// The group row's note: small rosters get per-member receipts ("Sven read ·
// Shane sent"), big ones a count — either way who still hasn't seen it.
function sentGroupDetail(members, readCount) {
if (members.length > 3) return `${members.length} people · ${readCount} read`;
return members
.map((m) => `${sentRecipient(m)} ${sentIsRead(m) ? "read" : sentDeliveryLabel(m).toLowerCase()}`)
.join(" · ");
}
function renderSent() {
const rows = payload.sent || [];
sentEmptyEl.classList.toggle("gone", rows.length > 0);
if (!rows.length) { sentListEl.innerHTML = ""; return; }
sentListEl.innerHTML = sentListRows(rows).map((members) => {
const r = members[0];
const id = r.relayId || r.id || "";
const subject = sentSubject(r);
const body = r.forHuman || "";
const preview = bodyPreview(body, subject, 118);
const isGroup = members.length > 1;
const readCount = members.filter(sentIsRead).length;
const detail = isGroup ? sentGroupDetail(members, readCount) : sentDetail(r);
const klass = isGroup ? "" : sentClass(r);
// The receipt is a WORD in the meta slot — "Sent 3h" until the recipient
// reads it, then "Read 3h" in accent. A read row settles to muted, so this
// tab reads as a chase list of what still hasn't been seen.
const read = isGroup ? readCount === members.length : sentIsRead(r);
const rec = isGroup ? sentGroupLabel(members) : sentRecipient(r);
// One clock per row, belonging to the state word: "Sent 5h" = sent five
// hours ago; "Read 2h" = read two hours ago.
const dd = r.delivery || {};
// Opened is not Read: it keeps the accent so the row still reads as moved,
// and stays unsettled so the sender's chase list still holds it.
const linkOpened = !isGroup
&& String(dd.channel || "").toLowerCase() === "link"
&& String(dd.state || "").toLowerCase() === "opened"
&& !sentIsRead(r);
// A fully read group send is read when its LAST member read it.
const stAt = read
? (isGroup
? members.map((m) => m.readAt || (m.delivery || {}).openedAt || m.createdAt).sort().pop()
: (r.readAt || dd.openedAt || r.createdAt))
: linkOpened
? (dd.openedAt || dd.sentAt || r.createdAt)
: (dd.sentAt || r.createdAt);
// Tasks never fan out (single-recipient by design), so the task receipt
// only ever decorates a single row.
const receipt = isGroup ? null : taskReceipt(r);
// A partially read group send reads "Read 1/2" — the fraction is the
// receipt, so the clock stays on the send.
const stLabel = receipt
? receipt.label
: isGroup && readCount && !read
? `Read ${readCount}/${members.length}`
: sentDeliveryLabel(r);
const stTime = receipt && receipt.at ? receipt.at : stAt;
const stCls = receipt
? (receipt.cls ? ` ${receipt.cls}` : "")
: `${read || linkOpened ? " read" : ""}${klass === "failed" ? " failed" : ""}`;
// A Done task settles like a read message; Started keeps it prominent.
const settled = receipt ? Boolean(receipt.cls === "done") : read;
const expanded = id === expandedSentId;
const shareCopyUrl = !isGroup && r.shareLink && r.shareLink.state !== "revoked" ? String(r.shareLink.url || "") : "";
const openCard = expanded ? openActionsHtml(id, { sent: true, shareLinkUrl: shareCopyUrl }) : "";
return `
<div class="row sent ${klass}${settled ? " settled" : ""}${expanded ? " expanded" : ""}" data-sent-id="${esc(id)}" data-opening-id="${esc(id)}">
<span class="avatar" style="--av-h:${avatarHue(rec)}" aria-hidden="true">${esc(avatarInitials(rec))}</span>
<div class="rk-body">
<div class="rk-top">
<span class="rk-sender">${esc(rec)}</span>
${receipt ? `<span class="kchip">Task</span>` : ""}
<span class="st${stCls}">${esc(stLabel)} ${timeAgo(stTime)}</span>
</div>
<div class="rk-subject">${esc(subject)}</div>
${preview ? `<div class="rk-preview">${esc(preview)}</div>` : ""}
<div class="row-note">${esc(detail)}</div>
${openCard}
<div class="row-err${rowNotes.get(id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(id)}">${esc(rowNotes.get(id)?.text || "")}</div>
</div>
</div>`;
}).join("");
wireSentRows();
}
// A sent row expands into the SAME three-action menu as a received relay:
// Preview (read your own message in place), Open in Current Chat, Open in
// New Chat — each routed to the sent-side IPC.
function wireSentRows() {
for (const el of sentListEl.querySelectorAll(".row.sent")) {
const id = el.getAttribute("data-sent-id");
if (openingIds.has(id)) el.classList.add("opening");
el.addEventListener("click", () => {
expandedSentId = expandedSentId === id ? null : id;
renderSent();
});
}
for (const z of sentListEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
for (const btn of sentListEl.querySelectorAll("[data-sent-preview]")) {
btn.addEventListener("click", () => {
expandedSentId = null;
openReader(btn.getAttribute("data-sent-preview"), "sent");
});
}
for (const btn of sentListEl.querySelectorAll("[data-sent-open-current]")) {
const id = btn.getAttribute("data-sent-open-current");
btn.addEventListener("click", () => {
expandedSentId = null;
openRelayFromUI(id, "sent", "current");
renderSent(); // the menu must close on screen, not just in state
});
}
for (const btn of sentListEl.querySelectorAll("[data-sent-open-fresh]")) {
const id = btn.getAttribute("data-sent-open-fresh");
btn.addEventListener("click", () => {
expandedSentId = null;
openRelayFromUI(id, "sent", "fresh");
renderSent(); // the menu must close on screen, not just in state
});
}
for (const btn of sentListEl.querySelectorAll("[data-sent-copy-link]")) {
const id = btn.getAttribute("data-sent-copy-link");
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(btn.getAttribute("data-share-url") || "");
setRowNote(id, "Link copied", "ok");
} catch {
setRowNote(id, "Copy the link from the message page instead.", "err");
}
});
}
}
// ---------- Tasks view ----------
const REQUEST_GROUPS = [
{ key: "needs", label: "Needs you", match: (t) => t.requiresViewerAction },
{ key: "active", label: "Active", match: (t) => t.state === "active" || t.state === "completing" },
{ key: "waiting", label: "Waiting", match: (t) => t.state === "draft" || t.state === "resolving" || t.state === "inviting" },
{ key: "completed", label: "Completed", match: (t) => t.state === "completed" },
{ key: "stopped", label: "Stopped or failed", match: (t) => t.state === "failed" || t.state === "cancelled" },
];
function renderRequests() {
const requests = payload.requests || [];
tasksEmptyEl.classList.toggle("gone", requests.length > 0);
if (!requests.length) { tasksListEl.innerHTML = ""; return; }
const assigned = new Set();
let html = "";
for (const g of REQUEST_GROUPS) {
const inGroup = requests.filter((t) => !assigned.has(t.id) && g.match(t));
for (const t of inGroup) assigned.add(t.id);
if (!inGroup.length) continue;
inGroup.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
html += `<div class="group-head">${esc(g.label)} <span class="ghc">${inGroup.length}</span></div>`;
html += inGroup.map(requestRowHtml).join("");
}
tasksListEl.innerHTML = html;
wireRequestRows();
}
async function runRequestAction(el, fn) {
if (!el || el.dataset.busy === "1") return;
el.dataset.busy = "1";
const prev = el.textContent;
el.style.opacity = ".5";
try {
await fn();
await window.relay.refreshRequests().catch(() => {});
} catch (err) {
el.textContent = prev;
} finally {
el.dataset.busy = "";
el.style.opacity = "";
}
}
// ---------- in-pill request detail (live) ----------
// Clicking a request row opens a live detail panel INSIDE the pill (the same data the
// web request page renders), polled every 5s while open. "Open in Claude/Codex" keeps
// the old materialize-into-host behavior as an explicit action.
let requestDetailId = null;
let requestDetailData = null;
let requestDetailError = "";
let requestDetailTimer = null;
function showRequestDetail(requestId) {
requestDetailId = requestId;
requestDetailData = (payload.requests || []).find((t) => t.id === requestId) || null; // summary while loading
requestDetailError = "";
activeView = "requestDetail";
commitNavigation({ outerScrollTop: 0 });
refreshRequestDetail();
if (requestDetailTimer) clearInterval(requestDetailTimer);
requestDetailTimer = setInterval(() => {
if (activeView === "requestDetail" && !collapsed) refreshRequestDetail();
}, 5000);
}
function closeRequestDetail() {
if (requestDetailTimer) { clearInterval(requestDetailTimer); requestDetailTimer = null; }
requestDetailId = null;
requestDetailData = null;
activeView = "sent";
commitNavigation({ outerScrollTop: 0 });
}
async function refreshRequestDetail() {
if (!requestDetailId) return;
const id = requestDetailId;
try {
const res = await window.relay.requestStatus(id);
if (id !== requestDetailId) return; // navigated away while fetching
if (res && res.ok && res.request) {
requestDetailData = res.request;
requestDetailError = "";
} else {
requestDetailError = (res && res.error) || "Could not load the Task.";
}
} catch (err) {
if (id !== requestDetailId) return;
requestDetailError = (err && err.message) || "Could not load the Task.";
}
if (activeView === "requestDetail") renderRequestDetail();
}
function tdChip(state) {
const cls = state === "active" || state === "completing" ? "s-active" : "";
return `<span class="td-state ${cls}">${esc(requestStateLabel(state))}</span>`;
}
// ---- request-detail helpers ----
// Light multi-line markdown flattener: strips heading hashes, blockquote marks,
// list bullets, and emphasis ticks per line, keeps line breaks (rendered via
// white-space:pre-line + -webkit-line-clamp), and truncates for the clamp.
function plainMd(s, max) {
const cap = max || 600;
const txt = String(s || "").replace(/\r/g, "").split("\n").map((l) =>
l.replace(/^\s{0,3}#{1,6}\s+/, "")
.replace(/^\s{0,3}>\s?/, "")
.replace(/^\s{0,3}[-*+]\s+/, "")
.replace(/^\s{0,3}\d+\.\s+/, "")
.replace(/[*_`]+/g, "")
).join("\n").replace(/\n{3,}/g, "\n\n").trim();
return txt.length > cap ? txt.slice(0, cap - 1).trimEnd() + "…" : txt;
}
// "David Kahuha’s agent" — recipient names, destinationKind-aware
function tdRecipients(a) {
const rs = Array.isArray(a.recipientIdentities) ? a.recipientIdentities : [];
const names = rs.map((r) => r && (r.displayName || r.email)).filter(Boolean);
if (!names.length) return a.destinationKind === "agent" ? "an agent" : "";
const who = names.join(", ");
return a.destinationKind === "agent" ? who + (names.length > 1 ? "’s agents" : "’s agent") : who;
}
function tdExpires(iso) {
const t = Date.parse(iso || "");
if (!t) return "";
const s = (t - Date.now()) / 1000;
if (s <= 0) return "expired";
if (s < 3600) return "expires in " + Math.max(1, Math.floor(s / 60)) + "m";
if (s < 86400) return "expires in " + Math.floor(s / 3600) + "h";
return "expires in " + Math.floor(s / 86400) + "d";
}
function tdApprovalCard(a) {
const body = plainMd(a.payload && a.payload.forHuman, 300);
const to = tdRecipients(a);
const exp = tdExpires(a.expiresAt);
return `
<div class="td-card">
<div class="td-kicker">Needs your approval</div>
<div class="td-card-summary">${esc(a.summary || "Share outbound content")}</div>
${to ? `<div class="td-card-to">To ${esc(to)}</div>` : ""}
${body ? `<div class="td-card-quote">${esc(body)}</div>` : ""}
${attachmentChips(a.attachments)}
<div class="td-card-actions" data-stop="1">
<button class="td-btn primary" type="button" data-td-approve="${esc(a.id)}">Approve</button>
<button class="td-btn ghost" type="button" data-td-decline="${esc(a.id)}">Decline</button>
${exp ? `<span class="td-expires">${esc(exp)}</span>` : ""}
</div>
</div>`;
}
function tdQuestionCard(m, requestId) {
const q = plainMd((m.humanResponse && m.humanResponse.question) || m.forHuman || "", 300) || "Your agent has a question.";
return `
<div class="td-card">
<div class="td-kicker">Question for you</div>
<div class="td-card-summary">${esc(q)}</div>
${m.senderLabel ? `<div class="td-card-to">From ${esc(m.senderLabel)}</div>` : ""}
${attachmentChips(m.attachments)}
<div class="td-card-actions" data-stop="1">
<button class="td-btn primary" type="button" data-td-open="${esc(requestId)}">Answer in Claude or Codex</button>
</div>
</div>`;
}
// one thread entry; human-typed messages carry the accent rule, system lines go quiet
function tdMessage(m) {
const kind = String(m.kind || "");
if (kind === "system_event" || kind === "result_notice") {
const line = firstLine(m.forHuman || "", 90);
return line ? `<div class="td-sys">${esc(line)}</div>` : "";
}
const human = kind === "human_message";
const body = plainMd(m.forHuman || (m.humanResponse && m.humanResponse.question) || "", 700);
let html = `
<div class="td-msg${human ? " human" : ""}">
<div class="td-msg-top">
<span class="td-msg-sender">${esc(m.senderLabel || (human ? "Someone" : "Agent"))}</span>
<span class="td-msg-time">${esc(timeAgo(m.createdAt))}</span>
</div>
${body ? `<div class="td-msg-body">${esc(body)}</div>` : ""}
${attachmentChips(m.attachments)}
</div>`;
if (kind === "relay_to_human" && m.humanResponse && m.humanResponse.status === "answered" && m.humanResponse.answerMarkdown) {
html += `
<div class="td-msg human">
<div class="td-msg-top"><span class="td-msg-sender">You</span></div>
<div class="td-msg-body">${esc(plainMd(m.humanResponse.answerMarkdown, 400))}</div>
</div>`;
}
return html;
}
function renderRequestDetail() {
if (!requestDetailViewEl) return;
const t = requestDetailData;
if (!t) {
requestDetailViewEl.innerHTML = `
<div class="td-head"><button class="td-back" type="button" data-td-back="1">‹ Sent</button></div>
<div class="td-quiet">${requestDetailError ? esc(requestDetailError) : "Loading Task…"}</div>`;
wireRequestDetail();
return;
}
const allMessages = Array.isArray(t.recentMessages) ? t.recentMessages.filter(Boolean) : [];
const done = t.state === "completed" || t.state === "cancelled" || t.state === "failed";
// every unanswered blocking question, scanned across ALL fetched messages
// (not just the visible window); suppressed once the Task is closed
const pendingQs = done ? [] : allMessages.filter((m) =>
m.kind === "relay_to_human" && m.humanResponse && m.humanResponse.status === "pending");
const messages = allMessages.filter((m) => pendingQs.indexOf(m) === -1).slice(-5);
const results = Array.isArray(t.results) ? t.results : [];
const approvals = Array.isArray(t.pendingApprovals) ? t.pendingApprovals : [];
const objective = String(t.objective || "").trim();
let html = `
<div class="td-head">
<button class="td-back" type="button" data-td-back="1">‹ Sent</button>
<span class="td-when">${esc(timeAgo(t.updatedAt))}</span>
</div>
<div class="td-title">${esc(t.title || "Untitled Task")}</div>
<div class="td-meta">${tdChip(t.state)}<span class="sep">·</span><span>${esc(participantSummary(t))}</span></div>`;
if (objective) html += `<div class="td-obj">${esc(firstLine(objective, 140))}</div>`;
if (requestDetailError) html += `<div class="td-err">${esc(requestDetailError)}</div>`;
// what needs YOU — pinned above everything else
for (const a of approvals) html += tdApprovalCard(a);
for (const q of pendingQs) html += tdQuestionCard(q, t.id);
if (results.length) {
html += `<div class="td-label">${done ? "Outcome" : "Results"}</div>`;
for (const r of results) {
html += `
<div class="td-result">
<div class="td-result-title">${esc(r.title || "Result")}</div>
<div class="td-result-body">${esc(plainMd(r.forHuman, 400))}</div>
</div>`;
}
}
html += `<div class="td-label">Conversation</div>`;
if (messages.length) {
for (const m of messages) html += tdMessage(m);
} else {
html += `<div class="td-quiet">No messages yet.</div>`;
}
const people = (Array.isArray(t.participants) ? t.participants : []).map((p) => {
const name = p && (p.displayName || p.email);
if (!name) return "";
const st = String(p.invitationState || "");
return st && st !== "accepted" ? name + " (" + st.replaceAll("_", " ") + ")" : name;
}).filter(Boolean);
if (people.length) html += `<div class="td-people">With ${esc(people.join(", "))}</div>`;
html += `
<div class="td-foot" data-stop="1">
<button class="td-open" type="button" data-td-open="${esc(t.id)}">Open in Claude or Codex</button>
</div>`;
requestDetailViewEl.innerHTML = html;
wireRequestDetail();
}
async function runTdMutation(btn, fn) {
if (!btn || btn.disabled) return;
btn.disabled = true;
try {
const res = await fn();
if (res && res.ok === false) requestDetailError = res.error || "Could not complete that.";
} catch (err) {
requestDetailError = (err && err.message) || "Could not complete that.";
} finally {
btn.disabled = false;
refreshRequestDetail();
}
}
function wireRequestDetail() {
for (const btn of requestDetailViewEl.querySelectorAll("[data-td-back]")) {
btn.addEventListener("click", closeRequestDetail);
}
for (const btn of requestDetailViewEl.querySelectorAll("[data-td-open]")) {
btn.addEventListener("click", () => window.relay.openTask(btn.getAttribute("data-td-open")));
}
for (const btn of requestDetailViewEl.querySelectorAll("[data-td-approve]")) {
btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.approve(requestDetailId, btn.getAttribute("data-td-approve"))));
}
for (const btn of requestDetailViewEl.querySelectorAll("[data-td-decline]")) {
btn.addEventListener("click", () => runTdMutation(btn, () => window.relay.decline(requestDetailId, btn.getAttribute("data-td-decline"))));
}
}
function wireRequestRows() {
// action zones inside a row must not also open the Task web detail
for (const z of tasksListEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
for (const btn of tasksListEl.querySelectorAll("[data-request-accept]")) {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const requestId = btn.getAttribute("data-request-accept");
const pid = btn.getAttribute("data-pid");
runRequestAction(btn, () => window.relay.accept(requestId, pid));
});
}
for (const btn of tasksListEl.querySelectorAll("[data-request-decline]")) {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const requestId = btn.getAttribute("data-request-decline");
const pid = btn.getAttribute("data-pid");
runRequestAction(btn, () => window.relay.reject(requestId, pid));
});
}
// "N waiting to approve" -> the in-pill detail, where the approval buttons live
for (const el of tasksListEl.querySelectorAll("[data-approve]")) {
el.addEventListener("click", (e) => {
e.stopPropagation();
showRequestDetail(el.getAttribute("data-approve"));
});
}
// Row click opens the live in-pill detail; opening in Claude/Codex is an
// explicit action inside it.
for (const el of tasksListEl.querySelectorAll(".request")) {
el.addEventListener("click", () => showRequestDetail(el.getAttribute("data-request")));
}
}
function requestRowHtml(t) {
const obj = String(t.objective || "").trim();
const approvals = Number(t.pendingApprovalCount || 0);
const pending = viewerPendingParticipant(t);
// VIEWER-RELATIVE state label. RequestSummary.requiresViewerAction marks rows that need the
// viewer; when there is also an outstanding invitation participant (viewerPendingParticipant,
// invitationState created/delivered per protocol.ts InvitationState) the label must reflect
// the VIEWER's action, not the global request.state ("Awaiting replies"). The dot/class follow
// so a needs-you row visibly reads as needing the viewer (prussian).
let stateLabel = requestStateLabel(t.state);
let stateCls = requestStateClass(t.state);
if (t.requiresViewerAction) {
stateCls = "s-active";
if (pending) {
// Outstanding invitation -> the viewer is being invited to join.
stateLabel = "Invited you";
} else {
// requiresViewerAction without a pending invite means another viewer action is
// outstanding (e.g. a pending approval or a question awaiting their reply).
stateLabel = "Your reply needed";
}
}
// meta line: role · participant summary · (waiting to approve)
const meta = [];
meta.push(`<span>${esc(requestRoleLabel(t.viewerRole))}</span>`);
meta.push(`<span class="sep">·</span>`);
meta.push(`<span>${esc(participantSummary(t))}</span>`);
if (approvals > 0) {
meta.push(`<span class="sep">·</span>`);
meta.push(`<span class="request-approve" data-approve="${esc(t.id)}">${approvals} waiting to approve</span>`);
}
// outstanding invitation -> inline Accept / Decline (flat text, prussian/muted)
const actions = pending
? `<div class="rk-actions" data-stop="1">
<button class="act-btn accept" type="button" data-request-accept="${esc(t.id)}" data-pid="${esc(pending.id)}">Accept</button>
<button class="act-btn decline" type="button" data-request-decline="${esc(t.id)}" data-pid="${esc(pending.id)}">Decline</button>
</div>`
: "";
return `
<div class="request" role="button" tabindex="0" data-request="${esc(t.id)}">
<div class="request-top">
<span class="t-dot ${stateCls}"></span>
<span class="t-state ${stateCls}">${esc(stateLabel)}</span>
<span class="request-time">${timeAgo(t.updatedAt)}</span>
</div>
<div class="request-title">${esc(t.title || "Untitled Task")}</div>
${obj ? `<div class="request-obj">${esc(obj)}</div>` : ""}
<div class="request-meta">${meta.join("")}</div>
${actions}
</div>`;
}
// ---------- Contacts view ----------
function cvInitials(name, email) {
const base = String(name || email || "?").trim();
const parts = base.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return base.slice(0, 2).toUpperCase();
}
function cvHue(value) {
let hash = 0;
for (const ch of String(value || "?")) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
return Math.abs(hash) % 360;
}
// A contact carries emails[] (primary = emails[0]); older rows may only have a single .email.
function contactEmails(c) {
if (Array.isArray(c.emails) && c.emails.length) return c.emails.filter(Boolean);
const e = String(c.email || "").trim();
return e ? [e] : [];
}
function contactKey(c) { return c.id || (contactEmails(c)[0] || "") || c.name; }
function renderContacts() {
contactsEmptyEl.classList.toggle("gone", contactsPane !== "people" || contactsList.length > 0);
refreshPaneCounts();
cvListEl.innerHTML = contactsList.map((c) => {
const emails = contactEmails(c);
const primary = emails[0] || "";
const more = emails.length > 1 ? `+${emails.length - 1} more` : "";
const via = primary && !c.onRelay ? "· by email" : "";
const recent = c.updatedAt ? timeAgo(c.updatedAt) : "";
return `
<button class="cv-item" type="button" data-contact="${esc(contactKey(c))}">
<span class="cv-avatar" style="--cv-h:${cvHue(c.name || primary)}">${esc(cvInitials(c.name, primary))}</span>
<span class="cv-body">
<span class="cv-name">${esc(c.name || primary)}</span>
<span class="cv-subrow">
<span class="cv-sub">${esc(primary || "no email")}</span>
${more ? `<span class="cv-more">${esc(more)}</span>` : ""}
${via ? `<span class="cv-via">${esc(via)}</span>` : ""}
</span>
</span>
<span class="cv-edit" role="button" tabindex="0" data-contact-edit="${esc(contactKey(c))}">Edit</span>
${recent ? `<span class="cv-meta">${esc(recent)}</span>` : ""}
</button>`;
}).join("");
for (const el of cvListEl.querySelectorAll(".cv-item")) {
el.addEventListener("click", () => {
// A row is the PERSON, so tapping it does what you came to a person
// for: it opens the conversation with them. Correcting their name and
// their addresses is the rarer errand, and it has its own button.
openContactRoom(el.getAttribute("data-contact"));
});
}
for (const edit of cvListEl.querySelectorAll("[data-contact-edit]")) {
const open = (event) => {
event.stopPropagation(); // the card, not the room
openContactForm(edit.getAttribute("data-contact-edit"));
};
edit.addEventListener("click", open);
// Now that the row and the control do DIFFERENT things, the card has to
// be reachable without a mouse. The row is a <button> and answers Enter
// itself; this gives its control the same manners.
edit.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
open(event);
});
}
}
/**
* Open the conversation a contact row names.
*
* A room already in the pill's own transcript opens IN the pill, so the
* frame the click happened in is the frame that answers. People stays the
* lit tab throughout: the reader is the same room from a different index,
* not a trip through Relays (David).
*
* A person nobody has written to yet has no local transcript to open. Resolve
* the canonical chat, register it in the same in-pill conversation model,
* and open it here with the composer ready.
*/
function openContactRoom(key) {
const openGeneration = ++cvOpenGeneration;
cvOpeningKey = null;
const contact = key ? contactsList.find((item) => contactKey(item) === key) : null;
if (!contact) return;
cvPeopleErrorEl.textContent = "";
// A direct room's identity is `email:<address>`; older rows carry only the
// displayed name. Match the address first — a contact renamed in the book
// is still the same person on the wire.
const emails = new Set(contactEmails(contact).map((email) => `email:${String(email).trim().toLowerCase()}`));
const names = new Set([contact.name, ...contactEmails(contact)]
.filter(Boolean).map((value) => String(value).trim().toLowerCase()));
const existing = chatSections().people.find((room) => {
if (emails.has(String(room.partyKey || "").toLowerCase())) return true;
if (stablePartyKey(room.partyKey)) return false; // a different, known address
return [room.name, room.party].filter(Boolean)
.some((value) => names.has(String(value).trim().toLowerCase()));
});
if (existing) {
const morphFromList = prepareReaderMorph(activeView);
openThreadDetail(existing.threadId, existing.name || contact.name, "contacts", { expanded: true });
if (morphFromList) startReaderMorph("threads");
return;
}
openContactChat(key, openGeneration);
}
/**
* Resolve a contact conversation that the local inbox/Sent projection has
* not produced yet. The existing bridge performs the server lookup; its
* result is registered as an in-pill anchor rather than opening Preview.
*
* A refusal is written on the LIST, because the list is what was clicked —
* the card that used to carry it is not open on this path.
*/
function openContactChat(key, openGeneration = ++cvOpenGeneration) {
const c = key ? contactsList.find((x) => contactKey(x) === key) : null;
if (!window.relay.openChatWith) { // older main process
cvPeopleErrorEl.textContent = "That conversation could not be opened.";
return;
}
const email = c ? (contactEmails(c)[0] || "") : "";
// A contact with no address is a name and nothing else — there is nowhere
// to send to. Say so; a click that does nothing reads as a broken row.
if (!email) {
cvPeopleErrorEl.textContent = `${c && c.name ? c.name : "That contact"} has no address to write to. Add one with Edit.`;
return;
}
// A failure belongs to the row it came from: by the time a slow one
// answers, the person may have opened somebody else's.
const stillCurrent = () => cvOpenGeneration === openGeneration && cvOpeningKey === key && activeView === "contacts";
const onThisRow = (message) => { if (stillCurrent()) cvPeopleErrorEl.textContent = message; };
cvOpeningKey = key;
Promise.resolve(window.relay.openChatWith(email, c.name || ""))
.then((res) => {
if (!res || res.ok === false || !res.chat || !res.chat.chatId) {
onThisRow((res && res.error) || "That conversation could not be opened.");
return;
}
if (!stillCurrent()) return;
storeCanonicalChatDetail(res.chat, { surface:"relay" });
const anchor = directContactRoomAnchor(res.chat, c, email, res.recipient);
contactChatAnchors.set(anchor.chatId, anchor);
const resolved = chatSections().people.find((room) =>
room.partyKey === anchor.partyKey || room.chatId === anchor.chatId);
if (!resolved) {
onThisRow("That conversation could not be opened.");
return;
}
const morphFromList = prepareReaderMorph(activeView);
openThreadDetail(resolved.threadId, resolved.name || c.name, "contacts", { expanded:true, hydrated:true });
if (morphFromList) startReaderMorph("threads");
})
.catch(() => onThisRow("That conversation could not be opened."));
}
async function loadContacts() {
try { contactsList = (await window.relay.contacts()) || []; }
catch { contactsList = []; }
renderContacts();
loadGroups();
}
// ---- Contact groups (rosters) ----
// Server-truth only: every mutation returns the updated group view and the
// section re-renders from it, so nothing here can drift from the API.
const cvgNewEl = document.getElementById("cvgNew");
const cvgNewFormEl = document.getElementById("cvgNewForm");
const cvgNewNameEl = document.getElementById("cvgNewName");
const cvgNewCancelEl = document.getElementById("cvgNewCancel");
// ---- contacts / groups pane (the segmented island in the head) ----
const cvSegPeopleEl = document.getElementById("cvSegPeople");
const cvSegGroupsEl = document.getElementById("cvSegGroups");
const cvSegPeopleNEl = document.getElementById("cvSegPeopleN");
const cvSegGroupsNEl = document.getElementById("cvSegGroupsN");
const cvGroupsEl = document.getElementById("cvGroups");
let contactsPane = "people";
function applyContactsPane() {
const people = contactsPane === "people";
cvSegPeopleEl.classList.toggle("active", people);
cvSegGroupsEl.classList.toggle("active", !people);
cvSegPeopleEl.setAttribute("aria-selected", String(people));
cvSegGroupsEl.setAttribute("aria-selected", String(!people));
cvListEl.classList.toggle("gone", !people);
contactsEmptyEl.classList.toggle("gone", !people || contactsList.length > 0);
cvGroupsEl.classList.toggle("gone", people);
// The add button follows the pane it belongs to.
cvAddEl.classList.toggle("gone", !people);
cvgNewEl.classList.toggle("gone", people);
if (people) cvgNewFormEl.classList.add("hidden");
else cvFormEl.classList.add("hidden");
// Neither pane inherits the other's refusal, and neither keeps one across
// a return to the tab — an error names a click, and the click is over.
cvPeopleErrorEl.textContent = "";
cvOpeningKey = null;
cvgShowError("");
}
function refreshPaneCounts() {
cvSegPeopleNEl.textContent = contactsList.length ? String(contactsList.length) : "";
cvSegGroupsNEl.textContent = groupsList.length ? String(groupsList.length) : "";
}
cvSegPeopleEl.addEventListener("click", () => { contactsPane = "people"; applyContactsPane(); });
cvSegGroupsEl.addEventListener("click", () => { contactsPane = "groups"; applyContactsPane(); loadGroups(); });
const cvgListEl = document.getElementById("cvgList");
const cvgEmptyEl = document.getElementById("cvgEmpty");
const cvgErrorEl = document.getElementById("cvgError");
const cvgDetailEl = document.getElementById("cvgDetail");
let groupsList = [];
let groupsLoaded = false;
let groupsLoading = null;
let cvgExpandedId = null; // group whose full details screen is open
let cvgRenamingId = null;
let cvgDeleteArmedId = null;
let groupLeaveArmedId = null;
let cvgSearchTimer = null;
const leftGroupIds = new Set();
function syncSlackChannelRows() {
const chats = (payload.chats || []).filter((chat) => chat?.chatId && chat.channel?.slack);
const slackIds = new Set(chats.map((chat) => String(chat.chatId)));
// Preserve native Relay channels and replace the Slack-owned projection on
// every render. Slack owns the name, archive state, and membership; this
// pane is a navigation surface, not a second roster editor.
groupsList = groupsList.filter((group) => group?.provider !== "slack" || slackIds.has(String(group.id)));
for (const chat of chats) {
const id = String(chat.chatId);
const row = {
id,
chatId:id,
name:chat.channel.name || chat.title || "Channel",
provider:"slack",
slack:chat.channel.slack,
canPost:chat.channel.canPost !== false,
owned:false,
archivedAt:chat.channel.archived ? (chat.updatedAt || new Date().toISOString()) : null,
members:(chat.participants || []).map((person) => ({
id:person.id,
userId:person.id,
name:person.name,
email:person.email,
})),
messageCount:Number(chat.messageCount || 0),
unreadCount:Number(chat.unreadCount || 0),
lastPreview:chat.lastMessage?.preview || "",
};
const at = groupsList.findIndex((group) => String(group.id) === id);
if (at === -1) groupsList.push(row);
else groupsList[at] = row;
}
groupsList.sort((a, b) => String(a.name).localeCompare(String(b.name)));
}
function cvgShowError(message) { cvgErrorEl.textContent = message || ""; }
function cvgApply(result) {
// A mutation's returned view replaces (or appends) its group; delete passes
// a removal marker. Sort by name so rows don't jump around per mutation.
if (result && result.removeId) groupsList = groupsList.filter((g) => g.id !== result.removeId);
else if (result && result.id) {
const at = groupsList.findIndex((g) => g.id === result.id);
if (at === -1) groupsList.push(result);
else groupsList[at] = result;
}
groupsList.sort((a, b) => String(a.name).localeCompare(String(b.name)));
renderGroups();
}
async function cvgCall(fn, ...args) {
cvgShowError("");
let res = null;
try { res = await fn(...args); } catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
if (!res || res.ok !== true) {
cvgShowError((res && res.error) || "That didn't go through — try again.");
return null;
}
return res.result;
}
async function loadGroups() {
const groups = await cvgCall(window.relay.groups);
if (Array.isArray(groups)) {
groupsList = groups.slice();
syncSlackChannelRows();
groupsLoaded = true;
renderGroups();
}
}
// ---- room-level group info ----------------------------------------------
// Contacts already had the complete roster editor, but a person should not
// have to leave the conversation to discover who is in the room. This sheet
// uses the same server-owned group view and mutations, so authority cannot
// drift between the two surfaces.
const groupInfoBackdropEl = document.getElementById("groupInfoBackdrop");
const groupInfoDetailEl = document.getElementById("groupInfoDetail");
let groupInfoGroup = null;
function closeGroupInfo() {
groupInfoBackdropEl.classList.add("hidden");
groupInfoGroup = null;
groupLeaveArmedId = null;
groupInfoDetailEl.innerHTML = "";
}
function groupInfoRoster(g, account = (payload && payload.account) || {}) {
// The API deliberately keeps the creator separate from the owner's contact
// rows: `owner` is identity/authority, while `members` are the fan-out
// contacts. A group-info sheet is about PEOPLE, so it must join both shapes
// (plus the signed-in viewer, whose membership is what made the group
// visible) and dedupe by account address before rendering.
const roster = [];
const seen = new Set();
const add = (person, role) => {
if (!person) return;
const email = String(person.email || "").trim().toLowerCase();
const currentUser = Boolean(email && email === String(account.email || "").trim().toLowerCase());
// A group's stored roster belongs to its owner, so its labels can differ
// from how the viewer has saved the same person. Mentions and group info
// are viewer-facing: their own contact book is authoritative there.
const savedContact = !currentUser && email
? contactsList.find((contact) => contactEmails(contact)
.some((address) => String(address || "").trim().toLowerCase() === email))
: null;
const savedName = String((savedContact && savedContact.name) || "").trim();
const shown = savedContact
? { ...person, ...(savedName ? { name:savedName } : {}), handle:savedContact.handle || null }
: person;
const name = String(shown.name || "").trim();
if (!email && !name && !person.userId && !person.contactId && !person.id) return;
const key = email
? `email:${email}`
: person.userId
? `user:${person.userId}`
: person.contactId || person.id
? `contact:${person.contactId || person.id}`
: `name:${normalizedPartyName(name)}`;
if (!key || seen.has(key)) return;
seen.add(key);
roster.push({
...shown,
role,
currentUser,
});
};
// Owner mutations historically returned the compact owned view without an
// owner object. The current account is still authoritative in that case.
add(g.owner || (g.owned !== false ? account : null), "owner");
for (const member of Array.isArray(g.members) ? g.members : []) add(member, "member");
// A non-owner can only receive this response because the API proved they
// belong to the group. Keep the viewer visible even across a rolling API
// response whose contact projection has not caught up yet.
add(account, g.owned !== false ? "owner" : "member");
return roster;
}
function groupDetailsMarkup(g, { sheet = false } = {}) {
const roster = groupInfoRoster(g);
const mine = g.owned !== false;
const slack = g.provider === "slack";
const archived = Boolean(g.archivedAt);
const editable = mine && !archived;
const count = roster.length;
const memberRows = roster.length ? roster.map((member) => {
const id = member.contactId || member.id;
const name = member.name || member.email || "Member";
const role = member.role === "owner" ? "Creator" : (member.currentUser ? "You" : "");
return `<div class="gd-member">
<span class="avatar" style="--av-h:${avatarHue(name)}" aria-hidden="true">${esc(avatarInitials(name))}</span>
<span class="gd-member-copy"><span class="nm">${esc(name)}${member.currentUser ? " (You)" : ""}</span>${member.email ? `<span class="em">${esc(member.email)}</span>` : ""}</span>
${editable && member.role !== "owner" && id
? `<button type="button" class="gd-remove" data-gd-remove="${esc(id)}">Remove</button>`
: role ? `<span class="gd-role">${esc(role)}</span>` : ""}
</div>`;
}).join("") : `<div class="cv-empty">No members yet.</div>`;
const rename = cvgRenamingId === g.id && editable
? `<form class="gd-rename-form" data-gd-rename-form><input class="cv-input" value="${esc(g.name)}" autocomplete="off" spellcheck="false" /><button class="cv-save" type="submit">Save</button><button class="cv-cancel" type="button" data-gd-rename-cancel>Cancel</button></form>`
: "";
const ownerTools = editable ? `<div class="gd-owner-tools">
<div class="gd-add"><input class="cv-input" data-gd-add placeholder="Add member — type a name…" autocomplete="off" spellcheck="false" /><div class="cvg-sugg" data-gd-suggestions></div></div>
${rename || `<div class="gd-actions"><button class="gd-action" type="button" data-gd-rename>Rename</button><button class="gd-action danger" type="button" data-gd-archive>${cvgDeleteArmedId === g.id ? "Really archive? Messages stay" : "Archive channel"}</button></div>`}
</div>` : "";
let membershipAction = "";
if (archived) {
membershipAction = `<div class="gd-membership"><div class="gd-status">This channel is archived. Its messages stay readable, but nobody can post in it.</div></div>`;
} else if (!mine && !slack) {
membershipAction = groupLeaveArmedId === g.id
? `<div class="gd-membership gd-membership-confirm"><div class="gd-confirm"><strong>Leave ${esc(g.name)}?</strong><p>You’ll stop receiving new messages. Your existing messages will remain in Relays.</p><div class="gd-confirm-actions"><button class="cancel" type="button" data-gd-leave-cancel>Cancel</button><button class="confirm" type="button" data-gd-leave-confirm>Leave channel</button></div></div></div>`
: `<div class="gd-membership"><button class="gd-leave" type="button" data-gd-leave>Leave channel</button></div>`;
}
return `<div class="group-detail${sheet ? " in-sheet" : ""}" data-group-detail="${esc(g.id)}">
${sheet ? "" : `<button class="gd-back" type="button" data-gd-back>‹ Channels</button>`}
<div class="gd-identity"><span class="cvg-stack">${groupAvatarStack(g, roster)}</span><span class="gd-title"><h2>${esc(g.name || "Channel")}</h2><p>${count} ${count === 1 ? "person" : "people"}</p></span></div>
<div class="gd-section-label">Members</div>
<div class="gd-members">${memberRows}</div>
${ownerTools}${membershipAction}
<div class="gd-error" data-gd-error></div>
</div>`;
}
async function groupDetailCall(container, fn, ...args) {
const errorEl = container.querySelector("[data-gd-error]");
if (errorEl) errorEl.textContent = "";
let res = null;
try { res = await fn(...args); } catch (error) { res = { ok:false, error:String(error?.message || error) }; }
if (!res || res.ok !== true) {
if (errorEl) errorEl.textContent = res?.error || "That change did not go through.";
return null;
}
return res.result;
}
function applyDetailedGroup(updated) {
if (!updated || !updated.id) return;
groupInfoGroup = groupInfoGroup && groupInfoGroup.id === updated.id ? updated : groupInfoGroup;
cvgApply(updated);
if (!groupInfoBackdropEl.classList.contains("hidden") && groupInfoGroup) renderGroupInfo();
}
function bindGroupDetails(container, g, { sheet = false } = {}) {
const back = container.querySelector("[data-gd-back]");
if (back) back.addEventListener("click", () => {
cvgExpandedId = null;
cvgRenamingId = null;
cvgDeleteArmedId = null;
groupLeaveArmedId = null;
renderGroups();
});
const leave = container.querySelector("[data-gd-leave]");
if (leave) leave.addEventListener("click", () => { groupLeaveArmedId = g.id; sheet ? renderGroupInfo() : renderGroups(); });
const leaveCancel = container.querySelector("[data-gd-leave-cancel]");
if (leaveCancel) leaveCancel.addEventListener("click", () => { groupLeaveArmedId = null; sheet ? renderGroupInfo() : renderGroups(); });
const leaveConfirm = container.querySelector("[data-gd-leave-confirm]");
if (leaveConfirm) leaveConfirm.addEventListener("click", async () => {
leaveConfirm.disabled = true;
const result = await groupDetailCall(container, window.relay.groupLeave, g.id);
if (!result) { leaveConfirm.disabled = false; return; }
leftGroupIds.add(g.id);
groupsList = groupsList.filter((group) => group.id !== g.id);
groupsLoaded = true;
groupLeaveArmedId = null;
cvgExpandedId = null;
closeGroupInfo();
renderGroups();
renderThreadDetail();
if (window.relay.refresh) window.relay.refresh().catch(() => {});
});
for (const button of container.querySelectorAll("[data-gd-remove]")) {
button.addEventListener("click", async () => {
button.disabled = true;
const updated = await groupDetailCall(container, window.relay.groupRemoveMember, g.id, button.getAttribute("data-gd-remove"));
if (updated) applyDetailedGroup(updated); else button.disabled = false;
});
}
const rename = container.querySelector("[data-gd-rename]");
if (rename) rename.addEventListener("click", () => { cvgRenamingId = g.id; sheet ? renderGroupInfo() : renderGroups(); });
const renameCancel = container.querySelector("[data-gd-rename-cancel]");
if (renameCancel) renameCancel.addEventListener("click", () => { cvgRenamingId = null; sheet ? renderGroupInfo() : renderGroups(); });
const renameForm = container.querySelector("[data-gd-rename-form]");
if (renameForm) renameForm.addEventListener("submit", async (event) => {
event.preventDefault();
const name = renameForm.querySelector("input").value.trim();
if (!name) return;
const updated = await groupDetailCall(container, window.relay.groupRename, g.id, name);
if (updated) { cvgRenamingId = null; applyDetailedGroup(updated); }
});
const archive = container.querySelector("[data-gd-archive]");
if (archive) archive.addEventListener("click", async () => {
if (cvgDeleteArmedId !== g.id) { cvgDeleteArmedId = g.id; sheet ? renderGroupInfo() : renderGroups(); return; }
archive.disabled = true;
const result = await groupDetailCall(container, window.relay.groupDelete, g.id);
cvgDeleteArmedId = null;
if (!result) { archive.disabled = false; return; }
applyDetailedGroup({ ...g, archivedAt: result.archivedAt || new Date().toISOString() });
});
const addInput = container.querySelector("[data-gd-add]");
const suggestions = container.querySelector("[data-gd-suggestions]");
if (addInput && suggestions) {
addInput.addEventListener("input", () => {
clearTimeout(cvgSearchTimer);
const q = addInput.value.trim();
if (q.length < 2) { suggestions.innerHTML = ""; return; }
cvgSearchTimer = setTimeout(async () => {
const result = await groupDetailCall(container, window.relay.contactsSearch, q);
if (!result) return;
const existing = new Set(groupInfoRoster(g).map((member) => member.contactId || member.id).filter(Boolean));
const matches = (result.matches || []).filter((match) => match.contactId && !existing.has(match.contactId)).slice(0, 5);
suggestions.innerHTML = matches.map((match) => `<button type="button" data-gd-add-pick="${esc(match.contactId)}">${esc(match.name || match.email)}${match.email && match.name ? ` — ${esc(match.email)}` : ""}</button>`).join("");
for (const button of suggestions.querySelectorAll("[data-gd-add-pick]")) {
button.addEventListener("click", async () => {
button.disabled = true;
const updated = await groupDetailCall(container, window.relay.groupAddMember, g.id, button.getAttribute("data-gd-add-pick"));
if (updated) applyDetailedGroup(updated); else button.disabled = false;
});
}
}, 220);
});
}
}
function renderPeopleGroupDetail() {
const group = groupsList.find((candidate) => candidate.id === cvgExpandedId) || null;
contactsViewEl.classList.toggle("contacts-group-detail", Boolean(group));
cvgDetailEl.classList.toggle("gone", !group);
if (!group) { cvgDetailEl.innerHTML = ""; return; }
cvgDetailEl.innerHTML = groupDetailsMarkup(group);
bindGroupDetails(cvgDetailEl, group);
}
function renderGroupInfo() {
if (!groupInfoGroup) return;
groupInfoDetailEl.innerHTML = groupDetailsMarkup(groupInfoGroup, { sheet:true });
bindGroupDetails(groupInfoDetailEl, groupInfoGroup, { sheet:true });
}
async function openGroupInfo() {
const room = threadDetailId ? chatRoomForThread(threadDetailId) : null;
if (!room || !room.isGroup) return;
groupInfoBackdropEl.classList.remove("hidden");
groupInfoDetailEl.innerHTML = `<div class="cv-empty">Loading channel info…</div>`;
const result = await cvgCall(window.relay.groups);
if (!Array.isArray(result)) {
groupInfoDetailEl.innerHTML = `<div class="cv-empty">This channel’s roster is unavailable.</div>`;
return;
}
groupsLoaded = true;
groupsList = result.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
// Native groups and Slack channels are two projections of the same
// Channels surface. Reloading the native roster must never discard the
// current Slack projection or turn a cache miss into a fake departure.
syncSlackChannelRows();
const wantedId = String(room.chatId || room.groupId || "");
const wantedName = normalizedPartyName(room.groupName || room.name);
groupInfoGroup = groupsList.find((group) => wantedId && String(group.id) === wantedId)
|| groupsList.find((group) => normalizedPartyName(group.name) === wantedName)
|| null;
if (!groupInfoGroup) {
groupInfoDetailEl.innerHTML = leftGroupIds.has(String(room.groupId || room.chatId || ""))
? `<div class="cv-empty">You left this channel. Existing messages remain readable.</div>`
: `<div class="cv-empty">Channel info is still syncing.</div>`;
return;
}
renderGroupInfo();
}
document.getElementById("groupInfoClose").addEventListener("click", closeGroupInfo);
groupInfoBackdropEl.addEventListener("click", (event) => { if (event.target === groupInfoBackdropEl) closeGroupInfo(); });
thGroupInfoEl.addEventListener("click", openGroupInfo);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !groupInfoBackdropEl.classList.contains("hidden")) closeGroupInfo();
});
function groupMemberSummary(roster) {
if (!roster.length) return "No people yet";
const firstNames = roster.map((member) =>
String(member.name || member.email || "Person").trim().split(/\s+/)[0],
);
if (firstNames.length <= 2) return firstNames.join(", ");
return `${firstNames[0]}, ${firstNames[1]} + ${firstNames.length - 2}`;
}
function groupAvatarStack(group, roster) {
if (group.provider === "slack") {
return '<img class="cvg-slack-logo" src="slackMark.png" alt="Slack" />';
}
const people = roster.slice(0, 3);
if (!people.length) {
return `<span class="cv-avatar" style="--cv-h:${cvHue(group.name)}" aria-hidden="true">${esc(cvInitials(group.name, ""))}</span>`;
}
return people.map((member) => {
const label = member.name || member.email || "?";
return `<span class="cv-avatar" style="--cv-h:${cvHue(label)}" aria-hidden="true">${esc(cvInitials(label, member.email))}</span>`;
}).join("");
}
function renderGroups() {
syncSlackChannelRows();
cvgEmptyEl.classList.toggle("gone", groupsList.length > 0);
refreshPaneCounts();
cvgListEl.innerHTML = groupsList.map((g) => {
const roster = groupInfoRoster(g);
const count = roster.length;
const slack = g.provider === "slack";
const mine = !slack && g.owned !== false;
const archived = Boolean(g.archivedAt);
return `<button class="cvg-item" type="button" data-group="${esc(g.id)}">
<span class="cvg-stack">${groupAvatarStack(g, roster)}</span>
<span class="cvg-body">
<span class="cvg-name">${esc(g.name)}</span>
<span class="cvg-sub">${esc(slack ? (g.lastPreview || "Slack channel") : groupMemberSummary(roster))}</span>
</span>
${slack ? "" : `<span class="cvg-edit" role="button" tabindex="0" data-group-edit="${esc(g.id)}">Edit</span>`}
<span class="cvg-meta">${slack ? '<span class="cvg-badge">Slack</span>' : archived ? `<span class="cvg-badge">Archived</span>` : mine ? "" : `<span class="cvg-badge">Added you</span>`}<span class="cvg-count">${slack ? `${Number(g.messageCount || 0)} ${Number(g.messageCount || 0) === 1 ? "message" : "messages"}` : `${count} ${count === 1 ? "person" : "people"}`}</span></span>
</button>`;
}).join("");
renderPeopleGroupDetail();
wireGroupRows();
}
/**
* Open the conversation a group row names.
*
* Same law as a contact row: the room the pill already holds opens IN the
* pill with People still lit, and a group nothing has been said in yet
* resolves server-side in its own window.
*/
function openGroupRoom(groupId) {
const group = groupsList.find((candidate) => candidate.id === groupId) || null;
if (!group) return;
cvgShowError("");
const wantedId = String(group.id);
const wantedName = normalizedPartyName(group.name);
// A group room's identity is its id where the wire carried one, and its
// name otherwise — the same two keys chatSections() merges rooms by.
const existing = chatSections().groups.find((room) =>
(room.groupId && String(room.groupId) === wantedId)
|| (!room.groupId && normalizedPartyName(room.groupName || room.name) === wantedName));
if (existing) {
const morphFromList = prepareReaderMorph(activeView);
openThreadDetail(existing.threadId, existing.name || group.name, "contacts", { expanded: true });
if (morphFromList) startReaderMorph("threads");
return;
}
cvgShowError("That conversation could not be opened.");
}
function groupRoomPostingState(room) {
if (!room || !room.isGroup || !room.groupId) return { blocked:false, reason:"" };
const groupId = String(room.groupId);
if (leftGroupIds.has(groupId)) return { blocked:true, reason:"You left this channel. Existing messages remain readable." };
const slackChat = [...(payload.chats || []), ...(payload.slackChats || [])].find((chat) =>
slackIntegrationForChat(chat) && (String(chat.chatId) === String(room.chatId || groupId)),
);
if (isSlackIntegratedRoom(room) || slackChat) {
if (room.integration?.state === "archived") return { blocked:true, reason:"This channel is archived. Existing messages remain readable." };
if (slackChat?.channel?.archived) return { blocked:true, reason:"This channel is archived. Existing messages remain readable." };
if (slackChat?.channel?.canPost === false) return { blocked:true, reason:"Posting is unavailable for this Slack channel." };
return { blocked:false, reason:"" };
}
if (!groupsLoaded) return { blocked:false, reason:"" };
const group = groupsList.find((candidate) => String(candidate.id) === groupId);
// A local roster miss is not evidence of departure. The API remains the
// write authority and will reject a send if membership really changed.
if (!group) return { blocked:false, reason:"" };
if (group.archivedAt) return { blocked:true, reason:"This channel is archived. Existing messages remain readable." };
return { blocked:false, reason:"" };
}
function wireGroupRows() {
for (const el of cvgListEl.querySelectorAll("[data-group]")) {
el.addEventListener("click", () => {
// A group row is the ROOM its roster names, so tapping it opens the
// conversation — the same law the people list follows. The roster
// itself is the rarer errand, and Edit is its way in.
openGroupRoom(el.getAttribute("data-group"));
});
}
for (const el of cvgListEl.querySelectorAll("[data-group-edit]")) {
const open = (event) => {
event.stopPropagation(); // the roster, not the room
const id = el.getAttribute("data-group-edit");
const closing = cvgExpandedId === id;
cvgExpandedId = closing ? null : id;
cvgDeleteArmedId = null; cvgRenamingId = null;
cvgShowError("");
renderGroups();
};
el.addEventListener("click", open);
el.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
open(event);
});
}
for (const el of cvgListEl.querySelectorAll("[data-unmember]")) {
el.addEventListener("click", async (ev) => {
ev.stopPropagation();
const [gid, cid] = el.getAttribute("data-unmember").split("|");
const view = await cvgCall(window.relay.groupRemoveMember, gid, cid);
if (view) cvgApply(view);
});
}
for (const el of cvgListEl.querySelectorAll("[data-rename]")) {
el.addEventListener("click", () => { cvgRenamingId = el.getAttribute("data-rename"); renderGroups();
const input = cvgListEl.querySelector(`[data-rename-input="${CSS.escape(cvgRenamingId)}"]`);
if (input) { input.focus(); input.select(); }
});
}
for (const el of cvgListEl.querySelectorAll("[data-rename-cancel]")) {
el.addEventListener("click", () => { cvgRenamingId = null; renderGroups(); });
}
for (const form of cvgListEl.querySelectorAll("[data-rename-form]")) {
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const gid = form.getAttribute("data-rename-form");
const name = form.querySelector("input").value.trim();
if (!name) return;
const view = await cvgCall(window.relay.groupRename, gid, name);
if (view) { cvgRenamingId = null; cvgApply(view); }
});
}
for (const el of cvgListEl.querySelectorAll("[data-delete]")) {
el.addEventListener("click", async () => {
const gid = el.getAttribute("data-delete");
// Archiving a room is not undoable from here: first tap arms, second
// tap commits. The room stays listed, marked archived, with its history.
if (cvgDeleteArmedId !== gid) { cvgDeleteArmedId = gid; renderGroups(); return; }
const res = await cvgCall(window.relay.groupDelete, gid);
cvgDeleteArmedId = null;
if (res) {
const at = groupsList.findIndex((g) => g.id === gid);
if (at !== -1) groupsList[at] = { ...groupsList[at], archivedAt: res.archivedAt || new Date().toISOString() };
cvgExpandedId = null;
renderGroups();
}
});
}
for (const input of cvgListEl.querySelectorAll("[data-addmember]")) {
const gid = input.getAttribute("data-addmember");
const suggEl = cvgListEl.querySelector(`[data-sugg="${CSS.escape(gid)}"]`);
input.addEventListener("input", () => {
clearTimeout(cvgSearchTimer);
const q = input.value.trim();
if (q.length < 2) { suggEl.innerHTML = ""; return; }
cvgSearchTimer = setTimeout(async () => {
const res = await cvgCall(window.relay.contactsSearch, q);
const group = groupsList.find((x) => x.id === gid);
const already = new Set(((group && group.members) || []).map((m) => m.contactId || m.id));
const matches = ((res && res.matches) || []).filter((m) => m.contactId && !already.has(m.contactId)).slice(0, 4);
suggEl.innerHTML = matches.map((m) =>
`<button type="button" data-addpick="${esc(gid)}|${esc(m.contactId)}">${esc(m.name || m.email)}${m.email && m.name ? ` — ${esc(m.email)}` : ""}</button>`,
).join("");
for (const b of suggEl.querySelectorAll("[data-addpick]")) {
b.addEventListener("click", async () => {
const [g2, cid] = b.getAttribute("data-addpick").split("|");
const view = await cvgCall(window.relay.groupAddMember, g2, cid);
if (view) cvgApply(view);
});
}
}, 250);
});
}
}
cvgNewEl.addEventListener("click", () => {
cvgNewFormEl.classList.toggle("hidden");
if (!cvgNewFormEl.classList.contains("hidden")) {
cvgNewNameEl.focus();
}
});
cvgNewCancelEl.addEventListener("click", () => { cvgNewNameEl.value = ""; cvgNewFormEl.classList.add("hidden"); });
cvgNewFormEl.addEventListener("submit", async (ev) => {
ev.preventDefault();
const name = cvgNewNameEl.value.trim();
if (!name) return;
const view = await cvgCall(window.relay.groupCreate, name);
if (view) {
cvgNewNameEl.value = "";
cvgNewFormEl.classList.add("hidden");
cvgExpandedId = view.id; // open the fresh group so adding members is the next tap
cvgApply(view);
}
});
function cvSplitName(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
return { first: parts[0] || "", last: parts.slice(1).join(" ") };
}
// ---- multi-email chip input (ported from the website's EmailsInput) ----
// The form holds the committed addresses here; the draft <input> (#cvEmail) holds whatever
// is being typed. Enter / comma / space (or a suggestion click) commits the draft as a chip.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const COMMON_DOMAINS = ["gmail.com","outlook.com","hotmail.com","yahoo.com","icloud.com","me.com","proton.me","protonmail.com","live.com","aol.com"];
let cvEmailsList = []; // committed, lowercased, deduped addresses (the chips)
let cvSuggIdx = 0; // highlighted suggestion row
let cvDeleteArmed = false;
let cvDeleteTimer = null;
function isValidEmail(s) { return EMAIL_RE.test(String(s).trim()); }
function cvSuggestions() {
const draft = cvEmailEl.value;
const at = draft.indexOf("@");
if (at < 1) return [];
const local = draft.slice(0, at);
const domainPart = draft.slice(at + 1).toLowerCase();
return COMMON_DOMAINS.filter((d) => d.startsWith(domainPart) && d !== domainPart)
.slice(0, 4)
.map((d) => `${local}@${d}`);
}
function renderEmailHint() {
// mirror the website: a per-relay routing hint once there's more than one address.
cvHintEl.textContent = cvEmailsList.length > 1
? "A relay goes to whichever address has a Relay account. If none does, all of them get the email."
: "";
}
function renderEmailChips() {
// wipe existing chips (keep the draft <input>), then prepend a chip per address.
for (const ch of [...cvEmailsEl.querySelectorAll(".cv-chip")]) ch.remove();
for (const e of cvEmailsList) {
const chip = document.createElement("span");
chip.className = "cv-chip";
const label = document.createElement("span");
label.className = "e"; label.textContent = e;
const x = document.createElement("button");
x.type = "button"; x.className = "cv-chip-x"; x.setAttribute("aria-label", "Remove " + e); x.textContent = "×";
x.addEventListener("click", (ev) => { ev.stopPropagation(); removeEmail(e); cvEmailEl.focus(); });
chip.appendChild(label); chip.appendChild(x);
cvEmailsEl.insertBefore(chip, cvEmailEl);
}
cvEmailEl.placeholder = cvEmailsList.length ? "Add another…" : "name@company.com";
renderEmailHint();
}
function renderSuggestions() {
const list = cvSuggestions();
if (!list.length) { cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = ""; return; }
if (cvSuggIdx >= list.length) cvSuggIdx = 0;
cvSuggestEl.innerHTML = list.map((s, i) =>
`<button type="button" class="cv-sugg${i === cvSuggIdx ? " active" : ""}" data-sugg="${esc(s)}">${esc(s)}</button>`
).join("");
cvSuggestEl.classList.remove("hidden");
for (const b of cvSuggestEl.querySelectorAll(".cv-sugg")) {
b.addEventListener("mousedown", (ev) => { ev.preventDefault(); addEmail(b.getAttribute("data-sugg")); });
}
}
function addEmail(raw) {
const e = String(raw).trim().toLowerCase().replace(/[,;]+$/, "");
if (!e) return true;
if (!isValidEmail(e)) { cvErrorEl.textContent = `"${e}" doesn't look like a valid email.`; return false; }
if (!cvEmailsList.some((v) => v.toLowerCase() === e)) cvEmailsList.push(e);
cvEmailEl.value = ""; cvErrorEl.textContent = ""; cvSuggIdx = 0;
renderEmailChips(); renderSuggestions();
return true;
}
function removeEmail(e) {
cvEmailsList = cvEmailsList.filter((x) => x !== e);
renderEmailChips();
}
function editingContactId() {
return cvEditing && (cvEditing.id || cvEditing.contactId) ? String(cvEditing.id || cvEditing.contactId) : "";
}
function resetDeleteButton() {
cvDeleteArmed = false;
if (cvDeleteTimer) { clearTimeout(cvDeleteTimer); cvDeleteTimer = null; }
cvDeleteEl.textContent = "Delete";
cvDeleteEl.classList.remove("armed");
cvDeleteEl.disabled = false;
}
function syncDeleteButton() {
resetDeleteButton();
const canDelete = Boolean(editingContactId());
cvDeleteEl.classList.toggle("gone", !canDelete);
}
cvEmailsEl.addEventListener("mousedown", (e) => {
if (e.target === cvEmailsEl) { e.preventDefault(); cvEmailEl.focus(); }
});
cvEmailEl.addEventListener("focus", () => { cvEmailsEl.classList.add("focus"); });
cvEmailEl.addEventListener("blur", () => {
cvEmailsEl.classList.remove("focus");
if (cvEmailEl.value.trim()) addEmail(cvEmailEl.value);
cvSuggestEl.classList.add("hidden");
});
cvEmailEl.addEventListener("input", () => { cvErrorEl.textContent = ""; cvSuggIdx = 0; renderSuggestions(); });
cvEmailEl.addEventListener("keydown", (ev) => {
const list = cvSuggestions();
if (ev.key === "ArrowDown" && list.length) { ev.preventDefault(); cvSuggIdx = Math.min(cvSuggIdx + 1, list.length - 1); renderSuggestions(); }
else if (ev.key === "ArrowUp" && list.length) { ev.preventDefault(); cvSuggIdx = Math.max(cvSuggIdx - 1, 0); renderSuggestions(); }
else if ((ev.key === "Enter" || ev.key === "Tab") && (list.length || cvEmailEl.value)) {
if (ev.key === "Tab" && !cvEmailEl.value) return;
ev.preventDefault();
addEmail(list.length ? list[cvSuggIdx] : cvEmailEl.value);
} else if (ev.key === "," || ev.key === " ") {
ev.preventDefault(); addEmail(cvEmailEl.value);
} else if (ev.key === "Backspace" && !cvEmailEl.value && cvEmailsList.length) {
ev.preventDefault(); removeEmail(cvEmailsList[cvEmailsList.length - 1]);
}
});
function openContactForm(key) {
const c = key ? contactsList.find((x) => contactKey(x) === key) : null;
cvEditing = c || null;
const { first, last } = cvSplitName(c ? c.name : "");
cvFirstEl.value = first;
cvLastEl.value = last;
cvEmailsList = c ? contactEmails(c).slice() : [];
cvEmailEl.value = "";
cvSuggIdx = 0;
cvErrorEl.textContent = "";
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
renderEmailChips();
syncDeleteButton();
cvFormEl.classList.remove("hidden");
setInteractive(true);
setTimeout(() => cvFirstEl.focus(), 30);
}
function closeContactForm() {
cvFormEl.classList.add("hidden");
cvEditing = null;
cvEmailsList = [];
cvEmailEl.value = "";
cvErrorEl.textContent = "";
cvHintEl.textContent = "";
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
cvDeleteEl.classList.add("gone");
resetDeleteButton();
}
// Normalizes a contact write into { ok, error, contacts }. The main process now
// answers with that envelope; the catch is a backstop for a genuinely broken IPC,
// where Electron prefixes the message with "Error invoking remote method '...':"
// — that prefix is noise to the person reading the contact card, so strip it.
async function contactWriteResult(promise, fallback) {
try {
const res = await promise;
if (Array.isArray(res)) return { ok: true, contacts: res }; // older main process
if (res && res.ok === false) return { ok: false, error: res.error || fallback };
if (res && res.ok === true) return { ok: true, contacts: Array.isArray(res.contacts) ? res.contacts : null };
return { ok: true, contacts: null };
} catch (err) {
const raw = (err && err.message) ? String(err.message) : "";
const clean = raw.replace(/^Error invoking remote method '[^']*':\s*/, "").replace(/^Error:\s*/, "").trim();
return { ok: false, error: clean || fallback };
}
}
cvAddEl.addEventListener("click", () => openContactForm(null));
cvCancelEl.addEventListener("click", closeContactForm);
cvDeleteEl.addEventListener("click", async () => {
const contactId = editingContactId();
if (!contactId || cvDeleteEl.disabled) return;
if (!cvDeleteArmed) {
cvDeleteArmed = true;
cvDeleteEl.textContent = "Delete?";
cvDeleteEl.classList.add("armed");
cvDeleteTimer = setTimeout(resetDeleteButton, 3500);
return;
}
cvDeleteEl.disabled = true;
cvSaveEl.disabled = true;
cvErrorEl.textContent = "";
try {
const res = await contactWriteResult(window.relay.contactDelete({ contactId }), "Could not delete.");
if (!res.ok) {
cvErrorEl.textContent = res.error;
resetDeleteButton();
return;
}
// The row is gone server-side even if the refresh didn't come back; drop it
// locally rather than leaving a deleted contact on screen.
if (Array.isArray(res.contacts)) contactsList = res.contacts;
else contactsList = contactsList.filter((c) => String((c && (c.id || c.contactId)) || "") !== contactId);
closeContactForm();
renderContacts();
} finally {
cvDeleteEl.disabled = false;
cvSaveEl.disabled = false;
}
});
cvFormEl.addEventListener("submit", async (e) => {
e.preventDefault();
const name = [cvFirstEl.value, cvLastEl.value].map((s) => s.trim()).filter(Boolean).join(" ");
// fold any half-typed draft into the chips before validating.
if (cvEmailEl.value.trim() && !addEmail(cvEmailEl.value)) return;
const emails = cvEmailsList.filter(isValidEmail);
if (!name) { cvErrorEl.textContent = "A first name is required."; return; }
if (!emails.length) { cvErrorEl.textContent = "At least one email is required."; return; }
cvSaveEl.disabled = true;
cvErrorEl.textContent = "";
try {
// Match the website's ContactWrite shape: emails[] is the identity, email = primary.
const contactId = editingContactId();
const res = await contactWriteResult(
window.relay.contactSave({ contactId, name, emails, email: emails[0] }),
"Could not save.",
);
if (!res.ok) { cvErrorEl.textContent = res.error; return; }
if (Array.isArray(res.contacts)) contactsList = res.contacts;
closeContactForm();
renderContacts();
} finally {
cvSaveEl.disabled = false;
}
});
// ---------- views / tabs ----------
// ---------- Threads view ----------
// Conversations grouped by threadId across BOTH directions: staged inbound
// relays plus the live Sent list. Local data only — the pill shows the
// user's own exchanges; the API's /v1/threads serves web and agents.
let threadDetailId = null; // stable room coordinate while a conversation is open
// Canonical Slack rooms are addressed by chatId. Message/root ids may change
// as replies arrive or missing Slack roots reconcile, but this coordinate
// remains valid for the lifetime of the room and can recover it independently
// from the current list summary.
let threadDetailChatId = "";
let threadDetailLoadError = "";
let threadDetailRecovery = null;
// Slack threads are a subview inside one Slack-backed Relay channel. Keep
// this separate from threadDetailId, which identifies the room itself.
let slackFocusedThreadId = "";
let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
let threadDetailEntryFollow = null; // follows newest through the room's initial sent-cache hydration
let threadDetailEntryFollowSeq = 0;
let threadComposerEntryFocus = null; // one-shot: entering a room gives its composer the keyboard
let threadComposerEntryFocusSeq = 0;
let threadDetailFollowSendFor = null; // one-shot: a successful send follows its new bubble smoothly
let threadDetailPartyHint = ""; // who it's with, for the header while messages load
const visibleRoomAckIds = new Set(); // suppress duplicate acks while the daemon catches up
const optimisticReadIds = new Set(); // a polling payload cannot resurrect a read while persistence waits
const threadComposerDrafts = new Map(); // threadId -> text; survives live DOM replacement
const threadReplyTargets = new Map(); // visible room/thread id -> exact Relay id being answered
const messageEditDrafts = new Map(); // relayId -> replacement forHuman while its inline editor is open
const messageDeleteConfirmIds = new Set(); // relayIds awaiting the deliberate second Delete click
const messageMutationIds = new Set(); // relayIds with an edit/delete request in flight
const threadReplyAttemptKeys = new Map(); // threadId -> { fingerprint, key }; retries converge
// The API acknowledgement and the Sent projection are two different network
// events. Keep the user's message in the room between them; otherwise a
// successful Send clears the composer and appears to have done nothing when
// the follow-up Sent fetch is slow or fails. Entries leave only when the
// canonical relay id appears on either side of the local payload.
const optimisticChatReplies = new Map(); // idempotencyKey -> thread-message row
const chatReplySending = new Set(); // visible room/thread id
function activeSlackRoomRecoveryMatches(chatId) {
return activeView === "threads" && threadsSource === "slack"
&& String(threadDetailChatId || "") === String(chatId || "");
}
function recoverActiveSlackRoom({ force = false } = {}) {
const chatId = String(threadDetailChatId || "");
if (!chatId || !activeSlackRoomRecoveryMatches(chatId)) return null;
if (threadDetailRecovery?.chatId === chatId) return threadDetailRecovery.promise;
if (threadDetailLoadError && !force) return null;
threadDetailLoadError = "";
const room = { chatId, provider:"slack", integration:{ provider:"slack" } };
let retryStale = false;
const promise = requestCanonicalChatDetail(room, "slack", { includeSlack:true }).then((result) => {
if (!activeSlackRoomRecoveryMatches(chatId)) return false;
if (!result?.ok || !result.chat) {
threadDetailLoadError = result?.error || "Could not load this Slack conversation.";
renderThreadDetail();
return false;
}
if (!canonicalChatResultIsCurrent(result, chatId)) {
retryStale = true;
return false;
}
storeCanonicalChatDetail(result.chat, { surface:"slack", includeSlack:true });
threadDetailLoadError = "";
renderThreadDetail();
readVisibleChatRoom();
return true;
}).catch((error) => {
if (!activeSlackRoomRecoveryMatches(chatId)) return false;
threadDetailLoadError = error?.message || "Could not load this Slack conversation.";
renderThreadDetail();
return false;
}).finally(() => {
if (threadDetailRecovery?.promise === promise) threadDetailRecovery = null;
if (retryStale && activeSlackRoomRecoveryMatches(chatId)) {
queueMicrotask(() => recoverActiveSlackRoom({ force:true }));
}
});
threadDetailRecovery = { chatId, promise };
return promise;
}
function beginThreadComposerEntryFocus(threadId) {
const request = {
token: ++threadComposerEntryFocusSeq,
threadId: String(threadId || ""),
settleQueued: false,
};
threadComposerEntryFocus = request;
return request;
}
function focusThreadComposerOnEntry(request = threadComposerEntryFocus, { recover = false, settle = false } = {}) {
if (!request || threadComposerEntryFocus?.token !== request.token
|| activeView !== "threads" || String(threadDetailId || "") !== request.threadId) return false;
const input = document.getElementById("thQrInput");
if (!input || input.disabled) return false;
const focused = document.activeElement;
if (recover && focused && focused !== document.body && focused !== document.documentElement
&& focused !== input && focused.isConnected) {
// A real click made after room entry wins over the recovery frame.
threadComposerEntryFocus = null;
return false;
}
input.focus({ preventScroll:true });
if (document.activeElement !== input) return false;
const end = input.value.length;
try { input.setSelectionRange(end, end); } catch {}
if (settle) threadComposerEntryFocus = null;
return true;
}
function settleThreadComposerEntryFocus(request = threadComposerEntryFocus) {
if (!request || request.settleQueued || threadComposerEntryFocus?.token !== request.token) return;
request.settleQueued = true;
requestAnimationFrame(() => {
request.settleQueued = false;
focusThreadComposerOnEntry(request, { recover:true, settle:true });
});
}
/**
* Mirror the device's send queue into the room transcript.
*
* An unsent message used to live ONLY here, in renderer memory, and only for
* as long as one send attempt took. That is why a send on weak wifi ended
* with the words back in the composer: when the attempt failed there was
* nothing left holding them. The queue in the main process is now the record,
* and this projects it — so a message typed offline keeps its place in the
* conversation across repaints, room switches, and quitting the pill.
*/
function syncOutboxProjection() {
const live = new Set();
for (const entry of payload.outbox || []) {
if (!entry || !entry.id) continue;
const key = String(entry.id);
live.add(key);
const chat = entry.chat || {};
const files = Array.isArray(entry.files) ? entry.files : [];
const existing = optimisticChatReplies.get(key) || {};
optimisticChatReplies.set(key, {
...existing,
id: entry.relayId || `outbox:${key}`,
fromOutbox: true,
outboxId: key,
// "queued" is the honest word for what the clock means in every other
// messenger: this device has it, the server does not yet.
outboxState: String(entry.state || "queued"),
outboxAttempts: Number(entry.attempts || 0),
outboxError: String(entry.lastError || ""),
relayId: String(entry.relayId || ""),
groupSendId: String(entry.groupSendId || ""),
threadId: String(entry.threadId || chat.threadId || ""),
inReplyToRelayId: String(entry.inReplyToRelayId || ""),
// Current pill senders set inReplyToRelayId only after an explicit
// reader/reply-button gesture. Keep that presentation while the
// durable outbox row stands in for the canonical Relay.
explicitReply: Boolean(entry.inReplyToRelayId),
direction: "out",
seen: false,
seenAt: null,
readReceipts: [],
// Render-model field, not the wire title: a text bubble paints m.title.
title: entry.text || (files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files`),
body: entry.text || "",
party: chat.party || "",
partyKey: chat.partyKey || "",
at: entry.createdAt,
isGroup: Boolean(chat.isGroup),
groupId: chat.groupId || "",
groupName: chat.groupName || "",
chatId: entry.recipient?.chatId || chat.chatId || "",
addressRecipient: entry.recipient || null,
provider: chat.provider ? { name:chat.provider } : null,
unread: false,
textLike: true,
request: false,
pending: String(entry.state || "queued") !== "sent",
reactions: { aggregates: [], events: [] },
preview: "",
});
}
// A row this projection put here and the queue has since retired is gone
// for good — its canonical relay is in the Sent list now. Rows the composer
// created moments ago are left alone; they retire on the canonical match.
for (const [key, row] of optimisticChatReplies) {
if (row && row.fromOutbox && !live.has(key)) optimisticChatReplies.delete(key);
}
}
// The room composer is wired ONCE and outlives every repaint, so its
// listeners dispatch through this ref instead of capturing one render's
// closure. Re-binding per render would stack a stale sender per repaint.
let threadComposerSend = null;
// The completion species: typed by the MCP contract, threaded onto the Task.
function isCompletionRelay(r) {
return String((r && r.type) || "") === "completion";
}
// A REQUEST OWNS ITS EXECUTION THREAD. Start receipts and progress notes
// belong to the Task run view. The completion is the boundary: the same
// two-document Relay closes the loop in the person's room and the Task.
function requestThreadIds() {
const ids = new Set();
for (const r of payload.relays || []) {
if (!isTaskRow(r)) continue;
ids.add(String(r.id));
if (r.threadId) ids.add(String(r.threadId));
}
for (const sent of payload.sent || []) {
if (String(sent.kind || "") !== "task" && String(sent.kind || "") !== "task_request") continue;
const id = sent.relayId || sent.id;
if (id) ids.add(String(id));
if (sent.threadId) ids.add(String(sent.threadId));
}
return ids;
}
function onRequestThread(r, ids) {
if (!r) return false;
const thread = String(r.threadId || "");
const reply = String(r.inReplyToRelayId || "");
return (thread && ids.has(thread)) || (reply && ids.has(reply));
}
// A self-send is staged in the local inbox so the conversation can retain
// its complete message, but it is still an outbound message for this viewer.
// Every unread projection must share this rule: otherwise the raw staged
// packet inflates the tab badge while the conversation correctly shows no
// unread row.
function relayIsSelfAuthored(row, sentTwin = null, viewerEmail = "") {
const mine = String(viewerEmail || "").trim().toLowerCase();
const sender = String(row?.senderEmail || "").trim().toLowerCase();
return Boolean(sentTwin || (mine && sender === mine));
}
// A read receipt describes the state of the CONVERSATION, not every message
// that happened to be read. Exactly one row can own it: the newest outbound
// item in the visible room, whether that item is a Relay or a quick text. A
// newer optimistic item deliberately owns the slot while it says Sending…,
// suppressing the stale receipt below instead of making Seen jump backward.
function receiptFor(m, msgs) {
return RelayReadReceipts.forLatest(m, msgs, timeAgo);
}
// The rungs BELOW the read receipt: this device has it, and this device is
// waiting. Said in the same whisper, because a message on its way is not an
// event — only a message that will not go is.
//
// A retry is deliberately neutral: a timeout, throttled server, or dropped
// socket is not evidence that the person's Wi-Fi is disconnected.
function outboxStatusBits(m) {
const state = String(m.outboxState || "");
if (state === "failed") {
return [
`<span class="th-seen warn">Not sent</span>`,
`<button type="button" class="th-seen act" data-outbox-retry="${esc(m.outboxId)}" data-stop="1">Retry</button>`,
`<button type="button" class="th-seen act" data-outbox-discard="${esc(m.outboxId)}" data-stop="1">Delete</button>`,
];
}
if (state === "queued" && Number(m.outboxAttempts || 0) >= 1) {
return [`<span class="th-seen th-settling">Trying again…</span>`];
}
return [`<span class="th-seen th-settling">Sending…</span>`];
}
function receiptTimestamp(value) {
const date = new Date(value || "");
if (!Number.isFinite(date.getTime())) return "Seen";
return date.toLocaleString([], { dateStyle:"medium", timeStyle:"short" });
}
function receiptDetailsHtml(receipt) {
if (!receipt || !receipt.expandable) return "";
const seen = receipt.readers.map((member) =>
`<div class="th-receipt-row"><span>${esc(member.name)}</span><span class="th-receipt-when">${esc(member.readAt ? receiptTimestamp(member.readAt) : "Seen")}</span></div>`
).join("");
const unread = receipt.unread.map((member) =>
`<div class="th-receipt-row"><span>${esc(member.name)}</span><span class="th-receipt-when">Not seen</span></div>`
).join("");
return `<div class="th-receipt-panel">${seen}${unread}</div>`;
}
function threadMessages() {
const { includeSlack = false } = arguments[0] || {};
const msgs = [];
const reqThreads = requestThreadIds();
// A note-to-self is projected on both inbox and sent surfaces. Keep the
// inbox copy because it carries the complete locally materialized message,
// but use its sent twin as viewer-relative truth: the author still sent
// this message, even though sender and recipient are the same account.
const sentByRelayId = new Map((payload.sent || [])
.map((item) => [String(item.relayId || item.id || ""), item])
.filter(([id]) => Boolean(id)));
const viewerEmail = String(payload.account?.email || "").trim().toLowerCase();
for (const r of payload.relays || []) {
// The REQUEST ROOT is correspondence: the sender delivered a named job
// to this person, so it must be visible in their room like any other
// relay. Progress stays on the Tasks page; the terminal completion Relay
// also returns to this room with both of its documents intact.
const request = isTaskRow(r);
if (!request && !isRelayListKind(r)) continue;
const sentTwin = sentByRelayId.get(String(r.id || r.relayId || ""));
// Sent history hydrates asynchronously on launch. The signed-in address
// makes the first paint correct too; the twin enriches it with receipts
// once that cache arrives.
const selfAuthored = relayIsSelfAuthored(r, sentTwin, viewerEmail);
const subj = relaySubject(r);
const ownedAgent = r.source?.host === "relay-agent-run";
const completion = isCompletionRelay(r);
const textLike = request ? false : ownedAgent || relayTextLike(r.forHuman, subj, r.forAgent);
// Non-completion execution output stays on the Task reading surface.
if (!completion && !request && onRequestThread(r, reqThreads) && !textLike) continue;
msgs.push({
id: r.id, threadId: r.threadId || r.id, direction: selfAuthored ? "out" : "in",
seen: sentTwin ? sentIsRead(sentTwin) : false,
seenAt: sentTwin?.readAt || null,
delivered: sentTwin ? sentIsDelivered(sentTwin) : false,
readReceipts: sentTwin
? [{ name:sentRecipient(sentTwin), seen:sentIsRead(sentTwin), readAt:sentTwin.readAt || "" }]
: [],
inReplyToRelayId: r.inReplyToRelayId || "",
addressRecipient: r.recipientGroupId ? { groupId:r.recipientGroupId } : (r.senderEmail ? { email:r.senderEmail } : null),
title: textLike ? String(r.forHuman || subj) : subj, party: relaySender(r), at: r.createdAt || r.updatedAt,
partyKey: r.recipientGroupName
? `group:${String(r.recipientGroupName).trim().toLowerCase()}`
: (r.senderEmail ? `email:${String(r.senderEmail).trim().toLowerCase()}` : `name:${String(relaySender(r)).trim().toLowerCase()}`),
// A saved-group delivery belongs to that exact group room even when
// only one member has spoken so far. Person and group identities must
// never collapse into one row merely because the latest sender matches.
isGroup: Boolean(r.recipientGroupName),
groupId: r.recipientGroupId || "",
groupName: r.recipientGroupName || "",
unread: selfAuthored ? false : Boolean(r.unread),
source: r.source || null,
ownedAgent,
updatedAt: r.updatedAt || r.createdAt,
editedAt: r.editedAt || null,
deletedAt: r.deletedAt || null,
textLike, request,
groupSendId: r.groupSendId || "",
taskStartedAt: r.taskStartedAt || null,
taskCompletedAt: r.taskCompletedAt || null,
taskClaim: r.taskClaim || null,
materializedCodex: Boolean(r.materializedCodex),
materializedClaude: Boolean(r.materializedClaude),
e2ee: Boolean(r.e2ee),
body: r.forHuman || "",
agent: String(r.forAgent || ""),
reactions: r.reactions || { aggregates: [], events: [] },
preview: textLike ? "" : bodyPreview(r.forHuman || "", subj, 72),
});
}
// A self-send exists on BOTH sides with the same relay id; the inbox copy
// above is richer, but has already inherited outbound direction and sent
// receipts from its twin. Skip the second copy so the room shows one bubble.
const inboundIds = new Set(msgs.map((m) => m.id));
// A group fan-out is one logical message written once per member; keep a
// single outbound copy per groupSendId or the transcript shows doubles.
const seenGroupSends = new Set();
const groupReceiptMembers = new Map();
// A fan-out is delivered when EVERY sibling is — the same rule the two
// ticks follow in any group chat. The collapse below keeps one sibling, so
// asking that survivor alone would call a room delivered on the strength of
// whichever member happened to be sorted first.
const groupDelivered = new Map();
for (const member of payload.sent || []) {
if (!member.groupSendId) continue;
const bucket = groupReceiptMembers.get(member.groupSendId) || [];
bucket.push({ name:sentRecipient(member), seen:sentIsRead(member), readAt:member.readAt || "" });
groupReceiptMembers.set(member.groupSendId, bucket);
const soFar = groupDelivered.get(member.groupSendId);
groupDelivered.set(member.groupSendId, soFar === false ? false : sentIsDelivered(member));
}
for (const s of payload.sent || []) {
const id = s.relayId || s.id;
if (!id || inboundIds.has(id)) continue;
const request = String(s.kind || "") === "task" || String(s.kind || "") === "task_request";
const ownedAgent = s.source?.host === "relay-agent-run";
const sentTextLike = request ? false : ownedAgent || relayTextLike(s.forHuman, sentSubject(s), s.forAgent);
if (s.groupSendId) {
if (seenGroupSends.has(s.groupSendId)) continue;
seenGroupSends.add(s.groupSendId);
}
msgs.push({
id, threadId: s.threadId || id, direction: "out", seen: sentIsRead(s), seenAt: s.readAt || null,
delivered: s.groupSendId ? groupDelivered.get(s.groupSendId) === true : sentIsDelivered(s),
inReplyToRelayId: s.inReplyToRelayId || "",
// A share-link id is a room address, not a posting gate. The server
// recognises its synthetic mailbox only for owned-agent invocations and
// never attempts to deliver email to it. Keeping that address here lets
// @my_codex / @my_claude use the room without making the earlier link's
// Opened state relevant to the new message.
addressRecipient: s.recipientGroupId
? { groupId:s.recipientGroupId }
: (s.shareLink && s.shareLink.state !== "claimed")
? { email:`${String(s.shareLink.id)}@guests.sendrelays.com` }
: (s.recipient || null),
shareLink: s.shareLink || null,
readReceipts: s.groupSendId
? (groupReceiptMembers.get(s.groupSendId) || [])
: [{ name:sentRecipient(s), seen:sentIsRead(s), readAt:s.readAt || "" }],
title: sentTextLike ? String(s.forHuman || sentSubject(s)) : sentSubject(s), party: s.recipientGroupName || sentRecipient(s), at: s.createdAt,
partyKey: s.groupSendId || s.recipientGroupName
? `group:${String(s.recipientGroupName || s.threadId || id).trim().toLowerCase()}`
// Each unclaimed link is its OWN room. Once the server omits the guest
// address this key would be the literal "email:", which stablePartyKey
// accepts as stable, and chatSections would merge every share relay in
// the account into one room titled after whichever link is newest.
: (s.shareLink && s.shareLink.state !== "claimed")
? `share:${String(s.shareLink.id)}`
: `email:${String((s.recipient && s.recipient.email) || "").trim().toLowerCase()}`,
isGroup: Boolean(s.groupSendId || s.recipientGroupName),
groupId: s.recipientGroupId || "",
groupName: s.recipientGroupName || "",
unread: false,
source: s.source || null,
ownedAgent,
updatedAt: s.updatedAt || s.createdAt,
editedAt: s.editedAt || null,
deletedAt: s.deletedAt || null,
// YOUR OWN RELAYS ARE READABLE TOO. This hardcoded `textLike: true`
// with no body and no preview, and the bubble gates the chevron, the
// gist and the attachment chips on exactly that flag — so a reader
// could open everyone's relays but never their own, even ones their
// agent had sent under their name with files attached (Sven, live).
// The rule is the same on both sides: human-only correspondence is a
// text; a human message plus an agent document is a Relay.
textLike: sentTextLike,
request,
groupSendId: s.groupSendId || "",
taskStartedAt: s.taskStartedAt || null,
taskCompletedAt: s.taskCompletedAt || null,
taskClaim: s.taskClaim || null,
materializedCodex: Boolean(s.materializedCodex),
materializedClaude: Boolean(s.materializedClaude),
body: s.forHuman || "",
agent: String(s.forAgent || ""),
reactions: s.reactions || { aggregates: [], events: [] },
preview: (() => {
const body = String(s.forHuman || "");
const subj = String(sentSubject(s) || "");
if (!body || !subj) return "";
return sentTextLike ? "" : bodyPreview(body, subj, 72);
})(),
});
}
// Slack-backed rooms use the normalized conversation/message model.
// Project the detail currently loaded for each linked room into the Pill's
// established message shape; this extends the deployed chat UI instead of
// inventing a second Slack inbox.
for (const chat of canonicalChatDetails.values()) {
const integration = chat?.integration || (chat?.channel?.slack
? { provider:"slack", ...chat.channel.slack }
: null);
if (!chat || integration?.provider !== "slack") continue;
const integrationType = String(integration.type || "");
const isGroup = chat.kind === "group" || integrationType === "mpim"
|| integrationType === "public_channel" || integrationType === "private_channel";
const roomName = chat.channel?.name || chat.title || (isGroup ? "Channel" : "Slack conversation");
for (const item of chat.items || []) {
const itemIsSlack = item.origin === "slack" || item.provider?.name === "slack";
if (!includeSlack && itemIsSlack) continue;
const id = String(item.relayId || "");
if (!id || msgs.some((message) => String(message.id || "") === id)) continue;
const mine = item.direction === "outbound";
const party = mine ? "You" : String(item.sender?.name || "Slack member");
const body = String(item.forHuman || "");
const agent = String(item.forAgent || "");
// A file or an agent document makes this a Relay, not a quick Slack
// text. It must open the deployed two-document reader, where the
// shared attachment shelf belongs to the whole handoff.
const textLike = !(item.attachments || []).length && relayTextLike(body, item.title || "", agent);
msgs.push({
id,
relayId: id,
chatId: chat.chatId,
threadId: item.threadId || id,
inReplyToRelayId: item.inReplyToRelayId || "",
direction: mine ? "out" : "in",
addressRecipient: { chatId:chat.chatId },
title: textLike ? (body || item.title || "Message") : String(item.title || "Relay"),
party,
partyKey: `chat-id:${chat.chatId}`,
at: item.createdAt || item.updatedAt,
isGroup,
groupId: isGroup ? chat.chatId : "",
groupName: isGroup ? roomName : "",
unread: !mine && (item.state === "pending" || item.state === "delivered"),
source: null,
integration,
// The conversation is Slack-backed, but the message may have been
// authored in Relay and mirrored out. "From Slack" describes the
// message's origin, never merely the room's transport.
provider: item.provider || (item.origin === "slack" ? { name:"slack" } : null),
origin: item.origin || "slack",
updatedAt: item.updatedAt || item.createdAt,
slackThreadContext: item.slackThreadContext || null,
editedAt: item.editedAt || null,
deletedAt: item.deletedAt || null,
textLike,
request: false,
body,
agent,
reactions: item.reactions || { aggregates:[], events:[] },
attachments: item.attachments || [],
preview: textLike ? "" : bodyPreview(body, item.title || "", 72),
});
}
}
// A direct conversation resolved from People can precede both the inbox
// payload and the lazy Sent cache. Project that canonical snapshot into
// the same message list immediately; local rows above win by relay id once
// they arrive, so there is never a duplicate or a surface change.
for (const anchor of contactChatAnchors.values()) {
for (const message of anchor.msgs || []) {
if (msgs.some((row) => String(row.id || "") === String(message.id || ""))) continue;
msgs.push(message);
}
}
const canonicalIds = new Set(msgs.map((message) => String(message.id || "")));
for (const [key, optimistic] of optimisticChatReplies) {
if (optimistic.agentInvocation) {
const matched = msgs.some((message) => message.ownedAgent
&& message.source?.surface === optimistic.source?.surface
&& new Date(message.at || 0).getTime() >= new Date(optimistic.at || 0).getTime() - 60_000);
if (matched) { optimisticChatReplies.delete(key); continue; }
}
if (optimistic.relayId && canonicalIds.has(String(optimistic.relayId))) {
optimisticChatReplies.delete(key);
continue;
}
// A group reply fans out to one sibling per member and the send response
// names fanout[0] — the first-inserted, OLDEST sibling — while the
// newest-first collapse above kept the newest one. The ids can never
// match for a room with two or more other people, so the id check alone
// left this bubble doubling beside its canonical row until restart
// (Sven, 2026-08-18). The groupSendId is shared by every sibling: once
// ANY canonical copy of this send is in the transcript, the optimistic
// row has been superseded.
if (optimistic.groupSendId && seenGroupSends.has(String(optimistic.groupSendId))) {
optimisticChatReplies.delete(key);
continue;
}
msgs.push(optimistic);
}
return msgs;
}
function buildThreads() {
const options = arguments[0] || {};
const byThread = new Map();
for (const m of threadMessages(options)) {
const t = byThread.get(m.threadId) || { threadId: m.threadId, msgs: [] };
t.msgs.push(m);
byThread.set(m.threadId, t);
}
const threads = [...byThread.values()];
for (const t of threads) {
t.msgs.sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
const latest = t.msgs[t.msgs.length - 1];
t.latest = latest;
t.unreadCount = t.msgs.filter((m) => m.unread).length;
// The thread's display party is the other side: prefer the latest
// inbound sender, else the latest outbound recipient.
const lastIn = [...t.msgs].reverse().find((m) => m.direction === "in" && !m.ownedAgent);
const counterpart = lastIn || [...t.msgs].reverse().find((m) => !m.ownedAgent) || latest;
// An owned agent is a speaking face inside the existing room, never a
// new participant. Name and key the room from its human counterpart.
t.party = counterpart.party || "Conversation";
t.partyKey = counterpart.partyKey || `name:${String(t.party).trim().toLowerCase()}`;
t.isGroup = t.msgs.some((m) => m.isGroup);
t.groupId = (t.msgs.find((m) => m.groupId) || {}).groupId || "";
t.groupName = (t.msgs.find((m) => m.groupName) || {}).groupName || "";
t.chatId = (t.msgs.find((m) => m.chatId) || {}).chatId || "";
t.integration = (t.msgs.find((m) => m.integration) || {}).integration || null;
t.provider = t.integration?.provider
|| (t.msgs.find((m) => m.provider?.name === "slack") || {}).provider?.name
|| "";
// Message titles remain message gists. They never name the room; people
// and saved groups are the only visible conversation identities.
t.title = latest.title || "";
}
threads.sort((a, b) => new Date(b.latest.at || 0) - new Date(a.latest.at || 0));
return threads;
}
// ---- the Chat tab: one activity-sorted rail of people and saved groups.
// Opening any conversation springs the pill into the expanded frame (the
// list is the glance surface; the room earns the frame).
function chatConversations(options = {}) {
const threads = buildThreads(options);
for (const t of threads) {
const peopleByIdentity = new Map();
for (const m of t.msgs.filter((m) => m.direction === "in" && !m.ownedAgent && m.party)) {
peopleByIdentity.set(m.partyKey || `name:${String(m.party).trim().toLowerCase()}`, m.party);
}
const people = [...peopleByIdentity.values()];
t.people = people.length ? people : [t.party];
// Saved group identity is authoritative even before a second member
// speaks; otherwise a group Relay briefly masquerades as a direct chat.
t.isGroup = t.isGroup || people.length > 1;
// The address rules (§2.13): a named group by its name; one person by
// full name; two by first names; more by two first names +n — never a
// wall of names, at any roster size.
const firsts = t.people.map((pp) => String(pp).split(" ")[0]);
const setLabel = t.people.length === 1 ? t.people[0]
: t.people.length === 2 ? `${firsts[0]} & ${firsts[1]}`
: `${firsts[0]}, ${firsts[1]} +${t.people.length - 2}`;
t.name = t.isGroup ? (t.groupName || setLabel) : t.people[0];
t.setLabel = setLabel;
}
return threads;
}
function normalizedPartyName(value) {
return String(value || "").trim().toLowerCase();
}
function stablePartyKey(value) {
const key = String(value || "");
return key && !key.startsWith("name:") ? key : "";
}
function sameDirectParty(message, roomKey, roomName) {
const messageKey = String((message && message.partyKey) || "");
if (messageKey && roomKey && messageKey === roomKey) return true;
// Rows staged before senderEmail existed have only a display-name key.
// Bridge those into a stable room only by the same displayed person. If
// both rows have distinct stable keys, the names may collide but the
// contacts do not — never merge them.
if (stablePartyKey(messageKey) && stablePartyKey(roomKey)) return false;
return Boolean(roomName && normalizedPartyName(message && message.party) === roomName);
}
function sortConversationRooms(rooms) {
return [...rooms].sort((a, b) => {
// Message activity owns the chronology. A saved room with no messages is
// still a room, but it follows active conversations; creation/update time
// orders only that quiet tail. Name is the final stable tie-breaker.
const aActive = a.hasActivity !== false;
const bActive = b.hasActivity !== false;
if (aActive !== bActive) return aActive ? -1 : 1;
const byTime = new Date((b.latest && b.latest.at) || 0) - new Date((a.latest && a.latest.at) || 0);
return byTime || String(a.name || "").localeCompare(String(b.name || ""));
});
}
function directContactRoomAnchor(chat, contact, email, resolvedRecipient) {
const address = String(email || "").trim().toLowerCase();
const name = String((contact && contact.name) || chat?.title || address || "Conversation");
const partyKey = `email:${address}`;
const recipient = resolvedRecipient && resolvedRecipient.email
? { email:String(resolvedRecipient.email), name:String(resolvedRecipient.name || name) }
: { email:address, name };
const items = Array.isArray(chat?.items) ? chat.items : [];
const msgs = items.map((item) => {
const id = String(item?.relayId || "");
const mine = item?.direction === "outbound";
const body = String(item?.forHuman || "");
const agent = String(item?.forAgent || "");
const title = String(item?.title || "");
const attachments = Array.isArray(item?.attachments) ? item.attachments : [];
const textLike = !attachments.length && relayTextLike(body, title, agent);
return {
id,
relayId:id,
chatId:String(chat.chatId),
threadId:String(item?.threadId || id || `direct-chat:${chat.chatId}`),
inReplyToRelayId:String(item?.inReplyToRelayId || ""),
direction:mine ? "out" : "in",
seen:mine && (item?.readReceipts || []).some((receipt) => receipt && receipt.seen),
seenAt:mine ? ((item?.readReceipts || []).find((receipt) => receipt && receipt.readAt)?.readAt || null) : null,
delivered:mine && !new Set(["pending", "failed"]).has(String(item?.state || "")),
readReceipts:Array.isArray(item?.readReceipts) ? item.readReceipts : [],
addressRecipient:recipient,
title:textLike ? (body || title || "Message") : (title || "Relay"),
party:mine ? "You" : String(item?.sender?.name || name),
partyKey,
at:item?.createdAt || item?.updatedAt || chat.updatedAt,
isGroup:false,
groupId:"",
groupName:"",
unread:!mine && (item?.state === "pending" || item?.state === "delivered"),
source:item?.source || null,
provider:item?.provider || null,
origin:item?.origin || "",
updatedAt:item?.updatedAt || item?.createdAt,
editedAt:item?.editedAt || null,
deletedAt:item?.deletedAt || null,
textLike,
request:false,
body,
agent,
reactions:item?.reactions || { aggregates:[], events:[] },
attachments,
preview:textLike ? "" : bodyPreview(body, title, 72),
materializedCodex:Boolean(item?.materializedCodex),
materializedClaude:Boolean(item?.materializedClaude),
};
}).filter((message) => message.id)
.sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
const threadIds = new Set([
...(Array.isArray(chat?.threadIds) ? chat.threadIds : []),
...msgs.map((message) => message.threadId),
].filter(Boolean).map(String));
const latest = msgs[msgs.length - 1] || null;
const threadId = `direct-chat:${chat.chatId}`;
return {
threadId,
threadIds,
msgs,
latest:latest || { id:threadId, at:chat?.updatedAt || "1970-01-01T00:00:00.000Z", title:"No messages yet", direction:"", party:"" },
unreadCount:Number(chat?.unreadCount || msgs.filter((message) => message.unread).length),
party:name,
partyKey,
isGroup:false,
groupId:"",
groupName:"",
name,
people:[name],
setLabel:name,
hasActivity:Boolean(latest),
chatId:String(chat.chatId),
messageCount:Number(chat?.messageCount || msgs.length),
addressRecipient:recipient,
};
}
function mergeDirectContactAnchor(existing, anchor) {
if (!existing) return { ...anchor, threadIds:new Set(anchor.threadIds), msgs:[...anchor.msgs] };
const byId = new Map();
for (const message of anchor.msgs || []) byId.set(String(message.id), message);
// The local inbox/Sent projection carries the latest delivery/read state;
// let it enrich the canonical snapshot returned by the resolve call.
for (const message of existing.msgs || []) {
const id = String(message.id || "");
byId.set(id, byId.has(id) ? { ...byId.get(id), ...message } : message);
}
const msgs = [...byId.values()].sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
const latest = msgs[msgs.length - 1] || existing.latest || anchor.latest;
return {
...existing,
...anchor,
// The chat coordinate stays stable while the first send moves from the
// local outbox into a server thread. Real thread ids are aliases; they
// must not strand the transcript that is already open.
threadId:anchor.threadId,
threadIds:new Set([
anchor.threadId,
...(anchor.threadIds || []),
existing.threadId,
...(existing.threadIds || []),
].filter(Boolean)),
msgs,
latest,
unreadCount:Math.max(Number(anchor.unreadCount || 0), Number(existing.unreadCount || 0)),
hasActivity:Boolean(msgs.length || existing.hasActivity !== false),
};
}
function directContactAnchorForChatId(chatId) {
const id = String(chatId || "");
return contactChatAnchors.get(id) || null;
}
function slackIntegrationForChat(chat) {
if (chat?.integration?.provider === "slack") return chat.integration;
return chat?.channel?.slack ? { provider:"slack", ...chat.channel.slack } : null;
}
function roomFromChatSummary(chat) {
const integration = slackIntegrationForChat(chat);
if (!chat?.chatId || !integration) return null;
const type = String(integration.type || "");
const isGroup = chat.kind === "group" || type === "mpim"
|| type === "public_channel" || type === "private_channel";
const others = (chat.participants || [])
.filter((person) => !person.self)
.map((person) => String(person.name || "").trim())
.filter(Boolean);
const people = others.length ? others : (chat.participants || []).map((person) => person.name).filter(Boolean);
const name = String(chat.channel?.name || chat.title || people.join(", ") || (isGroup ? "Channel" : "Slack conversation"));
const last = chat.lastMessage;
const threadId = (chat.threadIds || [])[0] || `slack-chat:${chat.chatId}`;
const cached = canonicalChatDetails.get(String(chat.chatId));
// A list summary already carries the newest visible message. On a cold
// room, use that local truth for the compositor's destination snapshot
// instead of navigating into an empty transcript while the selected room
// hydrates. The canonical detail replaces this one preview after motion.
const openingMessages = !cached && last ? [{
id:last.relayId,
relayId:last.relayId,
chatId:chat.chatId,
threadId,
inReplyToRelayId:"",
direction:last.direction === "outbound" ? "out" : "in",
addressRecipient:{ chatId:chat.chatId },
title:last.title || last.preview || "Message",
party:last.direction === "outbound" ? "You" : (last.senderName || "Slack member"),
partyKey:`chat-id:${chat.chatId}`,
at:last.createdAt,
isGroup,
groupId:isGroup ? chat.chatId : "",
groupName:isGroup ? name : "",
unread:last.direction !== "outbound" && Number(chat.unreadCount || 0) > 0,
source:null,
integration,
provider:null,
origin:"",
editedAt:null,
deletedAt:null,
textLike:!last.title,
request:false,
body:last.preview || "",
agent:"",
reactions:{ aggregates:[], events:[] },
attachments:[],
preview:last.title ? (last.preview || "") : "",
openingSummary:true,
}] : [];
return {
threadId,
threadIds: new Set(chat.threadIds || []),
msgs: openingMessages,
latest: last ? {
id:last.relayId, at:last.createdAt, title:last.title || last.preview || "Message",
direction:last.direction === "outbound" ? "out" : "in", party:last.senderName || "",
} : { id:threadId, at:chat.updatedAt, title:"No messages yet", direction:"", party:"" },
unreadCount: Number(chat.unreadCount || 0),
party:name,
partyKey:`chat-id:${chat.chatId}`,
isGroup,
groupId:isGroup ? chat.chatId : "",
groupName:isGroup ? name : "",
name,
people:people.length ? people : [name],
setLabel:type === "im" ? "Slack message" : type === "mpim" ? "Slack group message" : "Slack channel",
hasActivity:Boolean(last),
chatId:chat.chatId,
messageCount:Number(chat.messageCount || 0),
summaryUpdatedAt:chat.updatedAt || "",
summaryLastMessageId:last?.relayId || "",
provider:"slack",
integration,
channel:chat.channel || null,
};
}
function chatSections() {
const surface = arguments[0] || (activeView === "threads" ? conversationSurface() : "relay");
if (surface === "slack") {
const rooms = sortConversationRooms((payload.slackChats || []).map(roomFromChatSummary).filter(Boolean));
return {
convos:rooms,
groups:rooms.filter((room) => room.isGroup),
people:rooms.filter((room) => !room.isGroup),
rooms,
};
}
const convos = chatConversations();
const rawGroups = convos.filter((t) => t.isGroup);
// The iMessage rule: a person is ONE conversation. Threads are the wire
// shape; the Chat list merges every 1:1 with the same party into one row
// (newest exchange carries it) until the /v1/chats read model is wired.
const direct = convos.filter((x) => !x.isGroup);
const stableKeysByName = new Map();
for (const t of direct) {
const stable = stablePartyKey(t.partyKey);
if (!stable) continue;
const name = normalizedPartyName(t.name);
const keys = stableKeysByName.get(name) || new Set();
keys.add(stable);
stableKeysByName.set(name, keys);
}
const byParty = new Map();
for (const t of direct) {
const name = normalizedPartyName(t.name);
const candidates = stableKeysByName.get(name);
// A legacy name-only row may adopt a stable identity only when that name
// resolves unambiguously. Two current contacts named Alex stay separate.
const legacyBridge = !stablePartyKey(t.partyKey) && candidates && candidates.size === 1
? [...candidates][0]
: "";
const key = stablePartyKey(t.partyKey) || legacyBridge || t.partyKey || `name:${name}`;
const prev = byParty.get(key);
if (!prev) {
byParty.set(key, { ...t, threadIds: new Set([t.threadId]) });
continue;
}
prev.threadIds.add(t.threadId);
prev.unreadCount += t.unreadCount;
if (new Date(t.latest.at || 0) > new Date(prev.latest.at || 0)) { prev.latest = t.latest; prev.threadId = t.threadId; }
}
let people = [...byParty.values()].sort((a, b) => new Date(b.latest.at || 0) - new Date(a.latest.at || 0));
// A saved group is one visible room even when its replies span several
// internal reply-chain ids. Merge by the stable group name and retain the
// member thread ids only as invisible lookup keys for the room transcript.
const byGroup = new Map();
for (const t of rawGroups) {
const key = t.groupId ? `group-id:${t.groupId}` : `group:${normalizedPartyName(t.groupName || t.name)}`;
const prev = byGroup.get(key);
if (!prev) {
byGroup.set(key, { ...t, hasActivity: true, threadIds: new Set([t.threadId]) });
continue;
}
prev.threadIds.add(t.threadId);
prev.unreadCount += t.unreadCount;
if (new Date(t.latest.at || 0) > new Date(prev.latest.at || 0)) {
prev.latest = t.latest;
prev.threadId = t.threadId;
}
for (const person of t.people || []) if (!prev.people.includes(person)) prev.people.push(person);
if (!prev.groupId && t.groupId) prev.groupId = t.groupId;
if (!prev.chatId && t.chatId) prev.chatId = t.chatId;
if (!prev.provider && t.provider) prev.provider = t.provider;
}
// Relay summaries carry exact Slack integration bindings without carrying
// Slack-only activity. Join by canonical chat/thread ids only—never by a
// person's name—so a Relay-only conversation cannot enter the Slack tab.
for (const chat of payload.chats || []) {
const projected = roomFromChatSummary(chat);
if (!projected) continue;
const summaryThreadIds = projected.threadIds;
const existing = [...byGroup.values(), ...byParty.values()].find((room) =>
room.chatId === projected.chatId
|| room.groupId === projected.chatId
|| [...(room.threadIds || [])].some((id) => summaryThreadIds.has(id))
|| summaryThreadIds.has(room.threadId));
if (existing) {
existing.chatId = projected.chatId;
existing.integration = projected.integration;
existing.provider = "slack";
existing.channel = projected.channel;
for (const id of summaryThreadIds) existing.threadIds.add(id);
if (new Date(projected.latest.at || 0) > new Date(existing.latest.at || 0)) {
existing.latest = projected.latest;
existing.threadId = projected.threadId;
}
existing.unreadCount = Math.max(existing.unreadCount, projected.unreadCount);
continue;
}
if (projected.isGroup) byGroup.set(`chat-id:${projected.chatId}`, projected);
else byParty.set(`chat-id:${projected.chatId}`, projected);
}
people = sortConversationRooms([...byParty.values()]);
// A direct chat resolved from People is a real main-pill conversation even
// before Sent hydration or its first message. Merge it by stable address so
// later local rows replace the temporary anchor without changing surfaces.
for (const anchor of contactChatAnchors.values()) {
const key = stablePartyKey(anchor.partyKey) || anchor.partyKey;
const existing = byParty.get(key) || [...byParty.values()].find((room) =>
(anchor.chatId && room.chatId === anchor.chatId)
|| room.partyKey === anchor.partyKey);
byParty.set(key, mergeDirectContactAnchor(existing, anchor));
}
people = sortConversationRooms([...byParty.values()]);
// Message history is not the registry of rooms. Every saved group belongs
// in the conversation rail even before its first message; otherwise a new
// group opens in a detached legacy window and then vanishes from the rail.
for (const group of groupsList) {
if (!group || !group.id) continue;
const key = `group-id:${group.id}`;
const roster = groupInfoRoster(group);
const people = roster
.map((person) => String(person.name || person.email || "").trim())
.filter(Boolean);
const existing = byGroup.get(key) || [...byGroup.values()].find((room) =>
!room.groupId && normalizedPartyName(room.groupName || room.name) === normalizedPartyName(group.name));
if (existing) {
existing.groupId = String(group.id);
existing.groupName = group.name || existing.groupName;
existing.name = group.name || existing.name;
for (const person of people) if (!existing.people.includes(person)) existing.people.push(person);
continue;
}
const quietAt = group.updatedAt || group.createdAt || "1970-01-01T00:00:00.000Z";
byGroup.set(key, {
threadId: `group-room:${group.id}`,
threadIds: new Set(),
msgs: [],
latest: { at:quietAt, title:"No messages yet", direction:"", party:"" },
unreadCount: 0,
party: group.name || "Channel",
partyKey: key,
isGroup: true,
groupId: String(group.id),
groupName: group.name || "Channel",
name: group.name || "Channel",
people,
setLabel: groupMemberSummary(roster),
hasActivity: false,
});
}
const groups = sortConversationRooms([...byGroup.values()]);
// WhatsApp/iMessage law: group and direct conversations are peers. The
// visible Chat list is one chronology, never two type-based shelves.
const rooms = sortConversationRooms([...groups, ...people]);
return { convos, groups, people, rooms };
}
function chatRoomForThread(threadId, surface = conversationSurface()) {
const { rooms } = chatSections(surface);
const listed = rooms.find((room) =>
room.threadId === threadId || room.chatId === threadId
|| (room.threadIds instanceof Set && room.threadIds.has(threadId)),
);
if (listed) return listed;
// A full-payload refresh can temporarily replace the list summary while an
// already-open canonical room still has valid detail. Since Slack rooms are
// now opened by chatId, the detail cache is sufficient to keep that room
// addressable and to repaint it without falling into the legacy Sent loader.
const cached = canonicalChatDetails.get(String(threadId || ""));
const projected = cached ? roomFromChatSummary(cached) : null;
if (!projected) return null;
if (surface === "slack" || isSlackIntegratedRoom(projected)) return projected;
return null;
}
// Messages that arrive while their Chat room is already on screen have been
// read. A direct room spans every wire thread with that person; a group room
// keeps its shared thread boundary. This is also the room identity used by
// the expanded header, rather than any topic title carried by a relay.
function visibleChatRoomMessages() {
if (!fullCardIsOnStage()) return [];
if (activeView !== "threads" || !isConversationRoomSource() || !threadDetailId) return [];
const surface = conversationSurface();
const room = chatRoomForThread(threadDetailId, surface);
const roomName = String(threadDetailPartyHint || (room && room.name) || "").trim().toLowerCase();
const roomKey = room && room.partyKey;
return threadMessages({ includeSlack:slackMessagesVisible(room) }).filter((m) => {
if (m.direction !== "in") return false;
if (room && room.isGroup) {
const ids = room.threadIds instanceof Set ? room.threadIds : new Set([room.threadId]);
return ids.has(m.threadId);
}
return roomKey || roomName
? sameDirectParty(m, roomKey, roomName)
: m.threadId === threadDetailId;
});
}
function persistReadIds(ids, { afterPaint = false } = {}) {
const uniqueIds = [...new Set((ids || []).map(String).filter(Boolean))];
if (!uniqueIds.length) return;
for (const id of uniqueIds) optimisticReadIds.add(id);
const persist = () => {
Promise.resolve(window.relay.ackMany?.(uniqueIds))
.then((result) => {
if (!result || result.ok !== true) throw new Error("read persistence failed");
for (const id of uniqueIds) optimisticReadIds.delete(id);
})
.catch(() => {
const failedIds = new Set(uniqueIds);
for (const id of uniqueIds) {
visibleRoomAckIds.delete(id);
optimisticReadIds.delete(id);
}
// Persistence failed, so the local projection must stop claiming the
// row is durably read. Main also forces a canonical payload; this
// immediate rollback keeps Back/list state honest in the meantime.
for (const row of payload.relays || []) {
if (!failedIds.has(String(row.id || ""))) continue;
row.unread = true;
row.state = "unread";
}
if (!readerMorphSnapshot) renderAll();
});
};
// rAF callbacks run before their own frame is presented. Two frames put
// storage IPC after the destination's first committed pixels instead of
// racing the transition coordinator inside that same frame.
if (afterPaint) requestAnimationFrame(() => requestAnimationFrame(persist));
else persist();
}
function persistCanonicalChatRead(chatId, options) {
// Keep the older preload shape usable during rolling desktop updates while
// current callers always qualify the surface.
if (!options) return window.relay.canonicalChatRead?.(chatId);
return window.relay.canonicalChatRead?.(chatId, options);
}
function readVisibleChatRoom() {
const surface = conversationSurface();
const visibleRoom = threadDetailId ? chatRoomForThread(threadDetailId, surface) : null;
// Hidden Slack is not visible and therefore is never read. Relay-origin
// canonical rows remain visible and use the Relay surface cursor; a reveal
// adds Slack rows to that read, while the Slack tab consumes its own cursor.
const resolvedDirectAnchor = visibleRoom?.chatId
? directContactAnchorForChatId(visibleRoom.chatId)
: null;
if ((isSlackIntegratedRoom(visibleRoom) || resolvedDirectAnchor) && visibleRoom.chatId) {
const chatId = String(visibleRoom.chatId);
const chat = canonicalChatDetails.get(chatId);
const includeSlack = slackMessagesVisible(visibleRoom);
const readOptions = { surface, includeSlack:slackMessagesVisible(visibleRoom) };
const unreadCanonical = (chat?.items || []).filter((item) =>
(includeSlack || !(item.origin === "slack" || item.provider?.name === "slack"))
&& item.direction === "inbound" && (item.state === "pending" || item.state === "delivered"));
if (unreadCanonical.length) {
const generation = String(unreadCanonical.at(-1)?.relayId || "");
const generationKey = `${surface}:${chatId}`;
const failureKey = `${generationKey}:${generation}`;
const failure = canonicalReadFailures.get(failureKey);
const retryReady = !failure || Date.now() >= Number(failure.retryAfter || 0);
const unseenGeneration = canonicalReadGeneration.get(generationKey) !== generation && retryReady;
if (generation && unseenGeneration) {
canonicalReadGeneration.set(generationKey, generation);
// The channel is visibly on screen, so its local projection becomes
// read before either network round trip. The canonical endpoint
// persists the cursor for this surface; only Slack mirrors to Slack.
const opened = {
...chat,
unreadCount: 0,
items: (chat.items || []).map((item) => (includeSlack || !(item.origin === "slack" || item.provider?.name === "slack"))
&& item.direction === "inbound"
&& (item.state === "pending" || item.state === "delivered")
? { ...item, state:"read" }
: item),
};
canonicalChatDetails.set(chatId, opened);
if (resolvedDirectAnchor) {
resolvedDirectAnchor.unreadCount = 0;
resolvedDirectAnchor.msgs = (resolvedDirectAnchor.msgs || []).map((message) =>
message.direction === "in" ? { ...message, unread:false } : message);
}
const projectionKey = canonicalChatDetailProjectionKeys.get(chatId);
if (projectionKey) canonicalChatFingerprints.set(projectionKey, canonicalChatFingerprint(opened));
if (surface === "slack") {
payload.slackChats = (payload.slackChats || []).map((summary) => String(summary.chatId) === chatId
? { ...summary, unreadCount:0 }
: summary);
} else {
payload.chats = (payload.chats || []).map((summary) => String(summary.chatId) === chatId
? { ...summary, unreadCount:0 }
: summary);
}
Promise.resolve(includeSlack
? persistCanonicalChatRead(chatId, { surface, includeSlack:true })
: persistCanonicalChatRead(chatId, readOptions))
.then((result) => {
if (!result || result.ok === false) throw new Error(result?.error || "read persistence failed");
canonicalReadFailures.delete(failureKey);
})
.catch(() => {
if (canonicalReadGeneration.get(generationKey) === generation) canonicalReadGeneration.delete(generationKey);
const projectionKey = canonicalChatDetailProjectionKeys.get(chatId);
if (projectionKey) canonicalChatFingerprints.delete(projectionKey);
const failures = Number(canonicalReadFailures.get(failureKey)?.attempts || 0) + 1;
const retryImmediately = failures === 1;
canonicalReadFailures.set(failureKey, {
attempts:failures,
retryAfter:retryImmediately ? 0 : Date.now() + 5000,
});
// One immediate retry heals a transient failure. A persistent
// outage then restores server truth without recursively posting
// forever; the normal poll or later activity retries after the
// short cooldown.
refreshActiveCanonicalChat({ readVisible:retryImmediately });
});
}
}
}
// Slack's read cursor is independent from the Relay inbox cursor. Reading
// a mirrored Relay inside Slack must not clear it from the Relays surface.
if (surface === "slack") return;
const unreadRaw = new Map(
(payload.relays || []).filter((r) => r.unread).map((r) => [String(r.id), r]),
);
for (const id of [...visibleRoomAckIds]) {
if (!unreadRaw.has(id)) visibleRoomAckIds.delete(id);
}
const ackIds = [];
for (const m of visibleChatRoomMessages()) {
if (!m.unread) continue;
const id = String(m.id);
// Canonical Slack messages use the conversation cursor above. ackMany is
// the legacy per-delivery path and cannot make these messages durable.
if (!id.startsWith("relay_")) continue;
// Paint the already-visible arrival as read immediately; the ack makes
// that state durable. A stale polling payload is cleared the same way
// until the daemon returns the acknowledged row.
const raw = unreadRaw.get(id);
if (raw) raw.unread = false;
if (visibleRoomAckIds.has(id)) continue;
visibleRoomAckIds.add(id);
ackIds.push(id);
}
persistReadIds(ackIds);
}
// Open a conversation INTO the expanded frame (the list is the glance
// surface; the room earns the frame — David's law).
let chatListScrollTop = 0; // where the reader was in the list before opening a room
let relaysListScrollTop = 0; // same contract for the Relays identity index
let slackListScrollTop = 0; // Slack is a peer room index with its own return point
let contactsListScrollTop = 0; // and for People, now that a row opens a room
function openRoom(threadId, partyHint, source = null, options = {}) {
const roomSource = source || (activeView === "threads" ? threadsSource : activeView);
const surface = conversationSurface(roomSource);
if (activeView === "chat") {
try { chatListScrollTop = scrollEl ? scrollEl.scrollTop : 0; } catch { chatListScrollTop = 0; }
}
if (activeView === "slack") {
try { slackListScrollTop = scrollEl ? scrollEl.scrollTop : 0; } catch { slackListScrollTop = 0; }
}
const selected = chatRoomForThread(threadId, surface);
const roomCoordinate = isSlackIntegratedRoom(selected) && selected.chatId
? String(selected.chatId)
: threadId;
// If this tap came from the split rail, retain that room's deliberate rail
// position before threadDetailId changes to the destination room.
rememberChatRailScroll();
const morphFromList = prepareReaderMorph(activeView);
openThreadDetail(roomCoordinate, partyHint || "", roomSource, {
expanded:options.expanded !== false,
hydrated:options.hydrated === true,
});
if (morphFromList) startReaderMorph("threads");
if (isSlackIntegratedRoom(selected) && selected.chatId) {
readVisibleChatRoom();
}
}
function renderChat() {
const { rooms } = chatSections();
chatEmptyEl.classList.toggle("gone", rooms.length > 0);
const row = (t) => `
<button class="th-item${t.unreadCount ? " unread" : ""}" type="button" data-chat-open="${esc(t.threadId)}" data-party="${esc(t.name)}">
${t.isGroup
? `<span class="av-stack">${t.people.slice(0, 2).map((pp, i) => `<span class="avatar sm${i === 0 ? " sq" : ""}" style="--av-h:${avatarHue(pp)}" aria-hidden="true">${esc(avatarInitials(pp))}</span>`).join("")}</span>`
: `<span class="avatar" style="--av-h:${avatarHue(t.name)}" aria-hidden="true">${esc(avatarInitials(t.name))}</span>`}
<span class="rk-body">
<span class="th-top"><span class="th-party">${esc(t.name)}</span><span class="th-time">${t.hasActivity === false ? "" : esc(timeAgo(t.latest.at))}</span></span>
<span class="rk-line">
<span class="th-title">${t.latest.direction === "out" ? `<span class="gist-who">You:</span> ` : (t.isGroup ? `<span class="gist-who">${esc(String(t.latest.party || "").split(" ")[0])}:</span> ` : "")}${esc(t.latest.title)}</span>
${t.unreadCount ? `<span class="th-count">${t.unreadCount}</span>` : ""}
</span>
</span>
</button>`;
chatListEl.innerHTML =
rooms.map(row).join("") +
`<button class="people-row" type="button" id="peopleRow">
<span class="pd">${(contactsList.slice(0, 2).map((c) => `<span class="avatar" style="--av-h:${avatarHue(c.name || c.email)}" aria-hidden="true">${esc(avatarInitials(c.name || c.email))}</span>`).join("")) || '<span class="avatar" aria-hidden="true">·</span>'}</span>
<span class="lab">Your people</span>
<span class="n">${contactsList.length || ""}</span><span class="chev">›</span>
</button>`;
// The "Your people" discs need the contact book; load it lazily once.
if (!contactsList.length && !renderChat._loading) {
renderChat._loading = true;
loadContacts().then(() => { renderChat._loading = false; if (activeView === "chat") renderChat(); });
}
if (!groupsLoaded && !groupsLoading) {
groupsLoading = Promise.resolve(loadGroups()).finally(() => {
groupsLoading = null;
if (activeView === "chat") renderChat();
else if (activeView === "threads" && threadDetailId) renderThreadDetail();
});
}
for (const el of chatListEl.querySelectorAll("[data-chat-open]")) {
el.addEventListener("click", () => openRoom(el.getAttribute("data-chat-open"), el.getAttribute("data-party")));
}
// A room scrolls itself to the newest message; that scroll belongs to the
// ROOM, not to the list. Coming back landed the reader at the bottom of the
// conversation list every time (David, live).
requestAnimationFrame(() => {
if (activeView === "chat" && scrollEl) scrollEl.scrollTop = chatListScrollTop || 0;
});
const pr = chatListEl.querySelector("#peopleRow");
if (pr) pr.addEventListener("click", () => { activeView = "contacts"; commitNavigation(); });
}
function renderSlack() {
if (!slackConnectionReady(slackConnectionInfo)) {
slackEmptyEl.classList.add("gone");
const connection = slackConnectionInfo;
const team = connection?.team;
const teamConnected = team?.connected === true;
const teamName = team?.name || "your team";
const checking = !slackConnectionLoaded;
const connectLabel = checking
? "Checking Slack…"
: slackConnectionWaiting
? "Waiting for Slack…"
: connection?.state === "paused"
? "Reconnect Slack"
: "Connect Slack";
const account = payload.account || {};
slackListEl.innerHTML = `<div class="slack-connect" data-slack-connect-state="${checking ? "loading" : "disconnected"}">
<p class="slack-connect-kicker">Slack integration</p>
<h1 class="slack-connect-title">Connect your Slack to Relay.</h1>
<p class="slack-connect-copy">${teamConnected
? "Your team already uses Relay in Slack. Connect the Slack conversations you can access."
: "Connect Relay to your team and sync the Slack conversations you can access in one approval."}</p>
<div class="slack-connect-card">
${teamConnected ? "" : `<div class="slack-connect-row">
<span class="slack-connect-icon"><img src="slackMark.png" alt="" /></span>
<span class="slack-connect-row-copy"><span class="slack-connect-row-name">Relay for ${esc(teamName)}</span><span class="slack-connect-row-note">Deliver to teammates and channels</span></span>
</div>`}
<div class="slack-connect-row">
<span class="slack-connect-avatar" aria-hidden="true">${esc(cvInitials(account.name, account.email))}</span>
<span class="slack-connect-row-copy"><span class="slack-connect-row-name">Your Slack</span><span class="slack-connect-row-note">Sync conversations you can access and keep Relays in place</span></span>
</div>
</div>
<button class="sv-connect slack-connect-action" type="button" id="slackTabConnect" ${checking || slackConnectionBusy || slackConnectionWaiting ? "disabled" : ""}>${esc(connectLabel)}</button>
${slackConnectionError ? `<div class="row-err" role="status">${esc(slackConnectionError)}</div>` : ""}
<p class="slack-connect-foot">Sync starts when you connect. Relay does not import earlier Slack history.</p>
</div>`;
document.getElementById("slackTabConnect")?.addEventListener("click", connectSlackFromSurface);
return;
}
const { rooms } = chatSections("slack");
slackEmptyEl.classList.toggle("gone", rooms.length > 0);
const nextHtml = rooms.map((room) => relayIdentityRowHtml({
...room,
latestAt:room.latestAt || room.latest?.at || "",
})).join("");
if (slackListEl.innerHTML === nextHtml) return;
slackListEl.innerHTML = nextHtml;
for (const el of slackListEl.querySelectorAll(".relay-arrival")) {
el.addEventListener("click", () => {
// Slack is a peer compact room index, so its first visual transaction
// is the exact same compositor-owned pixel transition as Relays. Room
// freshness is reconciled after the captured destination has settled.
startRoomViewTransition(() => {
openThreadDetail(
el.getAttribute("data-thread"),
el.getAttribute("data-party") || "",
"slack",
{ expanded:false },
);
}, { motion:"forward" });
});
}
requestAnimationFrame(() => {
if (activeView === "slack" && scrollEl) scrollEl.scrollTop = slackListScrollTop || 0;
});
}
// ---- the split's rail: every conversation beside the open room ----
const chatRailScrollByRoom = new Map();
let chatRailRenderedRoomKey = "";
function chatRailRoomKey(threadId = threadDetailId) {
const room = chatRoomForThread(threadId, conversationSurface());
if (!room) return String(threadId || "");
if (room.isGroup) return `group:${normalizedPartyName(room.groupName || room.name)}`;
return stablePartyKey(room.partyKey) || `person:${normalizedPartyName(room.name)}`;
}
function setChatRailScrollTop(railEl, savedScroll) {
const parsed = Number(savedScroll);
const target = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
railEl.scrollTop = target;
return target;
}
function rememberChatRailScroll() {
const railEl = document.getElementById("chatRail");
if (!railEl || !chatRailRenderedRoomKey || !chatExpanded) return;
chatRailScrollByRoom.set(chatRailRenderedRoomKey, Math.max(0, Number(railEl.scrollTop) || 0));
}
function positionChatRail(railEl, roomKey) {
// A room that has never been visited always starts at the newest
// conversation. Returning to a room restores only an explicit rail scroll.
const hasSavedPosition = chatRailScrollByRoom.has(roomKey);
const target = setChatRailScrollTop(railEl, chatRailScrollByRoom.get(roomKey));
requestAnimationFrame(() => {
if (activeView !== "threads" || !chatExpanded || chatRailRoomKey() !== roomKey) return;
if (hasSavedPosition) setChatRailScrollTop(railEl, target);
else railEl.querySelector(".rl-row.on")?.scrollIntoView({ block:"nearest" });
});
}
function renderChatRail() {
const railEl = document.getElementById("chatRail");
if (!chatExpanded || !threadDetailId) {
railEl.innerHTML = "";
chatRailRenderedRoomKey = "";
return;
}
const surface = conversationSurface();
const { rooms } = chatSections();
const roomKey = chatRailRoomKey();
const roomIsOpen = (t) => t.threadId === threadDetailId || t.chatId === threadDetailId
|| (t.threadIds instanceof Set && t.threadIds.has(threadDetailId));
const row = (t) => `
<button class="rl-row${roomIsOpen(t) ? " on" : ""}" type="button" data-rail-open="${esc(t.threadId)}" data-party="${esc(t.name)}">
<span class="avatar${t.isGroup ? " sq" : ""}" style="--av-h:${avatarHue(t.isGroup ? (t.title || t.name) : t.name)}" aria-hidden="true">${esc(avatarInitials(t.name))}</span>
<span class="nm">${esc(t.name)}</span>
${t.unreadCount && !roomIsOpen(t) ? `<span class="ct">${t.unreadCount}</span>` : ""}
</button>`;
const peopleFooter = surface === "slack" ? "" : `<button class="people-row" type="button" data-rail-people>
<span class="pd">${contactsList.slice(0, 2).map((c) => `<span class="avatar" style="--av-h:${avatarHue(c.name || c.email)}" aria-hidden="true">${esc(avatarInitials(c.name || c.email))}</span>`).join("")}</span>
<span class="lab">Your people</span>
<span class="n">${contactsList.length || ""}</span><span class="chev">›</span>
</button>`;
const nextHtml = rooms.map(row).join("") + peopleFooter;
// Keeping an unchanged rail intact avoids manufacturing a new browser
// scroll anchor on every inbox poll. When content really changes, restore
// the room's explicit rail position immediately after the replacement.
if (railEl.innerHTML !== nextHtml) {
railEl.innerHTML = nextHtml;
for (const el of railEl.querySelectorAll("[data-rail-open]")) {
el.addEventListener("click", () => openRoom(
el.getAttribute("data-rail-open"),
el.getAttribute("data-party"),
threadsSource,
));
}
const pp = railEl.querySelector("[data-rail-people]");
if (pp) pp.addEventListener("click", () => {
rememberChatRailScroll();
chatExpanded = false; syncExpandButton();
activeView = "contacts"; commitNavigation();
});
}
chatRailRenderedRoomKey = roomKey;
positionChatRail(railEl, roomKey);
}
document.getElementById("chatRail").addEventListener("scroll", () => {
if (!chatExpanded || !chatRailRenderedRoomKey) return;
const railEl = document.getElementById("chatRail");
chatRailScrollByRoom.set(chatRailRenderedRoomKey, Math.max(0, Number(railEl.scrollTop) || 0));
}, { passive: true });
function renderThreads() {
const showingDetail = Boolean(threadDetailId);
thDetailEl.classList.toggle("gone", !showingDetail);
renderChatRail();
threadsListEl.classList.toggle("gone", showingDetail);
if (showingDetail) return renderThreadDetail();
// A detail-less internal route returns to the index that opened it.
if (threadsSource === "slack") activeView = "slack";
else if (threadsSource === "relays") activeView = "relays";
else activeView = "chat";
syncTabs();
if (activeView === "slack") renderSlack();
else if (activeView === "relays") renderRelays();
else renderChat();
}
// Stable per-party color: same person, same color, every conversation and
// every restart (a hash, never an index into render order).
// Everyone in a conversation wears a DIFFERENT color (David's law): inside
// an open thread, colors assign by first appearance — guaranteed distinct
// up to the palette. Outside a thread (reader kicker etc.) the stable
// name-hash keeps a person's hue consistent across surfaces.
let threadPalette = new Map(); // party(lowercased) -> ordinal, rebuilt per open thread
function partyColorClass(name, mine) {
if (mine) return "me";
const key = String(name || "").trim().toLowerCase();
if (threadPalette.has(key)) return `p${threadPalette.get(key) % 8}`;
let h = 0;
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
return `p${h % 8}`;
}
// ONE ROW, THE APP YOU CHOSE (Sven, 2026-08-17, on David's two-row footer:
// "should probably detect which desktop apps you have then only suggest
// those, or even in settings you choose which one"). The row names the app
// Settings picked (which detection defaults to the app this Mac has); the
// other app is one tap away in Settings. David's subline stays: per relay,
// from real state — this pill has already put THIS relay into a task/
// session there, or it has not.
function relayHostActionsHtml(message, { persistent = false } = {}) {
const id = String(message && message.id || "");
const actionsId = `relay-host-actions-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
const source = message && message.direction === "out" ? "sent" : "relay";
return `${persistent ? "" : `<button class="th-host-disclosure" type="button" data-host-disclosure="${esc(id)}" data-stop="1" aria-expanded="false" aria-controls="${esc(actionsId)}"><span>Open with an agent</span><span class="chev" aria-hidden="true">⌄</span></button>`}<div id="${esc(actionsId)}" class="th-host-actions${persistent ? " persistent" : ""}" data-host-actions="${esc(id)}" data-stop="1" aria-hidden="${persistent ? "false" : "true"}"${persistent ? "" : " inert"}>
<div class="th-host-actions-inner">
${agentAppHosts().map((host) => hostActionRowHtml(host, message, source)).join("")}
</div>
</div>`;
}
function hostActionRowHtml(host, message, source) {
const id = String(message && message.id || "");
const codex = host === "codex";
const context = codex
? (message?.materializedCodex ? "Continue in its existing task" : "Start a new task with this Relay")
: (message?.materializedClaude ? "Continue in its existing session" : "Start a new session with this Relay");
return `<button class="th-host-action" type="button" data-host-open="${esc(id)}" data-host="${codex ? "codex" : "claude"}" data-source="${source}">
<img class="th-host-logo" src="${codex ? "codexMark.svg" : "claudeCodeMark.svg"}" alt="" />
<span class="th-host-copy"><span class="th-host-label">Open in ${codex ? "Codex" : "Claude Code"}</span><span class="th-host-context">${esc(context)}</span></span>
<span class="th-host-arrow" aria-hidden="true">→</span>
</button>`;
}
// The row's click, wherever the row is painted (a room bubble, the reader).
function wireHostOpen(scope) {
for (const b of scope.querySelectorAll("[data-host-open]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
const id = b.getAttribute("data-host-open");
const host = b.getAttribute("data-host") === "claude" ? "claude" : "codex";
const source = b.getAttribute("data-source") === "sent" ? "sent" : "relay";
const app = host === "codex" ? "Codex" : "Claude Code";
// The open clears any earlier note first (a stale failure must not
// outlive the retry), so the answer to THIS click is written after it —
// written before, it vanished the same instant (found on the clone).
openRelayFromUI(id, source, "open", host);
setOpeningNote(id, `Opening in ${app}…`);
});
}
}
// Before 0.1.290, inReplyToRelayId doubled as a default reply-chain key: the
// room composer anchored every ordinary message to the newest one even when
// the human picked no target. Suppressing adjacent parents kept that legacy
// history from looking like a chain of selected quotes. Since 0.1.290,
// ordinary room sends are addressed to the room and only the reader or the
// explicit reply button sets inReplyToRelayId. Those gestures must keep their
// visible reference even when the selected Relay is immediately above.
function defaultReplyAnchorMap(msgs) {
const ordered = [...(msgs || [])].sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
const byId = new Map();
for (let i = 0; i < ordered.length; i += 1) {
const anchors = new Set();
for (let j = i - 1; j >= 0; j -= 1) {
const candidate = ordered[j];
if (j === i - 1) anchors.add(String(candidate.id || ""));
if (!candidate.request) { anchors.add(String(candidate.id || "")); break; }
}
byId.set(String(ordered[i].id || ""), anchors);
}
return byId;
}
function versionAtLeast(raw, minimum) {
const value = String(raw || "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!value) return false;
const actual = value.slice(1).map(Number);
const expected = String(minimum || "").split(".").slice(0, 3).map(Number);
while (expected.length < 3) expected.push(0);
for (let i = 0; i < 3; i += 1) {
if (actual[i] !== expected[i]) return actual[i] > expected[i];
}
return true;
}
function messageHasExplicitReplyGesture(message) {
if (message?.explicitReply === true) return true;
const source = message?.source;
return source?.host === "relay-preview" && versionAtLeast(source.clientVersion, "0.1.290");
}
// ---- the thread reply composer: a REAL send ----------------------------
// Files stage as chips (picker or paste); Send creates a relay through the
// device-token path (empty recipient + inReplyToRelayId = the reply rule);
function applySentMessageMutation(result, patch = {}) {
const ids = new Set((result?.affectedRelayIds?.length ? result.affectedRelayIds : [result?.relayId])
.map((id) => String(id || ""))
.filter(Boolean));
if (!ids.size) return;
const update = (item) => {
const id = String(item?.relayId || item?.id || "");
if (!ids.has(id)) return;
if (patch.deleted) {
item.deletedAt = result.deletedAt || result.updatedAt || new Date().toISOString();
item.title = "Message deleted";
item.displayTitle = "Message deleted";
item.forHuman = "This message was deleted.";
item.forAgent = "";
item.preview = "This message was deleted.";
item.attachments = [];
item.hasAttachments = false;
} else if (patch.forHuman !== undefined) {
item.forHuman = patch.forHuman;
item.preview = patch.forHuman;
item.editedAt = result.editedAt || result.updatedAt || new Date().toISOString();
}
item.updatedAt = result.updatedAt || item.updatedAt;
};
for (const item of payload.relays || []) update(item);
for (const item of payload.sent || []) update(item);
for (const chat of canonicalChatDetails.values()) {
for (const item of chat?.items || []) update(item);
}
}
function renderThreadDetail() {
// A ROOM IS A PERSON (the iMessage rule the Chat list already follows): every
// exchange with them, merged. The room was rendering ONE thread, so opening
// Shane showed two messages instead of the history (David, live). Opened
// from Relays, a single relay's thread is still the unit.
const surface = conversationSurface();
const chatRoom = isConversationRoomSource()
? chatRoomForThread(threadDetailId)
: null;
const threadMessageOptions = { includeSlack:slackMessagesVisible(chatRoom) };
if (chatRoom && chatRoom.isGroup && !groupsLoaded && !groupsLoading) {
groupsLoading = Promise.resolve(loadGroups()).finally(() => {
groupsLoading = null;
if (activeView === "threads" && threadDetailId) renderThreadDetail();
});
}
const groupPostingState = groupRoomPostingState(chatRoom);
const groupPostingBlocked = groupPostingState.blocked;
let thread = chatRoom || buildThreads(threadMessageOptions).find((t) => t.threadId === threadDetailId);
if (thread && isConversationRoomSource() && !(chatRoom && chatRoom.isGroup)) {
const party = String(threadDetailPartyHint || thread.party || "").trim().toLowerCase();
const partyKey = (chatRoom && chatRoom.partyKey) || thread.partyKey;
if (partyKey || party) {
const msgs = threadMessages(threadMessageOptions)
// Canonical Slack rows use their exact chat id as identity, while a
// locally staged Relay in the same linked room may still use the
// person's email. Either exact room coordinate belongs here.
.filter((m) => (chatRoom?.chatId && m.chatId === chatRoom.chatId)
|| sameDirectParty(m, partyKey, party))
.sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
if (msgs.length > thread.msgs.length) thread = { ...thread, msgs };
}
} else if (thread && isConversationRoomSource() && chatRoom && chatRoom.isGroup) {
const ids = chatRoom.threadIds instanceof Set ? chatRoom.threadIds : new Set([chatRoom.threadId]);
const msgs = threadMessages(threadMessageOptions)
.filter((m) => ids.has(m.threadId))
.sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
if (msgs.length) thread = { ...thread, msgs };
}
if (!thread) {
if (threadsSource === "slack" && threadDetailChatId) {
setThreadHeader(threadDetailPartyHint, "");
if (threadDetailLoadError) {
thHistoryEl.innerHTML = `<div class="cv-empty" role="status">${esc(threadDetailLoadError)}<button class="cv-empty-retry" id="thConversationRetry" type="button">Try again</button></div>`;
document.getElementById("thConversationRetry")?.addEventListener("click", () => {
threadDetailLoadError = "";
renderThreadDetail();
recoverActiveSlackRoom({ force:true });
});
} else {
thHistoryEl.innerHTML = '<div class="cv-empty" role="status">Loading conversation…</div>';
recoverActiveSlackRoom();
}
return;
}
// A conversation whose messages live on the SENT side renders empty until
// the sent cache loads — and since 0.1.73 that refresh is lazy (30s when
// idle). Ask for it now and show a loading line rather than a blank pane;
// the next payload re-renders this view with the messages.
setThreadHeader(threadDetailPartyHint, "");
thHistoryEl.innerHTML = '<div class="cv-empty">Loading conversation…</div>';
if (window.relay.refreshSent) window.relay.refreshSent().catch(() => {});
return;
}
const slackChannelAllMessages = chatRoom?.provider === "slack" ? [...thread.msgs] : [];
const slackThreadGroups = new Map();
for (const message of slackChannelAllMessages) {
// A Slack thread is defined by an explicit parent. Relay's generic
// `threadId` also has compatibility meanings for ordinary message
// chains and optimistic rows, so using it alone made adjacent top-level
// channel messages masquerade as replies.
const key = String(
message.inReplyToRelayId
|| (message.slackThreadContext ? message.threadId : "")
|| message.id
|| "",
);
const group = slackThreadGroups.get(key) || [];
group.push(message);
slackThreadGroups.set(key, group);
}
if (slackChannelAllMessages.length) {
if (slackFocusedThreadId && !slackThreadGroups.has(slackFocusedThreadId)) slackFocusedThreadId = "";
thread = {
...thread,
msgs: slackFocusedThreadId
? [...(slackThreadGroups.get(slackFocusedThreadId) || [])]
// Slack child replies live behind their root's thread affordance.
// A root from before Relay connected has no canonical parent, so its
// group is represented by a context stub below rather than leaking
// the first synchronized reply into the main channel timeline.
: slackChannelAllMessages.filter((message) => !message.inReplyToRelayId && !message.slackThreadContext),
};
}
// A group conversation is named by its voices, newest first — "Shane &
// David" — not by whoever happened to speak last.
const inboundNames = [];
for (const m of [...thread.msgs].reverse()) {
if (m.direction === "in" && m.party && !inboundNames.includes(m.party)) inboundNames.push(m.party);
}
const voices = inboundNames.length > 1
? (inboundNames.length === 2 ? inboundNames.join(" & ") : inboundNames.slice(0, 2).join(", ") + " +" + (inboundNames.length - 2))
: thread.party;
// A Chat header names only the person or saved group. Reply-chain ids and
// historical thread titles are transport metadata, never visible nouns.
const roomName = threadDetailPartyHint || (chatRoom && chatRoom.name) || voices;
// A visible room can contain many internal reply-chain ids. State keyed to
// `thread.threadId` jumped whenever a newer Relay belonged to another
// chain, losing drafts and releasing the duplicate-send gate mid-flight.
const threadStateKey = `${isConversationRoomSource() ? String(threadDetailId) : String(thread.threadId)}${slackFocusedThreadId ? `|slack-thread:${slackFocusedThreadId}` : ""}`;
setThreadHeader(roomName || voices, "");
if (slackFocusedThreadId) thDetailMetaEl.textContent = "Thread";
// No thread-level note div: every bubble carries its own row-err since the
// block redesign, so a second copy rendered the same note twice (Sven's
// double "Copied."). The old zero-feedback bug stays covered — every
// action sets its note on a message id, and that message's bubble shows it.
// ORDER IS A LIVE SETTING now that the composer exists. Sven's round-4 rule
// — "bottom-anchored is right when an input sits at the bottom" — argues for
// chat order (oldest→newest, composer at the bottom) as the default; the
// old inbox order (newest first, composer on top) stays one toggle away in
// Settings → Prototype, so the flip is revocable by feel, not by revert.
const chatShaped = chatOrder() === "chat";
const msgs = chatShaped ? [...thread.msgs] : [...thread.msgs].reverse();
// Reactions are messages in Relay's ontology. Interleave their durable add
// events by timestamp, while the aggregate remains attached to its target.
const chronological = [];
for (const message of thread.msgs) {
chronological.push({ kind:"message", at:message.at, message });
for (const event of (!REACTIONS_ENABLED || message.request) ? [] : ((message.reactions && message.reactions.events) || [])) {
if (event.action === "add") chronological.push({ kind:"reaction", at:event.at, event });
}
}
chronological.sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
const timeline = chatShaped ? chronological : chronological.reverse();
threadPalette = new Map();
for (const m of thread.msgs) {
if (m.direction === "out") continue;
const k = String(m.party || "").trim().toLowerCase();
if (k && !threadPalette.has(k)) threadPalette.set(k, threadPalette.size);
}
const threadDraft = threadComposerDrafts.get(threadStateKey) || "";
const messageById = new Map(thread.msgs.map((message) => [String(message.id || ""), message]));
const focusedSlackParent = slackFocusedThreadId
? thread.msgs.find((message) => String(message.id || "") === slackFocusedThreadId) || thread.msgs[0] || null
: null;
const addressAnchor = [...(thread.msgs || [])].reverse().find((message) => {
const recipient = message.addressRecipient;
return recipient && (recipient.email || recipient.contactId || recipient.relayUserId || recipient.groupId || recipient.chatId);
});
const replyTargetId = String(threadReplyTargets.get(threadStateKey) || "");
const replyTarget = replyTargetId ? messageById.get(replyTargetId) : null;
const replyPreviewAuthor = (message) => message && message.direction === "out" ? "You" : String((message && message.party) || "Someone");
const replyPreviewCopy = (message) => relayListGist(String((message && (message.title || message.body)) || "Message").replace(/\s+/g, " "), 120);
// The surface IS the byline. Nothing else is consulted: a closed set of
// known apps in, a name out, silence for anything else. It used to also
// demand source.host === "relay-mcp", which is a transport detail and one
// of at least fifteen host strings in live data — so relays that DID carry
// a correct surface (host "claude-code-direct") still rendered nothing.
// Unknown values fall through to silence by construction, which is what
// keeps a local staging stamp like surface "relay_companion" off the wire.
// A Map, not an object literal: the key is whatever the sender put on the
// wire, and a literal answers `surface: "constructor"` (or toString,
// valueOf, __proto__) with an inherited function — truthy, so the footer
// renders "Sent with undefined" beside a broken image. A Map has no
// prototype chain to walk, so the closed set stays closed by construction.
const PROVIDER_BYLINE = new Map([
["codex", { mark: "codexMark.svg", label: "Codex" }],
["claude_code", { mark: "claudeCodeMark.svg", label: "Claude Code" }],
["claude_desktop", { mark: "claudeCodeMark.svg", label: "Claude Desktop" }],
["claude_cowork", { mark: "claudeCodeMark.svg", label: "Claude Cowork" }],
]);
const providerBylineHtml = (message) => {
if (message?.deletedAt) return "";
if (message?.origin === "slack" || message?.provider?.name === "slack") {
return `<div class="th-provider-byline"><img src="slackMark.png" alt="" />From Slack</div>`;
}
const provider = PROVIDER_BYLINE.get(String(message?.source?.surface || ""));
if (!provider) return "";
return `<div class="th-provider-byline"><img src="${provider.mark}" alt="" />Sent with ${esc(provider.label)}</div>`;
};
const defaultReplyAnchorIds = defaultReplyAnchorMap(thread.msgs);
const renderedSlackThreadContexts = new Set();
const slackThreadContextHtml = (message) => {
const context = message?.slackThreadContext;
const key = String(message?.threadId || message?.id || "");
if (!context || renderedSlackThreadContexts.has(key)) return "";
renderedSlackThreadContexts.add(key);
const earlier = context.state === "earlier";
return `<div class="th-slack-thread-context" data-stop="1">
<div class="top"><img src="slackMark.png" alt="" /><span>${earlier ? "Earlier Slack thread" : "Linking Slack thread"}</span></div>
<div class="copy">${earlier ? "Started before Relay was connected." : "Still linking this reply to its Slack thread…"}</div>
<button type="button" data-slack-thread-open="${esc(context.slackUrl)}">Open in Slack</button>
</div>`;
};
const slackThreadReplyCount = (message) => {
if (!slackChannelAllMessages.length) return 0;
const rootId = String(message?.id || "");
const rows = slackThreadGroups.get(rootId) || [];
// Count only rows that positively name this root. A sibling that happens
// to share an opaque Relay thread key is not a Slack reply.
return rows.filter((row) => String(row.inReplyToRelayId || "") === rootId).length;
};
const slackThreadLinkHtml = (message) => {
if (slackFocusedThreadId) return "";
const count = slackThreadReplyCount(message);
if (!count) return "";
return `<button type="button" class="th-thread-link" data-slack-thread-focus="${esc(message.threadId || message.id)}" data-stop="1"><img src="slackMark.png" alt="" /><span>${count} ${count === 1 ? "reply" : "replies"}</span></button>`;
};
const missingSlackThreadStubsHtml = !slackChannelAllMessages.length || slackFocusedThreadId
? ""
: [...slackThreadGroups.entries()].map(([key, rows]) => {
const first = rows.find((row) => row.slackThreadContext);
if (!first) return "";
const count = rows.length;
return `${slackThreadContextHtml(first)}<button type="button" class="th-thread-link" data-slack-thread-focus="${esc(key)}" data-stop="1"><img src="slackMark.png" alt="" /><span>${count} ${count === 1 ? "reply" : "replies"}</span></button>`;
}).join("");
const messageReplyReferenceHtml = (message) => {
const parentId = String((message && message.inReplyToRelayId) || "");
if (!parentId) return "";
const defaults = defaultReplyAnchorIds.get(String((message && message.id) || ""));
if (!messageHasExplicitReplyGesture(message) && defaults && defaults.has(parentId)) return "";
const parent = messageById.get(parentId);
// Outside the loaded room, we cannot tell a deliberate reply from the
// default anchor, and the default is overwhelmingly the case — so a
// placeholder here is noise on old messages, not context. Say nothing.
if (!parent) return "";
return `<button type="button" class="th-reply-ref" data-reply-ref="${esc(parentId)}" data-stop="1"><span class="th-reply-ref-who">${esc(replyPreviewAuthor(parent))}</span><span class="th-reply-ref-copy">${esc(replyPreviewCopy(parent))}</span></button>`;
};
const composerReplyTargetHtml = replyTargetId
? replyTarget
? `<div class="th-reply-target"><div class="th-reply-target-who">Replying to ${esc(replyPreviewAuthor(replyTarget))}</div><div class="th-reply-target-copy">${esc(replyPreviewCopy(replyTarget))}</div><button type="button" class="th-reply-target-close" data-reply-cancel="1" aria-label="Cancel specific reply">×</button></div>`
: `<div class="th-reply-target missing"><div class="th-reply-target-who">Replying to an unavailable Relay</div><div class="th-reply-target-copy">The original is no longer in this room.</div><button type="button" class="th-reply-target-close" data-reply-cancel="1" aria-label="Cancel specific reply">×</button></div>`
: "";
// Human chat composes only a human reply. Agent hand-offs belong to the
// exact Relay rows above, where Codex and Claude Code are both explicit.
const threadComposer = `<div class="th-composer-dock${chatShaped ? " chat-shaped" : ""}"><div class="qr th-qr col${replyTargetId ? " replying" : ""}">
${composerReplyTargetHtml}
<div class="th-mention-menu hidden" id="thMentionMenu" role="listbox"></div>
<div id="thQrInput" class="th-rich-composer" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Reply" data-placeholder="Reply…"></div>
<div class="ta-rail">
<span class="rt-spacer"></span>
<button type="button" id="thQrSend"${chatReplySending.has(threadStateKey) ? " disabled" : ""}>Send</button>
</div>
</div></div>`;
const visibleThreadComposer = groupPostingBlocked
? `<div class="th-room-status">${esc(groupPostingState.reason)}</div>`
: threadComposer;
// Own messages are labelled "You" — the pill is a personal surface, only
// ever read by its owner, so the account name carries no information here
// (Slack shows your name because its rooms are shared; this one is not).
// Sven's call, round 5.
const me = "You";
const latestHostActionRelayId = String((
[...thread.msgs].reverse().find((message) => !message.textLike && !message.request) || {}
).id || "");
let previousChunkMessage = null;
const rowsHtml = timeline.map((entry, i) => {
if (entry.kind === "reaction") {
const event = entry.event;
const label = event.actor && event.actor.self ? "You" : ((event.actor && event.actor.name) || "Someone");
return `<div class="rx-line" data-rx-event="${esc(event.id)}"><span class="th-party ${partyColorClass(label, label === "You")}">${esc(label)}</span> reacted <span class="emoji">${esc(event.emoji)}</span></div>`;
}
const m = entry.message;
// Direction decides the side. Every outbound Relay approaches from the
// right, including work sent by this person's owned agent; every inbound
// Relay stays on the left. `ownedAgent` may change the truthful byline to
// "My Codex" / "My Claude", but it cannot make an outbound message look
// as though the other person sent it.
const mine = m.direction === "out";
const ownedAgentName = m.direction === "out"
? (m.source?.surface === "codex" ? "My Codex" : "My Claude")
: m.party;
const who = m.ownedAgent ? ownedAgentName : mine ? me : m.party;
// Consecutive messages from one sender group into a run (the Slack
// shape): the row above (newest of the run) carries the header, the
// continuation renders title-only underneath.
const prevEntry = timeline[i - 1];
const prev = prevEntry && prevEntry.kind === "message" ? prevEntry.message : null;
const chunkDateLabel = chatChunkDateLabel(m.at, previousChunkMessage && previousChunkMessage.at);
const firstChunk = !previousChunkMessage;
previousChunkMessage = m;
// Identity creates the room and time creates its sections. A dated chunk
// starts a new run; internal reply chains and Task/Relay/text presentation
// never add seams or repeat the same speaker inside that chunk.
const cont = continuesSenderRun(prev, m, chunkDateLabel);
// A quick TEXT (title derived from its own body) speaks in the body
// voice — sans, message-toned; a titled relay keeps the serif subject.
const textLike = m.textLike;
const agentWorking = m.ownedAgent
&& !String(m.body || "").trim()
&& !String(m.agent || "").trim()
&& !m.deletedAt;
const editingMessage = messageEditDrafts.has(m.id);
const editDraft = editingMessage ? messageEditDrafts.get(m.id) : "";
const hasHostActions = !textLike && !m.request;
const chunkDivider = chunkDateLabel
? `<div class="th-chunk-date${firstChunk ? " first" : ""}" role="separator"><span>${esc(chunkDateLabel)}</span></div>`
: "";
const runHeader = cont ? "" : `
<div class="th-run${mine ? " mine" : ""}"><span class="th-party ${partyColorClass(who, mine)}">${esc(who)}</span>${m.e2ee ? `<span class="echip" title="Only the sender's and recipient's devices can read this message">Encrypted</span>` : ""}</div>`;
const open = expandedMsgIds.has(m.id);
const inlineBody = !textLike && !m.request && !open
&& !!m.body && String(m.body).length <= INLINE_BODY_MAX;
const aggregates = (!REACTIONS_ENABLED || m.request) ? [] : ((m.reactions && m.reactions.aggregates) || []);
const badges = aggregates.length ? `<div class="rx-badges" data-stop="1">${aggregates.map((reaction) =>
`<button type="button" class="rx-badge${reaction.reactedByMe ? " mine" : ""}" data-rx-toggle="${esc(m.id)}" data-emoji="${esc(reaction.emoji)}" data-mine="${reaction.reactedByMe ? "1" : "0"}" title="${esc((reaction.actors || []).map((actor) => actor.self ? "You" : actor.name).join(", "))}">${esc(reaction.emoji)}${reaction.count > 1 ? `<span class="n">${reaction.count}</span>` : ""}</button>`
).join("")}</div>` : "";
const hostActionsPersistent = hasHostActions && String(m.id) === latestHostActionRelayId;
const nextEntry = timeline[i + 1];
const nextMessage = nextEntry && nextEntry.kind === "message" ? nextEntry.message : null;
const nextChunkDateLabel = nextMessage ? chatChunkDateLabel(nextMessage.at, m.at) : "";
const continuesIntoNext = Boolean(nextMessage && continuesSenderRun(m, nextMessage, nextChunkDateLabel));
const providerKey = (message) => message?.origin === "slack" || message?.provider?.name === "slack"
? "slack"
: String(message?.source?.surface || "");
// One provenance footer closes a consecutive sender run. Repeating the
// Slack shelf inside every two-word bubble made a live Slack burst look
// like unrelated cards rather than the normal Relay chat geometry.
const showProviderByline = !continuesIntoNext || providerKey(nextMessage) !== providerKey(m);
const channelTask = Boolean(m.request && m.taskClaim?.scope === "channel");
const claimControl = channelTask ? taskClaimControlHtml(m, { surface: "chat" }) : "";
return `${slackThreadContextHtml(m)}${chunkDivider}${runHeader}
${channelTask ? `<div class="th-task-stack${mine ? " mine" : ""}">` : ""}<div class="th-msg${hasHostActions ? " has-host-actions" : ""}${m.unread ? " unread" : ""}${mine ? " mine" : ""}${cont ? " cont" : ""}${textLike ? " text" : ""}${agentWorking ? " agent-working" : ""}${open ? " open" : ""}${m.deletedAt ? " deleted" : ""}${aggregates.length ? " has-rx" : ""}${liveCanonicalArrivalIds.has(String(m.id || "")) ? " live-arrival" : ""}"${textLike ? "" : ' role="button" tabindex="0"'}${agentWorking ? ' aria-busy="true"' : ""}${hasHostActions ? ` aria-expanded="${hostActionsPersistent ? "true" : "false"}"` : ""} data-msg="${esc(m.id)}" data-dir="${m.direction}" data-opening-id="${esc(m.id)}">
${!REACTIONS_ENABLED || m.request ? "" : messageReactionPickerHtml(m.id)}
${messageReplyReferenceHtml(m)}
${editingMessage ? `
<div class="th-message-edit" data-stop="1">
<textarea data-message-edit-input="${esc(m.id)}" aria-label="Edit message">${esc(editDraft)}</textarea>
<div class="th-message-edit-actions">
<button type="button" data-message-edit-cancel="${esc(m.id)}">Cancel</button>
<button type="button" data-message-edit-save="${esc(m.id)}"${messageMutationIds.has(m.id) ? " disabled" : ""}>Save</button>
</div>
</div>` : `
<span class="th-msg-title">${agentWorking
? '<span class="agent-working-indicator" aria-hidden="true"><span class="agent-working-dot"></span><span class="agent-working-dot"></span><span class="agent-working-dot"></span></span>'
: `${m.request ? '<span class="kchip">Task</span>' : ""}${textLike ? linkify(m.title) : esc(m.title)}${textLike ? "" : `<span class="th-more">${open ? "‹" : "›"}</span>`}${m.editedAt && !m.deletedAt ? '<span class="th-edited">edited</span>' : ""}`}</span><span class="th-blk-time">${esc(timeAgo(m.at))}</span>
${!textLike && !open && !inlineBody && m.preview ? `<span class="th-preview">${esc(m.preview)}</span>` : ""}
${!textLike && !open && inlineBody ? `<div class="th-body" data-stop="0">${readerParagraphs(m.body)}</div>` : ""}
${!textLike && open ? `<div class="th-body" data-stop="0">${readerParagraphs(m.body)}</div>` : ""}
`}
<div class="row-err${rowNotes.get(m.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(m.id)}" data-stop="1">${esc(rowNotes.get(m.id)?.text || "")}</div>
${!REACTIONS_ENABLED || m.request ? "" : reactionConfirmationHtml(m.id)}
${badges}
${showProviderByline ? providerBylineHtml(m) : ""}
${hasHostActions ? relayHostActionsHtml(m, { persistent:hostActionsPersistent }) : ""}
</div>${channelTask ? `${claimControl}</div>` : ""}
${slackThreadLinkHtml(m)}
${(() => {
// A receipt and a reply control are ABOUT the message, not part of it.
// Inside the bubble the receipt collided with the timestamp and the
// reply button sat on the words themselves (David). They live under it.
const receipt = receiptFor(m, msgs);
const bits = [];
const settleReceipt = receipt && (receipt.label === "Sent" || receipt.label === "Delivered") ? " th-settling" : "";
if (m.ownedAgent) {
bits.push(`<button type="button" class="th-reply-ghost" data-open-agent-work="${esc(m.id)}" data-stop="1">Open Work</button>`);
} else if (!m.request && !groupPostingBlocked) {
bits.push(`<button type="button" class="th-reply-ghost" data-reply-to="${esc(m.id)}" data-stop="1">reply</button>`);
}
if (payload.features?.messageMutations === true && mine && !m.pending && !m.request && !m.deletedAt) {
bits.push(`<button type="button" class="th-message-action" data-message-edit="${esc(m.id)}" data-stop="1">edit</button>`);
if (messageDeleteConfirmIds.has(m.id)) {
bits.push(`<button type="button" class="th-message-action confirm" data-message-delete-confirm="${esc(m.id)}" data-stop="1">Delete for everyone</button>`);
bits.push(`<button type="button" class="th-message-action" data-message-delete-cancel="${esc(m.id)}" data-stop="1">keep</button>`);
} else {
bits.push(`<button type="button" class="th-message-action danger" data-message-delete="${esc(m.id)}" data-stop="1">delete</button>`);
}
}
if (mine && m.pending) bits.push(...outboxStatusBits(m));
if (receipt) bits.push(receipt.expandable
? `<button type="button" class="th-seen" data-receipt-toggle="${esc(m.id)}" data-stop="1" aria-expanded="${expandedReceiptIds.has(m.id) ? "true" : "false"}">${esc(receipt.label)}</button>`
: `<span class="th-seen${settleReceipt}">${esc(receipt.label)}</span>`);
const under = bits.length ? `<div class="th-under${mine ? " mine" : ""}">${bits.join("")}</div>` : "";
const details = receipt && expandedReceiptIds.has(m.id) ? receiptDetailsHtml(receipt) : "";
return under + details;
})()}
${(() => {
// THE CARGO LAW: a bubble holds WORDS. Photos, video and files stand
// free BENEATH it — never inside, where the enclosure outweighed the
// relay and the relay read as a file card (David, twice).
const files = relayAttachmentsForId(m.id) || [];
if (!files.length) return "";
return `<div class="th-cargo${mine ? " mine" : ""}">${attachmentPlates(files, { relayId: m.id })}</div>`;
})()}`;
}).join("");
const focusedSlackThreadBanner = slackFocusedThreadId
? `<div class="th-thread-focus" data-stop="1"><img src="slackMark.png" alt="" /><span><strong>Slack thread</strong></span></div>`
: "";
const finalRowsHtml = `${focusedSlackThreadBanner}${missingSlackThreadStubsHtml}${rowsHtml}`;
// Chat order: history above, composer at the bottom, land at the newest.
// Inbox order: composer + newest on top. The actions card is gone either
// way — actions live in the reader; the composer is the resident action.
// THE COMPOSER IS NOT REPAINTED (David, 2026-08-18: after sending, "the
// pipe/selection doesn't stay focused on the chat composer so i need to
// click the chat composer again if i want to type another message").
// A room used to be one innerHTML write containing its own composer, so
// every payload push destroyed the textarea. That forced two hacks: the
// repaint was DEFERRED while the composer held focus (arrivals waited for
// you to stop typing), and the send path BLURRED itself to let its own
// bubble through — which is exactly where the caret went. Rows and dock
// are now siblings: #thRows is rewritten, the composer is left alone, and
// focus survives a send the way it does in every other messaging app.
const shellKey = `${chatShaped ? "chat" : "inbox"}|${groupPostingBlocked ? "blocked" : "open"}|${threadStateKey}`;
// Repaint guard. This view is rebuilt on EVERY payload push (and entering it
// triggers a sent refresh), which tore down and recreated the open-actions
// card ~a second after opening — the flash David reported on "Read the
// Conversation". Identical markup now means no DOM write and no rewiring.
const followOwnSend = threadDetailFollowSendFor === threadStateKey;
const entryFollowToken = threadEntryFollowToken();
const shellChanged = thHistoryEl.dataset.shellKey !== shellKey || !document.getElementById("thRows");
if (shellChanged) {
thHistoryEl.dataset.shellKey = shellKey;
const rowsShell = `<div id="thRows" class="${chatShaped ? "chat-order" : "inbox-order"}"></div>`;
thHistoryEl.innerHTML = chatShaped ? rowsShell + visibleThreadComposer : visibleThreadComposer + rowsShell;
}
const thRowsEl = document.getElementById("thRows");
const rowsChanged = Boolean(thRowsEl) && thRowsEl.innerHTML !== finalRowsHtml;
if (rowsChanged) {
thRowsEl.innerHTML = finalRowsHtml;
for (const id of liveCanonicalArrivalIds) {
if (thRowsEl.querySelector(`[data-msg="${CSS.escape(id)}"]`)) liveCanonicalArrivalIds.delete(id);
}
}
// The composer's own faces still move while it stays put: the Relay it is
// attached to, and the in-flight Send. Both are set in place.
const composerQr = thHistoryEl.querySelector(".qr.th-qr");
if (composerQr && composerQr.dataset.replyTarget !== replyTargetId) {
composerQr.dataset.replyTarget = replyTargetId;
composerQr.classList.toggle("replying", Boolean(replyTargetId));
composerQr.querySelector(".th-reply-target")?.remove();
if (composerReplyTargetHtml) {
composerQr.insertAdjacentHTML("afterbegin", composerReplyTargetHtml);
wireReplyCancel(composerQr, threadStateKey);
}
}
const composerSendEl = document.getElementById("thQrSend");
if (composerSendEl) composerSendEl.disabled = chatReplySending.has(threadStateKey);
// The queue payload can paint the optimistic bubble before the IPC reply
// sets followOwnSend. The next render then has identical markup; returning
// early in that case left the new bubble below the viewport until another
// message happened to repaint the room. Following a send is a navigation
// obligation, not a side effect of a DOM write.
if (followOwnSend && !shellChanged && !rowsChanged) {
threadDetailFollowSendFor = null;
scrollRoomToNewest(chatShaped);
requestAnimationFrame(() => scrollRoomToNewest(chatShaped, { smooth:true }));
return;
}
if (shellChanged || rowsChanged) {
relayHostHoverIntent.refresh();
// Entering a conversation always lands at the top (the newest message);
// rebuilds of the same open thread keep the user's scroll position.
// The one exception is the messaging-app contract: after YOU send,
// follow the newly appended bubble so success is visible immediately.
if (threadDetailScrolledFor !== thread.threadId || followOwnSend || entryFollowToken) {
threadDetailScrolledFor = thread.threadId;
if (followOwnSend) threadDetailFollowSendFor = null;
// The entry latch is stronger than a paint callback: backgrounded or
// inspector-driven Electron windows can defer rAF long enough for a
// hydrated transcript to remain at its stale bottom. The DOM write has
// already completed, so pin once now and once after layout settles.
if (entryFollowToken) scrollRoomToNewest(chatShaped);
requestAnimationFrame(() => {
if (entryFollowToken && threadEntryFollowToken() !== entryFollowToken) return;
scrollRoomToNewest(chatShaped, { smooth:followOwnSend });
});
}
} else if (threadDetailScrolledFor !== thread.threadId || entryFollowToken) {
threadDetailScrolledFor = thread.threadId;
if (entryFollowToken) scrollRoomToNewest(chatShaped);
requestAnimationFrame(() => {
if (entryFollowToken && threadEntryFollowToken() !== entryFollowToken) return;
scrollRoomToNewest(chatShaped);
});
return; // markup unchanged: existing handlers are still bound
} else {
return; // nothing changed at all
}
for (const z of thHistoryEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
for (const input of thHistoryEl.querySelectorAll("[data-message-edit-input]")) {
const id = String(input.getAttribute("data-message-edit-input") || "");
input.addEventListener("input", () => messageEditDrafts.set(id, input.value));
input.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
messageEditDrafts.delete(id);
renderThreadDetail();
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
thHistoryEl.querySelector(`[data-message-edit-save="${CSS.escape(id)}"]`)?.click();
}
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-edit]")) {
button.addEventListener("click", () => {
const id = String(button.getAttribute("data-message-edit") || "");
const message = threadMessages(threadMessageOptions).find((item) => String(item.id) === id);
if (!message) return;
messageDeleteConfirmIds.delete(id);
messageEditDrafts.set(id, String(message.body || message.title || ""));
renderThreadDetail();
const input = thHistoryEl.querySelector(`[data-message-edit-input="${CSS.escape(id)}"]`);
if (input) {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-edit-cancel]")) {
button.addEventListener("click", () => {
messageEditDrafts.delete(String(button.getAttribute("data-message-edit-cancel") || ""));
renderThreadDetail();
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-edit-save]")) {
button.addEventListener("click", async () => {
const id = String(button.getAttribute("data-message-edit-save") || "");
const message = threadMessages(threadMessageOptions).find((item) => String(item.id) === id);
const input = thHistoryEl.querySelector(`[data-message-edit-input="${CSS.escape(id)}"]`);
const forHuman = String(input?.value || "").trim();
if (!message || messageMutationIds.has(id)) return;
if (!forHuman) {
setRowNote(id, "A message cannot be empty.", "err");
return;
}
messageEditDrafts.set(id, forHuman);
messageMutationIds.add(id);
button.disabled = true;
clearRowNote(id);
const result = await window.relay.editMessage(id, forHuman, message.updatedAt || "");
messageMutationIds.delete(id);
if (!result?.ok) {
setRowNote(id, result?.error || "Could not edit this message.", "err");
renderThreadDetail();
return;
}
applySentMessageMutation(result, { forHuman });
messageEditDrafts.delete(id);
setRowNote(id, "Message edited.", "ok");
fadeRowNoteLater(id, 2500);
renderThreadDetail();
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-delete]")) {
button.addEventListener("click", () => {
const id = String(button.getAttribute("data-message-delete") || "");
messageEditDrafts.delete(id);
messageDeleteConfirmIds.add(id);
renderThreadDetail();
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-delete-cancel]")) {
button.addEventListener("click", () => {
messageDeleteConfirmIds.delete(String(button.getAttribute("data-message-delete-cancel") || ""));
renderThreadDetail();
});
}
for (const button of thHistoryEl.querySelectorAll("[data-message-delete-confirm]")) {
button.addEventListener("click", async () => {
const id = String(button.getAttribute("data-message-delete-confirm") || "");
const message = threadMessages(threadMessageOptions).find((item) => String(item.id) === id);
if (!message || messageMutationIds.has(id)) return;
messageMutationIds.add(id);
button.disabled = true;
clearRowNote(id);
const result = await window.relay.deleteMessage(id, message.updatedAt || "");
messageMutationIds.delete(id);
if (!result?.ok) {
setRowNote(id, result?.error || "Could not delete this message.", "err");
renderThreadDetail();
return;
}
applySentMessageMutation(result, { deleted:true });
messageDeleteConfirmIds.delete(id);
renderThreadDetail();
});
}
for (const receiptButton of thHistoryEl.querySelectorAll("[data-receipt-toggle]")) {
receiptButton.addEventListener("click", (event) => {
event.stopPropagation();
const id = receiptButton.getAttribute("data-receipt-toggle");
if (expandedReceiptIds.has(id)) expandedReceiptIds.delete(id);
else expandedReceiptIds.add(id);
renderThreadDetail();
});
}
for (const badge of thHistoryEl.querySelectorAll("[data-rx-toggle]")) {
badge.addEventListener("click", async (event) => {
event.stopPropagation();
const id = badge.getAttribute("data-rx-toggle");
const emoji = badge.getAttribute("data-emoji");
const action = badge.getAttribute("data-mine") === "1" ? "remove" : "add";
badge.disabled = true;
const result = await commitReaction(id, emoji, action);
if (result && result.ok && action === "remove") reactionNotes.delete(id);
if (!result || !result.ok) { badge.disabled = false; renderThreadDetail(); }
});
}
wireReactionControls(thHistoryEl);
wireTaskClaimControls(thHistoryEl, renderThreadDetail);
wireAttachmentChips(thHistoryEl);
wireHostOpen(thHistoryEl);
for (const button of thHistoryEl.querySelectorAll("[data-slack-thread-open]")) {
button.addEventListener("click", (event) => {
event.stopPropagation();
window.relay.openUrl(button.getAttribute("data-slack-thread-open"));
});
}
for (const button of thHistoryEl.querySelectorAll("[data-slack-thread-focus]")) {
button.addEventListener("click", (event) => {
event.stopPropagation();
slackFocusedThreadId = String(button.getAttribute("data-slack-thread-focus") || "");
threadDetailScrolledFor = null;
renderThreadDetail();
});
}
const thQrSend = document.getElementById("thQrSend");
const thQrInput = document.getElementById("thQrInput");
if (thQrSend && thQrInput) {
let thReplySending = false;
const mentionMenu = document.getElementById("thMentionMenu");
let mentionMatches = [];
let mentionIndex = 0;
let mentionStart = -1;
// A DM already has exactly one human recipient, so naming that person
// again cannot change the audience. Human @mentions disambiguate group
// recipients only; the owned Claude and Codex options remain available
// in every developer composer below.
const participantMentions = new Map();
if (payload.features?.peopleMentions === true && thread.isGroup) {
const group = groupsList.find((candidate) => String(candidate.id || "") === String(thread.groupId || ""))
|| groupsList.find((candidate) => normalizedPartyName(candidate.name) === normalizedPartyName(thread.groupName));
// The current roster decides who can be mentioned. Historical sender
// labels can be stale (or belong to somebody who has left), and must
// not sit beside the viewer's saved contact name as a second person.
for (const member of group ? groupInfoRoster(group) : []) {
const name = String(member.name || "").trim();
const token = mentionTokenForContact(member);
if (!member.currentUser && name && token && !participantMentions.has(normalizedMentionToken(token))) {
participantMentions.set(normalizedMentionToken(token), {
token, name:`@${name}`, note:"Participant", agent:false,
});
}
}
}
let mentionOptions = [
...(payload.features?.agentMentions === true ? [
ownedAgentMentionOption("claude"),
ownedAgentMentionOption("codex"),
] : []),
...participantMentions.values(),
];
let mentionProviderStatusLoading = false;
const refreshMentionProviderStatus = () => {
if (mentionProviderStatusLoading || providerAuthInfo?.providers?.claude?.authLoaded || !window.relay.providerAuthStatus) return;
mentionProviderStatusLoading = true;
window.relay.providerAuthStatus().then((result) => {
applyProviderAuthResult(result);
mentionOptions = [ownedAgentMentionOption("claude"), ownedAgentMentionOption("codex"), ...participantMentions.values()];
if (thQrInput.isConnected) renderMentions();
}).catch(() => {}).finally(() => { mentionProviderStatusLoading = false; });
};
prepareMentionComposer(thQrInput, mentionOptions, threadDraft);
const closeMentions = () => { mentionMatches = []; mentionStart = -1; mentionMenu?.classList.add("hidden"); };
const chooseMention = (index) => {
const option = mentionMatches[index];
if (!option || mentionStart < 0) return;
const caret = thQrInput.selectionStart ?? thQrInput.value.length;
thQrInput.value = `${thQrInput.value.slice(0, mentionStart)}@${option.token} ${thQrInput.value.slice(caret)}`;
const nextCaret = mentionStart + option.token.length + 2;
thQrInput.setSelectionRange(nextCaret, nextCaret);
threadComposerDrafts.set(threadStateKey, thQrInput.value);
thQrInput.relayResize?.();
closeMentions();
thQrInput.focus();
};
const renderMentions = () => {
if (!mentionMenu) return;
const caret = thQrInput.selectionStart ?? thQrInput.value.length;
const before = thQrInput.value.slice(0, caret);
const match = before.match(/(^|\s)@([^\s@]*)$/);
if (!match) { closeMentions(); return; }
if (payload.features?.agentMentions === true) refreshMentionProviderStatus();
const query = String(match[2] || "").toLowerCase();
mentionStart = caret - query.length - 1;
mentionMatches = mentionOptions.filter((option) => option.token.toLowerCase().includes(query)).slice(0, 8);
if (!mentionMatches.length) { closeMentions(); return; }
mentionIndex = Math.min(mentionIndex, mentionMatches.length - 1);
mentionMenu.innerHTML = mentionMatches.map((option, index) => `<button type="button" class="th-mention-item${option.agent ? " agent" : ""}${index === mentionIndex ? " on" : ""}" data-mention-index="${index}" role="option" aria-selected="${index === mentionIndex ? "true" : "false"}"><span class="th-mention-dot"></span><span class="th-mention-copy"><span class="th-mention-name">${esc(option.name)}</span><span class="th-mention-note">${esc(option.note)}</span></span></button>`).join("");
mentionMenu.classList.remove("hidden");
for (const button of mentionMenu.querySelectorAll("[data-mention-index]")) {
button.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopPropagation(); chooseMention(Number(button.getAttribute("data-mention-index"))); });
button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); chooseMention(Number(button.getAttribute("data-mention-index"))); });
}
};
thQrInput.addEventListener("input", () => {
threadComposerDrafts.set(threadStateKey, thQrInput.value || "");
mentionIndex = 0;
renderMentions();
});
thQrInput.addEventListener("click", renderMentions);
thQrInput.addEventListener("keyup", (event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"].includes(event.key)) renderMentions();
});
thQrInput.addEventListener("blur", (event) => {
if (!mentionMenu?.contains(event.relatedTarget)) closeMentions();
});
thQrInput.addEventListener("keydown", (event) => {
if (!mentionMatches.length || mentionMenu?.classList.contains("hidden")) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault(); event.stopImmediatePropagation();
mentionIndex = (mentionIndex + (event.key === "ArrowDown" ? 1 : -1) + mentionMatches.length) % mentionMatches.length;
renderMentions();
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault(); event.stopImmediatePropagation(); chooseMention(mentionIndex);
} else if (event.key === "Escape") {
event.preventDefault(); event.stopImmediatePropagation(); closeMentions();
}
}, true);
const doThReply = async () => {
if (thReplySending || chatReplySending.has(threadStateKey)) return;
const text = (thQrInput.value || "").trim();
const staged = peekStagedFiles(thQrInput);
if (!text && !staged.length) return;
const newest = (thread.msgs || [])[thread.msgs.length - 1];
const selectedReplyTargetId = String(threadReplyTargets.get(threadStateKey) || "");
const selectedReplyTarget = selectedReplyTargetId ? messageById.get(selectedReplyTargetId) : null;
// An empty saved group is already an addressable room. It has no
// message from which to inherit an address, so the room itself is the
// anchor for its first send.
const emptyGroupAnchor = chatRoom && chatRoom.isGroup && chatRoom.groupId
? {
threadId: chatRoom.threadId,
party: chatRoom.name,
partyKey: chatRoom.partyKey,
isGroup: true,
groupId: chatRoom.groupId,
groupName: chatRoom.groupName || chatRoom.name,
chatId: chatRoom.chatId || "",
provider: chatRoom.provider || "",
}
: null;
const emptySlackDirectAnchor = chatRoom && !chatRoom.isGroup && isSlackIntegratedRoom(chatRoom) && chatRoom.chatId
? {
threadId:chatRoom.threadId,
party:chatRoom.name,
partyKey:chatRoom.partyKey,
isGroup:false,
groupId:"",
groupName:"",
chatId:chatRoom.chatId,
provider:"slack",
}
: null;
const emptyRelayDirectAnchor = chatRoom && !chatRoom.isGroup
&& chatRoom.addressRecipient && chatRoom.addressRecipient.email
? {
threadId:chatRoom.threadId,
party:chatRoom.name,
partyKey:chatRoom.partyKey,
isGroup:false,
groupId:"",
groupName:"",
chatId:chatRoom.chatId || "",
provider:"relay",
addressRecipient:chatRoom.addressRecipient,
}
: null;
const emptyRoomAnchor = emptyGroupAnchor || emptySlackDirectAnchor || emptyRelayDirectAnchor;
const sendAnchor = selectedReplyTarget || addressAnchor || newest || emptyRoomAnchor;
const inReplyToRelayId = selectedReplyTargetId || String(focusedSlackParent?.id || "");
const recipient = (addressAnchor && addressAnchor.addressRecipient)
|| (emptyRoomAnchor
? (emptyRoomAnchor.addressRecipient
|| (emptyRoomAnchor.provider === "slack"
? { chatId:emptyRoomAnchor.chatId }
: { groupId:emptyRoomAnchor.groupId }))
: null);
if (!sendAnchor || (!inReplyToRelayId && !recipient) || !window.relay.sendReply) {
setRowNote(newest ? newest.id : thread.threadId, "This conversation cannot be replied to yet.", "err");
return;
}
takeStagedFiles(thQrInput);
let files;
let attachments;
try {
({ files, attachments } = await composerFilePayloads(staged));
} catch (error) {
stageFileList(thQrInput, staged.map((f) => f.file || f));
setRowNote(newest ? newest.id : thread.threadId, `Could not attach that file: ${(error && error.message) || String(error)}`, "err");
return;
}
thReplySending = true;
thQrSend.disabled = true;
const fingerprint = JSON.stringify([recipient, inReplyToRelayId, text, files.map((f) => [f.name, f.size || 0])]);
const priorAttempt = threadReplyAttemptKeys.get(threadStateKey);
const idempotencyKey = priorAttempt && priorAttempt.fingerprint === fingerprint
? priorAttempt.key
: `pill-reply-${crypto.randomUUID()}`;
threadReplyAttemptKeys.set(threadStateKey, { fingerprint, key: idempotencyKey });
const optimisticId = `optimistic:${idempotencyKey}`;
optimisticChatReplies.set(idempotencyKey, {
id: optimisticId,
relayId: "",
threadId: sendAnchor.threadId || thread.threadId,
inReplyToRelayId: inReplyToRelayId || "",
explicitReply: Boolean(inReplyToRelayId),
direction: "out",
seen: false,
seenAt: null,
readReceipts: [],
// Render-model field, not the wire title (the send path sends no
// title for typed text): textLike bubbles paint m.title as the text.
title: text || (files.length === 1 ? String(files[0].name || "1 file") : `${files.length} files`),
body: text,
party: sendAnchor.party || thread.party,
partyKey: sendAnchor.partyKey || thread.partyKey,
at: new Date().toISOString(),
isGroup: Boolean(sendAnchor.isGroup || thread.isGroup),
groupName: sendAnchor.groupName || thread.groupName || "",
unread: false,
textLike: true,
request: false,
pending: true,
reactions: { aggregates: [], events: [] },
attachments,
preview: "",
});
const agentMentions = payload.features?.agentMentions === true ? agentMentionSpans(text) : [];
const invokedProviders = agentMentions.map((mention) => mention.provider);
for (const [index, provider] of invokedProviders.entries()) {
optimisticChatReplies.set(`${idempotencyKey}:agent:${provider}`, {
id:`optimistic-agent:${idempotencyKey}:${provider}`, relayId:"", threadId:sendAnchor.threadId || thread.threadId,
inReplyToRelayId:optimisticId, direction:"out", seen:false, seenAt:null, readReceipts:[],
title:"", body:"", party:provider === "claude" ? "Claude" : "Codex",
partyKey:sendAnchor.partyKey || thread.partyKey || "", at:new Date(Date.now() + index + 1).toISOString(), isGroup:Boolean(sendAnchor.isGroup || thread.isGroup),
groupName:sendAnchor.groupName || thread.groupName || "", unread:false, textLike:true, request:false, pending:true,
source:{ host:"relay-agent-run", surface:provider === "claude" ? "claude_code" : "codex" }, ownedAgent:true, agentInvocation:true,
reactions:{ aggregates:[], events:[] }, attachments:[], preview:"",
});
}
chatReplySending.add(threadStateKey);
// Paint before awaiting IPC. The words now live in the optimistic row,
// so the field empties — but it is the SAME field afterwards, still
// focused and ready for the next message. Only its height has to be
// told, because grow() answers to typing and nothing was typed.
thQrInput.value = "";
thQrInput.setSelectionRange(0, 0);
thQrInput.relayResize?.();
threadComposerDrafts.delete(threadStateKey);
renderThreadDetail();
// Send COMMITS the message to this device. The answer is immediate on
// any network, because the network is no longer in this path: the
// outbox owes the delivery and reports its own progress through the
// payload. The old code awaited the send here, and a fifteen-second
// timeout on weak wifi ended with the bubble deleted and the words
// pushed back under the cursor (David, Granular, 2026-08-20).
let res;
try {
res = await window.relay.sendReply({
text, recipient, ...(inReplyToRelayId ? { inReplyToRelayId } : {}), files, idempotencyKey, agentMentions,
// The room the queue is speaking into, so a bubble rebuilt from the
// queue after a restart knows which conversation it belongs to.
chat: {
threadId: sendAnchor.threadId || thread.threadId || "",
party: sendAnchor.party || thread.party || "",
partyKey: sendAnchor.partyKey || thread.partyKey || "",
isGroup: Boolean(sendAnchor.isGroup || thread.isGroup),
groupId: sendAnchor.groupId || thread.groupId || "",
groupName: sendAnchor.groupName || thread.groupName || "",
chatId: sendAnchor.chatId || thread.chatId || "",
provider: sendAnchor.provider?.name || sendAnchor.provider || thread.provider || "",
},
});
} catch (error) {
res = { ok: false, error: error && error.message ? error.message : "Send failed — try again." };
}
if (!res || !res.ok) {
// The device itself refused to hold the message — not a network
// failure, which the queue absorbs, but something it cannot store at
// all. Only then do the words belong back in the composer.
optimisticChatReplies.delete(idempotencyKey);
optimisticChatReplies.delete(`${idempotencyKey}:agent:claude`);
optimisticChatReplies.delete(`${idempotencyKey}:agent:codex`);
chatReplySending.delete(threadStateKey);
threadComposerDrafts.set(threadStateKey, text);
setRowNote(newest ? newest.id : (sendAnchor.threadId || thread.threadId), (res && res.error) || "Send failed — try again.", "err");
thReplySending = false;
renderThreadDetail();
// The composer is no longer rebuilt from the draft store, so put
// the words back into the live field itself.
const restored = document.getElementById("thQrInput");
if (restored) {
restored.value = text;
restored.relayResize?.();
if (staged.length) stageFileList(restored, staged.map((f) => f.file || f));
restored.focus();
restored.setSelectionRange(text.length, text.length);
}
return;
}
// The queue's own record supersedes this bubble from the next payload
// onward; until then it keeps the row on screen under the same key.
const optimistic = optimisticChatReplies.get(idempotencyKey);
if (optimistic && res.entry) {
optimistic.fromOutbox = true;
optimistic.outboxId = String(res.entry.id || idempotencyKey);
optimistic.outboxState = String(res.entry.state || "queued");
optimistic.outboxAttempts = Number(res.entry.attempts || 0);
}
threadComposerDrafts.delete(threadStateKey);
threadReplyTargets.delete(threadStateKey);
threadReplyAttemptKeys.delete(threadStateKey);
chatReplySending.delete(threadStateKey);
thReplySending = false;
threadDetailFollowSendFor = threadStateKey;
renderThreadDetail();
if (window.relay.refreshSent) window.relay.refreshSent().catch(() => {});
if (window.relay.refresh) window.relay.refresh().catch(() => {});
};
threadComposerSend = doThReply;
if (!thQrSend.dataset.wired) {
thQrSend.dataset.wired = "1";
thQrSend.addEventListener("click", (e) => { e.stopPropagation(); threadComposerSend?.(); });
thQrInput.addEventListener("mousedown", (e) => e.stopPropagation());
}
dressComposer(thQrInput, () => threadComposerSend?.());
// The first paint may happen after the sent-side cache hydrates. Honour
// the room-entry request here as well as in openThreadDetail so that a
// loading conversation still becomes immediately typeable when ready.
if (threadComposerEntryFocus) {
focusThreadComposerOnEntry(threadComposerEntryFocus, { recover:true });
settleThreadComposerEntryFocus(threadComposerEntryFocus);
}
}
for (const b of thHistoryEl.querySelectorAll("[data-reply-to]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
const id = String(b.getAttribute("data-reply-to") || "");
if (!id) return;
threadReplyTargets.set(threadStateKey, id);
renderThreadDetail();
const box = document.getElementById("thQrInput");
if (box) { box.focus(); box.scrollIntoView({ block: "nearest" }); }
});
}
// The two acts left on a message the server refused. Retry puts it back in
// line; Delete is the only path that ever throws typed words away, and it
// is the human's own hand doing it.
for (const b of thHistoryEl.querySelectorAll("[data-outbox-retry]")) {
b.addEventListener("click", async (e) => {
e.stopPropagation();
const id = String(b.getAttribute("data-outbox-retry") || "");
if (!id || !window.relay.outboxRetry) return;
await window.relay.outboxRetry(id).catch(() => {});
});
}
for (const b of thHistoryEl.querySelectorAll("[data-outbox-discard]")) {
b.addEventListener("click", async (e) => {
e.stopPropagation();
const id = String(b.getAttribute("data-outbox-discard") || "");
if (!id || !window.relay.outboxDiscard) return;
await window.relay.outboxDiscard(id).catch(() => {});
});
}
wireReplyCancel(thHistoryEl, threadStateKey);
for (const button of thHistoryEl.querySelectorAll("[data-open-agent-work]")) {
button.addEventListener("click", (event) => {
event.stopPropagation();
openReader(String(button.getAttribute("data-open-agent-work") || ""), "threads");
});
}
for (const b of thHistoryEl.querySelectorAll("[data-reply-ref]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
const id = String(b.getAttribute("data-reply-ref") || "");
const source = id ? thHistoryEl.querySelector(`[data-msg="${CSS.escape(id)}"]`) : null;
if (!source) return;
source.scrollIntoView({ block:"center", behavior:REDUCED ? "auto" : "smooth" });
source.classList.remove("reply-source-flash");
void source.offsetWidth;
source.classList.add("reply-source-flash");
setTimeout(() => source.classList.remove("reply-source-flash"), 1250);
});
}
for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
const id = el.getAttribute("data-msg");
if (openingIds.has(id)) el.classList.add("opening");
el.addEventListener("click", () => {
// Resolve the exact card that was painted. A canonical direct chat can
// be visible before inbox/Sent hydration, so rebuilding only the local
// projection here made a real two-document Relay look clickable while
// its click silently found no message.
const m = messageById.get(String(id));
// Plain text is INERT (David): selectable, its links clickable, no
// navigation hiding under it — Reply is a deliberate hover affordance.
if (m && m.textLike && !m.ownedAgent) return;
if (m && m.ownedAgent) {
openReader(id, m.direction === "out" ? "sent" : "threads");
return;
}
// ONE ontology, BOTH DIRECTIONS: every titled relay opens the same
// document reader. Outbound rows resolve through the sent cache;
// returning early here was the entire "I can open theirs, never mine"
// bug even though the card rendered a chevron promising the opposite.
if (m && !m.textLike) {
const shouldAck = Boolean(m.unread);
if (m.unread) {
const raw = (payload.relays || []).find((row) => String(row.id) === String(id));
if (raw) raw.unread = false;
}
openReader(id, m.direction === "out" ? "sent" : (m.request ? "tasks" : "threads"));
if (shouldAck) persistReadIds([id], { afterPaint: true });
return;
}
});
}
// Per-message actions moved to the READER (chat-first IA): tapping an
// inbound message opens it as a page; explicit paths live there. (The
// header-name sheet was tried for conversation-level actions and killed —
// "no one will see it" (Sven). Whole-conversation hand-off keeps its one
// power path: hold ⌥ on the reader's Open in {app}.)
}
function syncExpandButton() {
const b = document.getElementById("thExpand");
if (b) b.innerHTML = chatExpanded
? '<span class="xg">⤡</span><span class="xl">Collapse</span>'
: '<span class="xg">⤢</span><span class="xl">Expand</span>';
// See info follows the frame: re-run its gate on every toggle, not just
// on room open (setThreadHeader runs the same expression).
if (typeof thGroupInfoEl !== "undefined" && thGroupInfoEl) {
const room = threadDetailId ? chatRoomForThread(threadDetailId) : null;
thGroupInfoEl.classList.toggle("hidden",
!(chatExpanded && room && room.isGroup && (room.groupName || room.groupId)));
syncSlackVisibilityButton(room);
}
}
thSlackVisibilityEl.addEventListener("click", async (e) => {
e.stopPropagation();
const surface = conversationSurface();
const room = threadDetailId ? chatRoomForThread(threadDetailId, surface) : null;
if (!isSlackIntegratedRoom(room) || !room.chatId) return;
const key = slackVisibilityKey(room);
const wasVisible = slackMessagesVisible(room);
const nextVisible = !wasVisible;
slackVisibilityOverrides.set(key, nextVisible);
syncSlackVisibilityButton(room);
if (!nextVisible) {
slackFocusedThreadId = "";
renderThreadDetail();
return;
}
thSlackVisibilityEl.disabled = true;
thSlackVisibilityLabelEl.textContent = "Loading…";
const result = await requestCanonicalChatDetail(room, surface, { includeSlack:true });
if (!result?.ok || !result.chat) {
slackVisibilityOverrides.set(key, wasVisible);
syncSlackVisibilityButton(room);
thSlackVisibilityEl.title = result?.error || "Could not load Slack messages.";
return;
}
reconcileCanonicalChatResult(result, {
chatId:room.chatId,
surface,
readVisible:true,
});
});
document.getElementById("thExpand").addEventListener("click", (e) => {
e.stopPropagation();
const roomScroll = captureRoomScroll();
rememberChatRailScroll();
const morphFromCompact = !chatExpanded && prepareReaderMorph(activeView);
chatExpanded = !chatExpanded;
syncExpandButton();
// Populate/clear the rail before the frame begins moving. Building it
// after springTo made the first wide frame an empty column.
renderChatRail();
applyView();
if (morphFromCompact) startReaderMorph("threads");
requestAnimationFrame(() => restoreRoomScroll(roomScroll));
});
thBackEl.addEventListener("click", () => {
if (slackFocusedThreadId) {
slackFocusedThreadId = "";
threadDetailScrolledFor = null;
renderThreadDetail();
return;
}
if (!groupInfoBackdropEl.classList.contains("hidden")) closeGroupInfo();
// Compact rooms are a same-frame navigation stack and reverse their push.
// Expanded rooms are also shrinking the card, so they keep the quieter
// resize morph instead of combining a full-width swipe with frame motion.
const wasExpanded = chatExpanded;
const destinationView = threadsSource === "chat" ? "chat"
: threadsSource === "contacts" ? "contacts"
: threadsSource === "slack" ? "slack"
: "relays";
const returnTop = destinationView === "chat" ? chatListScrollTop
: destinationView === "contacts" ? contactsListScrollTop
: destinationView === "slack" ? slackListScrollTop
: relaysListScrollTop;
const navigateBack = () => {
threadDetailId = null;
threadDetailChatId = "";
threadDetailLoadError = "";
threadDetailRecovery = null;
threadDetailScrolledFor = null;
chatExpanded = false;
// Back goes back to the LIST that was clicked. A room entered from People
// returns to People, on the row it left, not to Relays.
activeView = destinationView;
commitNavigation({ outerScrollTop: returnTop });
};
if (!wasExpanded) {
startRoomViewTransition(navigateBack, { motion:"back" });
return;
}
const morphBackToList = prepareReaderMorph("threads", { motion:"morph" });
navigateBack();
if (morphBackToList) startReaderMorph(activeView);
});
let threadsSource = "chat"; // which tab the open conversation belongs to (the reader's readerSource law)
let threadsCameFrom = "relays"; // where it was born — that tab also re-homes instead of navigating
function isConversationRoomSource(source = threadsSource) {
// Relays and People are different INDEXES into the same rooms, not
// different readers. A topic row may select the starting message, but it
// must not manufacture an isolated conversation that disappears from the
// person's Chat room — and neither may a contact row.
return source === "chat" || source === "relays" || source === "slack" || source === "contacts";
}
function openThreadDetail(threadId, partyHint = "", source = "relays", options = {}) {
if (activeView === "relays" && source === "relays" && scrollEl) relaysListScrollTop = scrollEl.scrollTop;
if (activeView === "slack" && source === "slack" && scrollEl) slackListScrollTop = scrollEl.scrollTop;
if (activeView === "contacts" && source === "contacts" && scrollEl) contactsListScrollTop = scrollEl.scrollTop;
const surface = conversationSurface(source);
const requestedRoom = isConversationRoomSource(source)
? chatRoomForThread(threadId, surface)
: null;
const canonicalChatId = isSlackIntegratedRoom(requestedRoom) && requestedRoom.chatId
? String(requestedRoom.chatId)
: source === "slack" ? String(threadId || "") : "";
const roomCoordinate = canonicalChatId || threadId;
dismissReactionPickers();
slackFocusedThreadId = "";
threadDetailScrolledFor = null;
const entryFollowToken = beginThreadEntryFollow(roomCoordinate);
const composerFocusRequest = beginThreadComposerEntryFocus(roomCoordinate);
threadsSource = source;
threadsCameFrom = source;
expandedMsgIds.clear();
// Every open people/group room owns the split view and therefore its rail.
// Resetting this to false here raced openRoom's true assignment and left a
// correctly-sized blank left pane until a later repaint happened to heal it.
// A RELAY TAP LANDS IN THE GLANCE FRAME (Sven, 2026-08-14): clicking a
// relay used to auto-expand into the split — rail, people, groups — when
// all you wanted was the message. Relay-sourced opens land compact; ⤢
// Expand is the opt-in. Chat rooms keep David's law (openRoom asserts it
// and callers may pass options.expanded explicitly).
chatExpanded = options.expanded === undefined ? source === "chat" : Boolean(options.expanded);
syncExpandButton();
threadDetailId = roomCoordinate;
threadDetailChatId = canonicalChatId;
threadDetailLoadError = "";
threadDetailRecovery = null;
threadDetailPartyHint = partyHint;
activeView = "threads";
// Entering a conversation READS IT — all of it, like every messenger.
// Letters used to be exempt, waiting for a per-message unfold, so a reader
// could open a chat, read the thing, come back, and still find it bold with
// a count on it (David, live). Opening is reading.
const room = requestedRoom || (isConversationRoomSource(source)
? chatRoomForThread(roomCoordinate, surface)
: null);
const partyKey = room && room.partyKey;
const party = String(partyHint || (room && room.name) || "").trim().toLowerCase();
const roomAckIds = [];
// Slack has its own read cursor. Relay opens acknowledge only Relay rows;
// Slack opens are handled after the canonical transcript is rendered.
for (const m of source === "slack" ? [] : threadMessages({ includeSlack:false })) {
if (m.direction !== "in" || !m.unread) continue;
const sameRoom = room && room.isGroup
? (room.threadIds instanceof Set ? room.threadIds.has(m.threadId) : m.threadId === threadId)
: partyKey || party
? sameDirectParty(m, partyKey, party)
: m.threadId === roomCoordinate;
if (sameRoom) {
const messageId = String(m.id);
if (m.provider?.name !== "slack") roomAckIds.push(messageId);
visibleRoomAckIds.add(messageId);
const raw = (payload.relays || []).find((row) => String(row.id) === String(m.id));
if (raw) raw.unread = false;
}
}
// The destination is complete now: paint the room once while hidden,
// reveal it, and retarget the frame. The old openRoom path did those jobs
// three times (including once on the next animation frame).
commitNavigation({ outerScrollTop: 0 });
readVisibleChatRoom();
const includeSlack = slackMessagesVisible(room, source);
if (isSlackIntegratedRoom(room) && room.chatId && !options.hydrated
&& !canonicalChatDetailIsCurrent(room, source, { includeSlack })) {
requestCanonicalChatDetail(room, source, { includeSlack }).then((result) => {
reconcileCanonicalChatResult(result, {
chatId:room.chatId,
surface,
readVisible:true,
});
}).catch(() => {});
}
// The row click has already put the native window in front. Put the caret
// in its composer in the same task, then reassert it once after layout in
// case the compact/expanded frame transition briefly disturbed focus.
focusThreadComposerOnEntry(composerFocusRequest);
settleThreadComposerEntryFocus(composerFocusRequest);
hydrateThreadEntry(entryFollowToken, { includeSent:source !== "slack" });
// Let Chromium commit the destination before the main process takes the
// one locked state.json write for the room. Per-message synchronous writes
// here were the first-open-only stall: once read, later opens were smooth.
persistReadIds(roomAckIds, { afterPaint: true });
}
// ---------- Settings view ----------
// The account card + sign-out / switch-account lifecycle. Renders from its own
// relay:accountInfo load (like Contacts), never from inbox pushes, so a typed
// pairing code is never rebuilt away mid-entry. Switch/sign-out succeed by
// RESTARTING the pill into the new account, so the success note is brief.
let settingsInfo = null; // last relay:accountInfo payload (null while loading)
let slackConnectionInfo = null;
let slackConnectionLoaded = false;
let slackConnectionBusy = false;
let slackConnectionWaiting = false;
let slackConnectionWaitingSince = 0;
let slackConnectionRefreshSeq = 0;
let slackConnectionRefreshDeferred = false;
let slackConnectionError = "";
let slackDisconnectConfirm = false;
let chatAgentPreferences = null;
let chatAgentPreferencesError = "";
let providerAuthInfo = null; // safe status only; credentials stay in provider CLIs
let providerAuthBusy = "";
let providerDetailsOpen = "";
let providerAuthNote = "";
let providerAuthError = "";
let providerInventoryRefreshing = false;
let providerInventoryStale = true;
let providerInventoryCheckedAt = null;
let providerInventoryError = "";
let providerAuthPollTimer = null;
let settingsLoadSeq = 0;
let svPairOpen = false; // inline code-entry row revealed
let permTrayOpen = null; // vendor key whose mode tray is unfolded
let svDraft = ""; // pairing-code text preserved across re-renders
let svBusy = false; // pair / sign-out in flight
let svNote = ""; // success note while the pill restarts
let svError = "";
let svSignOutArmed = false;
let svSignOutTimer = null;
let svFocusCodeOnce = false;
let deviceApprovalInfo = null;
let deviceApprovalBusy = "";
let deviceApprovalError = "";
let deviceApprovalNote = "";
let chatConnectionBusy = false;
let chatConnectionError = "";
let chatConnectionNote = "";
const SLACK_CONNECTION_POLL_MS = 2000;
const SLACK_CONNECTION_WAIT_TIMEOUT_MS = 2 * 60 * 1000;
function slackConnectionReady(connection) {
return connection?.state === "connected"
&& connection?.team?.connected === true
&& connection?.personal?.state === "connected";
}
function paintSlackConnectionSurface() {
if (activeView !== "settings" && activeView !== "slack") return false;
if (!rendererSurfaceActive()) {
surfaceRenderDeferred = true;
return false;
}
if (activeView === "settings") renderSettings();
else renderSlack();
return true;
}
async function refreshSlackConnection({ preserveWaiting = true } = {}) {
if (payload.features?.slack !== true) return;
if (!window.relay.slackConnection) return;
if (!rendererSurfaceActive()) {
slackConnectionRefreshDeferred = true;
surfaceRenderDeferred = true;
return;
}
slackConnectionRefreshDeferred = false;
const seq = ++slackConnectionRefreshSeq;
let result = null;
try { result = await window.relay.slackConnection(); }
catch (error) { result = { ok:false, error:error?.message || "Could not check Slack." }; }
if (seq !== slackConnectionRefreshSeq) return;
const before = JSON.stringify({
connection:slackConnectionInfo,
loaded:slackConnectionLoaded,
waiting:slackConnectionWaiting,
error:slackConnectionError,
});
if (result?.ok && result.connection) {
slackConnectionInfo = result.connection;
slackConnectionLoaded = true;
slackConnectionError = "";
const waitExpired = slackConnectionWaitingSince > 0
&& Date.now() - slackConnectionWaitingSince >= SLACK_CONNECTION_WAIT_TIMEOUT_MS;
if (slackConnectionReady(result.connection) || !preserveWaiting || waitExpired) {
slackConnectionWaiting = false;
slackConnectionWaitingSince = 0;
}
} else {
slackConnectionLoaded = true;
slackConnectionError = result?.error || "Could not check Slack.";
}
const after = JSON.stringify({
connection:slackConnectionInfo,
loaded:slackConnectionLoaded,
waiting:slackConnectionWaiting,
error:slackConnectionError,
});
if (after !== before) {
paintSlackConnectionSurface();
}
}
async function connectSlackFromSurface() {
if (slackConnectionBusy || slackConnectionWaiting || !window.relay.slackConnect) return;
slackConnectionBusy = true;
slackConnectionError = "";
paintSlackConnectionSurface();
const team = slackConnectionInfo?.team;
let result = null;
try {
result = await window.relay.slackConnect(team?.connected
? { mode:"user", expectedTeamId:team.id }
: { mode:"combined" });
} catch (error) { result = { ok:false, error:error?.message || "Could not open Slack." }; }
slackConnectionBusy = false;
slackConnectionWaiting = result?.ok === true;
slackConnectionWaitingSince = result?.ok === true ? Date.now() : 0;
slackConnectionError = result?.ok === false ? result.error || "Could not open Slack." : "";
paintSlackConnectionSurface();
}
function resetSignOutArm() {
svSignOutArmed = false;
if (svSignOutTimer) { clearTimeout(svSignOutTimer); svSignOutTimer = null; }
}
function svPairRowHtml() {
const code = String(svDraft || "").replace(/[^A-Z0-9]/gi, "").toUpperCase().slice(0, 8);
const cells = Array.from({ length:8 }, (_, index) => `<input class="sv-code-cell" data-sv-code-index="${index}"
aria-label="Pairing code character ${index + 1}" type="text" inputmode="text" autocomplete="off" spellcheck="false"
maxlength="1" value="${esc(code[index] || "")}" ${svBusy ? "disabled" : ""} />`).join("");
return `
<div class="sv-pair" data-stop="1">
<div class="sv-pair-title">Paste your pairing code</div>
<div class="sv-pair-copy">Copy the 8-character code from the browser page.</div>
<div class="sv-code-cells" role="group" aria-label="Pairing code">${cells}</div>
<button class="sv-connect" type="button" id="svConnect" ${svBusy ? "disabled" : ""}>${svBusy ? "Connecting…" : "Connect"}</button>
</div>
<div class="sv-hint" data-stop="1">Need the setup page again? <button class="sv-setup-link" type="button" id="svSetupLink">Open here</button>.</div>`;
}
function providerInventoryStateText() {
if (providerInventoryError) {
return providerInventoryCheckedAt
? "Could not refresh integration health. Showing the last successful check."
: "Could not check integration health. Local configured MCPs are shown where available.";
}
if (providerInventoryRefreshing) {
return providerInventoryCheckedAt
? "Refreshing integration health in the background…"
: "Checking integration health in the background…";
}
if (providerInventoryStale && providerInventoryCheckedAt) return "Integration health is from an earlier check.";
return "";
}
function updateProviderInventoryState() {
const el = document.getElementById("svProviderInventoryState");
if (!el) return;
const text = providerInventoryStateText();
el.textContent = text;
el.classList.toggle("hidden", !text);
}
function providerRowsSignature() {
return JSON.stringify(providerAuthInfo?.providers || {});
}
function applyProviderAuthResult(result) {
if (!result || result.ok === false || !result.providers) return false;
const before = providerRowsSignature();
const previous = providerAuthInfo?.providers || {};
const providers = {};
for (const id of ["claude", "codex"]) {
const prior = previous[id] || {};
const fresh = result.providers[id];
providers[id] = fresh
? { ...prior, ...fresh, integrations: prior.integrations || fresh.integrations, authLoaded:true }
: prior;
}
providerAuthInfo = { ok:true, providers };
return providerRowsSignature() !== before;
}
function applyProviderInventoryResult(result) {
if (!result || result.ok === false || !result.providers) return false;
const before = providerRowsSignature();
const previous = providerAuthInfo?.providers || {};
const providers = {};
for (const id of ["claude", "codex"]) {
const prior = previous[id] || {};
const inventory = result.providers[id]?.integrations;
providers[id] = {
id,
label:id === "claude" ? "Claude Code" : "Codex",
enabled:true,
installed:false,
connected:false,
detail:"Checking connection…",
busy:false,
authLoaded:false,
...prior,
integrations:inventory || prior.integrations || { mcpServers:[], apps:[] },
};
}
providerAuthInfo = { ok:true, providers };
return providerRowsSignature() !== before;
}
function connectionDisclosureHtml({ id, label, logo, meta, stateHtml, detailHtml }) {
const open = providerDetailsOpen === id;
return `<div class="sv-provider" data-provider-row="${esc(id)}">
<button class="sv-agent-provider-toggle" type="button" data-provider-details="${esc(id)}" aria-expanded="${open ? "true" : "false"}" aria-controls="svProviderDetail-${esc(id)}">
<img class="sv-agent-provider-logo" src="${esc(logo)}" alt="" />
<span class="sv-agent-provider-summary"><span class="sv-provider-name">${esc(label)}</span><span class="sv-agent-provider-count">${esc(meta)}</span></span>
${stateHtml}
<svg class="sv-agent-provider-chev" width="12" height="7" viewBox="0 0 12 7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.5 1.5L6 5.5L10.5 1.5"/></svg>
</button>
<div class="sv-agent-provider-detail${open ? " open" : ""}" id="svProviderDetail-${esc(id)}" aria-hidden="${open ? "false" : "true"}"${open ? "" : " inert"}><div class="sv-agent-provider-detail-inner"><div class="sv-agent-provider-detail-body">
${detailHtml}
</div></div></div>
</div>`;
}
function providerConnectionRowsHtml() {
const providers = providerAuthInfo && providerAuthInfo.providers ? providerAuthInfo.providers : {};
return ["claude", "codex"].map((id) => {
const item = providers[id] || { id, label: id === "claude" ? "Claude Code" : "Codex", enabled:true, installed:false, connected:false, detail:"Checking connection…", busy:false, authLoaded:false };
const authLoaded = item.authLoaded === true;
const busy = providerAuthBusy === id || item.busy;
const requirement = item.requirement || (id === "claude" ? "Claude subscription required" : "ChatGPT subscription required");
const status = !item.enabled
? `${item.connected ? `${item.detail} · ` : ""}Disabled for Tasks`
: busy
? item.busyDetail || "Waiting for authorization…"
: item.connected
? `${item.detail || "Subscription connected"} · Ready for Tasks`
: item.detail || "Not signed in";
const integrations = item.integrations && typeof item.integrations === "object" ? item.integrations : {};
const mcpServers = Array.isArray(integrations.mcpServers) ? integrations.mcpServers : [];
const apps = Array.isArray(integrations.apps) ? integrations.apps : [];
const totalConnections = mcpServers.length + apps.length;
const integrationList = (label, values) => {
if (!values.length) return "";
const list = values.map((entry) => {
const name = typeof entry === "string" ? entry : entry && entry.name;
return `<li title="${esc(name || "")}">${esc(name || "")}</li>`;
}).join("");
return `<div class="sv-agent-provider-group">
<div class="sv-agent-provider-group-head"><span>${esc(label)}</span><span>${esc(values.length)}</span></div>
<ul class="sv-agent-provider-integrations">${list}</ul>
</div>`;
};
const enableLabel = item.enabled ? "Disable" : "Enable";
const signInLabel = item.signInAction || (id === "claude" ? "Sign in to Claude Code" : "Sign in to Codex");
const connectLabel = busy
? "Signing in…"
: item.connected
? "Connected"
: item.authState === "api_billing"
? (id === "claude" ? "Use Claude subscription" : "Use ChatGPT subscription")
: signInLabel;
const connectDisabled = busy || !authLoaded || !item.installed || !item.enabled || item.connected;
const needsInstallation = authLoaded && !item.installed;
const healthy = item.enabled && item.connected && !busy;
const logo = id === "claude" ? "claudeCodeMark.svg" : "codexMark.svg";
const countLabel = totalConnections === 1 ? "1 connection" : `${totalConnections} connections`;
const headerMeta = healthy ? countLabel : status;
const headerState = healthy
? '<span class="sv-agent-provider-ready">Ready</span>'
: `<span class="sv-agent-provider-state">${esc(busy ? "Connecting" : item.enabled ? "Needs attention" : "Disabled")}</span>`;
const detailNote = healthy ? "" : `<div class="sv-agent-provider-note">${esc(requirement)}${status ? ` · ${esc(status)}` : ""}</div>`;
return connectionDisclosureHtml({
id,
label:item.label,
logo,
meta:headerMeta,
stateHtml:headerState,
detailHtml:`${detailNote}
${integrationList(id === "claude" ? "MCPs and account connectors" : "Local MCPs", mcpServers)}
${integrationList("Connected apps", apps)}
<div class="sv-agent-provider-actions">
${needsInstallation ? `<button class="sv-provider-btn primary" type="button" data-provider-setup-prompt="${id}">Copy setup prompt</button>` : ""}
${item.connected ? "" : `<button class="sv-provider-btn primary" type="button" data-provider-connect="${id}" ${connectDisabled ? "disabled" : ""}>${esc(connectLabel)}</button>`}
<button class="sv-provider-btn" type="button" data-provider-enable="${id}" data-enabled="${item.enabled ? "1" : "0"}" ${busy || !authLoaded ? "disabled" : ""}>${enableLabel}</button>
</div>`,
});
}).join("");
}
function chatConnectionRowsHtml(info) {
if (!info?.paired || (!window.relay.connectChatGPT && !window.relay.connectClaude)) return "";
const chatgpt = connectionDisclosureHtml({
id:"chatgpt-chat",
label:"ChatGPT",
logo:"codexMark.svg",
meta:"Relay in ChatGPT is coming soon.",
stateHtml:'<span class="sv-agent-provider-state">Coming soon</span>',
detailHtml:`<div class="sv-agent-provider-note">Relay connections for ChatGPT are not available yet.</div>
<div class="sv-agent-provider-actions"><button class="sv-provider-btn" type="button" disabled>Coming soon</button></div>`,
});
const claudeMeta = chatConnectionNote || chatConnectionError || "Use Relay in your Claude chats.";
const claude = connectionDisclosureHtml({
id:"claude-chat",
label:"Claude",
logo:"claudeCodeMark.svg",
meta:claudeMeta,
stateHtml:`<span class="sv-agent-provider-state ${chatConnectionBusy ? "busy" : "available"}">${chatConnectionBusy ? "Opening" : "Set up"}</span>`,
detailHtml:`<div class="sv-agent-provider-note">Relay copies its connector address and opens Claude at the right setup page.</div>
${chatConnectionNote ? `<div class="sv-agent-provider-note success">${esc(chatConnectionNote)}</div>` : ""}
${chatConnectionError ? `<div class="sv-agent-provider-note error">${esc(chatConnectionError)}</div>` : ""}
<div class="sv-agent-provider-actions"><button class="sv-provider-btn primary" type="button" id="svConnectClaude" ${chatConnectionBusy || !window.relay.connectClaude ? "disabled" : ""}>${chatConnectionBusy ? "Opening…" : "Connect Claude"}</button></div>`,
});
return `${chatgpt}${claude}`;
}
function connectionsHtml(info, includeAgentProviders) {
const rows = `${includeAgentProviders ? providerConnectionRowsHtml() : ""}${chatConnectionRowsHtml(info)}`;
if (!rows) return "";
return `<section class="sv-provider-section" data-stop="1">
<div class="sv-open-title">Connections</div>
<div class="sv-provider-list sv-agent-provider-list">${rows}</div>
${includeAgentProviders ? `<div class="sv-hint${providerInventoryRefreshing || providerInventoryStale || providerInventoryError ? "" : " hidden"}" id="svProviderInventoryState">${esc(providerInventoryStateText())}</div>` : ""}
${includeAgentProviders && providerAuthNote ? `<div class="sv-note">${esc(providerAuthNote)}</div>` : ""}
${includeAgentProviders && providerAuthError ? `<div class="row-err sv-err">${esc(providerAuthError)}</div>` : ""}
</section>`;
}
async function connectClaudeFromSettings() {
if (chatConnectionBusy || !window.relay.connectClaude) return;
chatConnectionBusy = true;
chatConnectionError = "";
chatConnectionNote = "";
renderSettings();
try {
const result = await window.relay.connectClaude();
if (!result?.ok) throw new Error(result?.error || "Claude setup could not be opened.");
chatConnectionNote = "Relay’s address is copied. In Claude, choose +, Add custom connector, then paste it.";
} catch (error) {
chatConnectionError = error?.message || "Claude setup could not be opened.";
} finally {
chatConnectionBusy = false;
renderSettings();
}
}
// Notification preferences. Unlike a ✕ (a snooze that the next relay revokes),
// these are durable choices: nothing in the product undoes them but the user.
function svSwitchHtml(key, checked, { disabled = false, label = "", inverted = false } = {}) {
return `<button class="sv-switch" type="button" role="switch" data-quiet="${esc(key)}"${inverted ? ' data-quiet-inverted="1"' : ""}
aria-checked="${checked ? "true" : "false"}" aria-label="${esc(label)}" ${disabled ? "disabled" : ""}></button>`;
}
function quietPrefsHtml(info) {
const hidden = info.pillHidden === true;
const muted = info.soundsMuted === true;
// Without a status-area icon there is no way to bring a hidden pill back, so the
// switch is refused rather than offered — the same law the ✕ already follows.
const canHide = info.canHide !== false;
const surface = info.reopenSurface || "the Relay icon";
return `
<div class="sv-quiet-section" id="quietPrefs" data-stop="1">
<div class="sv-quiet-title">Notifications</div>
<div class="sv-quiet-list">
<div class="sv-quiet-row">
<span class="sv-quiet-copy">
<span class="sv-quiet-name">Show Relay automatically</span>
<span class="sv-quiet-note">${canHide
? "When new messages arrive."
: `Always shown because ${esc(surface)} is unavailable.`}</span>
</span>
${svSwitchHtml("pillHidden", !hidden, { disabled: !canHide, label: "Show Relay automatically", inverted: true })}
</div>
<div class="sv-quiet-row">
<span class="sv-quiet-copy">
<span class="sv-quiet-name">Play sounds</span>
<span class="sv-quiet-note">For new messages.</span>
</span>
${svSwitchHtml("soundsMuted", !muted, { label: "Play sounds", inverted: true })}
</div>
</div>
</div>`;
}
function chatAgentDefaultsHtml() {
if (payload.features?.requests !== true || !window.relay.chatAgentPreferences) return "";
if (!chatAgentPreferences) return `<div class="sv-open-section" data-stop="1"><div class="sv-open-title">Chat agents</div><div class="sv-open-intro">${esc(chatAgentPreferencesError || "Loading model defaults…")}</div></div>`;
const rows = [
{ key:"claude", label:"@Claude", logo:"claudeCodeMark.svg", models:[["opus","Opus 5"],["fable","Fable 5"],["sonnet","Sonnet 5"],["haiku","Haiku 4.5"]], efforts:["low","medium","high","xhigh","max"] },
{ key:"codex", label:"@Codex", logo:"codexMark.svg", models:[["gpt-5.6-sol","5.6 Sol"],["gpt-5.6-terra","5.6 Terra"],["gpt-5.6-luna","5.6 Luna"],["gpt-5.5","5.5"],["gpt-5.4","5.4"]], efforts:["auto","low","medium","high","xhigh"] },
];
return `<div class="sv-open-section sv-agent-defaults" data-stop="1">
<div class="sv-open-title">Chat agents</div>
<div class="sv-open-intro">Defaults for Work sessions that run on this computer.</div>
<div class="sv-open-list">${rows.map((row) => {
const current = chatAgentPreferences[row.key] || {};
return `<div class="sv-open-row">
<span class="sv-agent-identity"><img class="sv-open-logo" src="${row.logo}" alt="" /><span class="sv-open-copy"><span class="sv-open-name">${row.label}</span></span></span>
<span class="sv-agent-fields">
<label class="sv-agent-field"><span class="sv-agent-field-label">Model</span><select class="sv-agent-select" data-chat-agent-model="${row.key}" aria-label="${row.label} model">${row.models.map(([value,label]) => `<option value="${value}"${current.model === value ? " selected" : ""}>${label}</option>`).join("")}</select></label>
<label class="sv-agent-field"><span class="sv-agent-field-label">Thinking</span><select class="sv-agent-select" data-chat-agent-effort="${row.key}" aria-label="${row.label} thinking">${row.efforts.map((value) => `<option value="${value}"${current.effort === value ? " selected" : ""}>${value}</option>`).join("")}</select></label>
</span>
</div>`;
}).join("")}</div>
${chatAgentPreferencesError ? `<div class="row-err sv-err">${esc(chatAgentPreferencesError)}</div>` : ""}
</div>`;
}
function deviceApprovalsHtml(info) {
// Device trust is an E2EE-only concept. Keep the entire section absent
// until the server explicitly says encrypted delivery is available; an
// `e2ee_disabled` response is product state, not an error the user needs
// to see in ordinary Settings.
if (!info?.paired || !window.relay.e2eeDeviceApprovals || deviceApprovalInfo?.available !== true) return "";
const pending = Array.isArray(deviceApprovalInfo?.pendingDevices) ? deviceApprovalInfo.pendingDevices : [];
const body = !deviceApprovalInfo
? '<div class="sv-device-row"><span class="sv-device-copy"><span class="sv-device-note">Checking trusted devices…</span></span></div>'
: pending.length
? pending.map((device) => {
const enrolled = device.enrolledAt ? new Date(device.enrolledAt).toLocaleDateString() : "recently";
const shortId = String(device.deviceId || "").slice(-6);
const busy = deviceApprovalBusy === device.deviceId;
return `<div class="sv-device-row">
<span class="sv-device-shield" aria-hidden="true">◇</span>
<span class="sv-device-copy">
<span class="sv-device-name">New Relay device</span>
<span class="sv-device-note">Paired ${esc(enrolled)}${shortId ? ` · ${esc(shortId)}` : ""}. Approving lets encrypted messages and history include it.</span>
</span>
<button class="sv-device-approve" type="button" data-device-approve="${esc(device.deviceId)}" ${deviceApprovalBusy ? "disabled" : ""}>${busy ? "Approving…" : "Approve"}</button>
</div>`;
}).join("")
: '<div class="sv-device-row"><span class="sv-device-shield" aria-hidden="true">✓</span><span class="sv-device-copy"><span class="sv-device-name">No devices awaiting approval</span><span class="sv-device-note">New devices cannot join encrypted delivery until a trusted device vouches for them.</span></span></div>';
return `<section class="sv-device-section" data-stop="1">
<div class="sv-open-title">Device approvals</div>
<div class="sv-device-list">${body}</div>
${deviceApprovalNote ? `<div class="sv-note">${esc(deviceApprovalNote)}</div>` : ""}
${deviceApprovalError ? `<div class="row-err sv-err">${esc(deviceApprovalError)}</div>` : ""}
</section>`;
}
function slackSettingsHtml(info) {
if (payload.features?.slack !== true) return "";
if (!info?.paired || !window.relay.slackConnection) return "";
const connection = slackConnectionInfo;
const state = connection?.state || "disconnected";
const team = connection?.team;
const teamConnected = team?.connected === true;
const personalConnected = connection?.personal?.state === "connected" && state === "connected";
const teamName = team?.name || "your team";
const connectLabel = slackConnectionWaiting ? "Waiting for Slack…" : state === "paused" ? "Reconnect" : "Connect";
const connectionLabel = personalConnected
? `${teamName} · Connected`
: teamConnected
? `${teamName} · Finish connecting`
: state === "paused"
? `${teamName} · Needs attention`
: "Not connected";
return `<section class="sv-slack-section" data-stop="1">
<div class="sv-open-title">Slack</div>
<div class="sv-slack-card${slackDisconnectConfirm ? " is-confirming" : ""}" id="svSlackCard">
<div class="sv-slack-row">
<img class="sv-slack-logo" src="slackMark.png" alt="Slack" />
<span class="sv-slack-copy"><span class="sv-slack-name">Slack</span><span class="sv-slack-note">${esc(connectionLabel)}</span></span>
${personalConnected
? `<button class="sv-slack-action" type="button" id="svSlackDisconnect" aria-controls="svSlackDisconnectConfirmPanel" aria-expanded="${slackDisconnectConfirm ? "true" : "false"}" ${slackConnectionBusy || slackDisconnectConfirm ? "disabled" : ""}>Disconnect</button>`
: `<button class="sv-slack-action" type="button" id="svSlackConnect" ${slackConnectionBusy || slackConnectionWaiting ? "disabled" : ""}>${esc(connectLabel)}</button>`}
</div>
<div class="sv-slack-foot">New Slack messages sync with Relay, and Relays send from your Slack account. Earlier Slack history is not imported.</div>
${personalConnected ? `<div class="sv-slack-confirm" id="svSlackDisconnectConfirmPanel" role="region" aria-labelledby="slackDisconnectTitle" aria-hidden="${slackDisconnectConfirm ? "false" : "true"}" ${slackDisconnectConfirm ? "" : "inert"}>
<div class="sv-slack-confirm-clip">
<div class="sv-slack-confirm-content">
<div>
<div class="sv-slack-confirm-title" id="slackDisconnectTitle">Disconnect Slack?</div>
<div class="sv-slack-confirm-copy">New messages and Relay deliveries will stop syncing. Messages already in Relay will stay.</div>
</div>
<div class="sv-slack-confirm-actions">
<button class="sv-slack-action" type="button" id="svSlackDisconnectCancel" ${slackConnectionBusy ? "disabled" : ""}>Keep connected</button>
<button class="sv-slack-action sv-slack-confirm-danger" type="button" id="svSlackDisconnectConfirm" ${slackConnectionBusy ? "disabled" : ""}>${slackConnectionBusy ? "Disconnecting…" : "Disconnect Slack"}</button>
</div>
</div>
</div>
</div>` : ""}
</div>
${slackConnectionError ? `<div class="row-err sv-err">${esc(slackConnectionError)}</div>` : ""}
</section>`;
}
function renderSettings() {
if (activeView !== "settings") return;
const info = settingsInfo;
if (!info) {
settingsViewEl.innerHTML = '<div class="td-quiet">Loading account…</div>';
return;
}
let html = "";
if (info.paired) {
const name = info.name || info.email || "Relay account";
html += `
<div class="sv-account">
<span class="sv-avatar">${esc(cvInitials(info.name, info.email))}</span>
<span class="sv-id">
<span class="sv-name">${esc(name)}</span>
${info.email ? `<span class="sv-email">${esc(info.email)}</span>` : ""}
<span class="sv-device">This device · ${esc(info.deviceName || "")}</span>
</span>
</div>
<div class="open-actions sv-actions" data-stop="1" aria-label="Account actions">
<button class="oa-item" type="button" id="svAccount" ${svBusy ? "disabled" : ""}>Account Settings…</button>
<button class="oa-item oa-primary" type="button" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
<div class="oa-sep" role="separator"></div>
<button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
</div>`;
} else {
html += `
<div class="sv-head"><span class="sv-title">Sign in to Relay.</span></div>
<div class="sv-copy">This device isn’t connected to an account yet. Sign in from your browser, then enter the pairing code here.</div>
<div class="open-actions sv-actions" data-stop="1" aria-label="Account actions">
<button class="oa-item oa-primary" type="button" id="svSwitch" ${svBusy ? "disabled" : ""}>Sign In to Relay</button>
</div>`;
}
if (svPairOpen) html += svPairRowHtml();
if (svNote) html += `<div class="sv-note">${esc(svNote)}</div>`;
html += `<div class="row-err sv-err" id="svErr">${esc(svError)}</div>`;
html += slackSettingsHtml(info);
html += deviceApprovalsHtml(info);
// ONE CONNECTIONS SURFACE. Claude Code and Codex are the subscription
// runtimes behind Tasks; ChatGPT and Claude are the chat surfaces. They
// share one contained disclosure list because they are all ways Relay
// connects to an agent, even though each provider keeps its own setup path.
// The native-runtime rows remain gated while their product feature is off;
// the chat rows stay available to every paired Companion account.
html += connectionsHtml(info, payload.features?.agentConnections === true);
// Version line doubles as the manual update backstop: if the auto-updater
// has not refreshed this machine, say so here and make it one click.
// A machine whose updates keep FAILING outranks one that merely has an
// update pending: offering a cheerful "Update and Restart" that has already
// failed N times, without saying so, is how someone sits 62 versions behind
// without ever knowing (field report, Shane 2026-08-11). Say what is
// happening, keep the retry — the usual cause is one they can fix.
if (info.updateFailing) {
html += `<div class="sv-version sv-update">
<span>Relay ${esc(info.version || "")} · ${esc(String(info.updateFailing.count))} failed attempts to install ${esc(info.updateFailing.target)}</span>
<button class="oa-item oa-primary sv-update-btn" type="button" id="svUpdate" ${info.updating ? "disabled" : ""}>${info.updating ? "Updating…" : "Try Again"}</button>
</div>`;
} else if (info.updateAvailable) {
html += `<div class="sv-version sv-update">
<span>Relay ${esc(info.version || "")} · update ${esc(info.updateAvailable)} ready</span>
<button class="oa-item oa-primary sv-update-btn" type="button" id="svUpdate" ${info.updating ? "disabled" : ""}>${info.updating ? "Updating…" : "Update and Restart"}</button>
</div>`;
}
// QUIET MODE. Two durable per-device preferences, placed after the version block
// so nothing above it moves. Both say plainly what happens and — for hiding —
// how to get the pill back, because Settings lives INSIDE the pill it hides.
html += quietPrefsHtml(info);
// WHERE RELAYS OPEN. Each installed app is independent and both start on.
// That makes the visible controls match the actions beneath every Relay;
// at least one stays enabled so the Relay never loses its open action.
const selectedAgentApps = agentAppSelection();
html += `
<div class="sv-open-section" id="protoPrefs" data-stop="1">
<div class="sv-open-title">Open Relays with</div>
<div class="sv-open-intro">Choose which app buttons appear on each Relay.</div>
<div class="sv-open-list">
${AGENT_APP_OPTIONS.map((app) => {
const available = appAvailable(app);
const on = selectedAgentApps.includes(app);
const last = on && selectedAgentApps.length === 1;
const logo = app === "Codex" ? "codexMark.svg" : "claudeCodeMark.svg";
const why = !available ? appUnavailableReason(app) : last ? "At least one app must stay on." : "";
return `<div class="sv-open-row${available ? "" : " off"}">
<img class="sv-open-logo" src="${logo}" alt="" />
<span class="sv-open-copy"><span class="sv-open-name">${app}</span>${why ? `<span class="sv-open-why">${esc(why)}</span>` : ""}</span>
<button class="sv-switch" type="button" role="switch" data-agent-app="${app}" aria-checked="${on ? "true" : "false"}" aria-label="Open Relays with ${app}" ${!available || last ? "disabled" : ""}></button>
</div>`;
}).join("")}
</div>
</div>`;
html += chatAgentDefaultsHtml();
// WHAT TASKS MAY DO. One value row per app — the same species as the
// "Open Relays with" rows above (logo, name, current state), under the
// shared section title style. Tapping a row unfolds the standard
// open-actions tray inline with each vendor's real modes in their own
// words — never ours; picking one (or tapping again) folds it back. The
// four-line explainer died in review: nobody read it, and it made this
// the only section wearing a caption header (David, 2026-08-18). One
// intro line still says what a request is.
if (payload.features?.requests === true) html += `
<div class="sv-open-section" id="permPrefs" data-stop="1">
<div class="sv-open-title">What Tasks may do</div>
<div class="sv-open-intro">Each app’s own permission mode, for Tasks you Start.</div>
<div class="sv-open-list">
${PERM_VENDORS.map((v) => {
const cur = v.modes.find((m) => m.id === permMode(v.key)) || v.modes[0];
const open = permTrayOpen === v.key;
const logo = /codex/i.test(v.key) ? "codexMark.svg" : "claudeCodeMark.svg";
return `<div class="perm-app${open ? " open" : ""}">
<button type="button" class="sv-open-row perm-vrow" data-perm-open="${esc(v.key)}" aria-expanded="${open ? "true" : "false"}">
<img class="sv-open-logo" src="${logo}" alt="" />
<span class="sv-open-name">${esc(v.label)}</span>
<span class="perm-value">${open ? "" : esc(cur.name)}<svg class="perm-chev" width="8" height="12" viewBox="0 0 8 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 2.5L5.5 6L2 9.5"/></svg></span>
</button>
${open ? `<div class="open-actions perm-tray">
${v.modes.map((m, i) => `${i ? '<div class="oa-sep"></div>' : ""}<button type="button" class="oa-item ap-row perm-row${cur.id === m.id ? " on" : ""}" data-perm="${esc(v.key)}" data-mode="${esc(m.id)}">
<span class="ap-sq" aria-hidden="true" style="${cur.id === m.id ? `background:${vendorSq(v.sample)}` : ""}"></span>
<span class="ap-name">${esc(m.name)}</span>
<svg class="ap-check" width="11" height="9" viewBox="0 0 11 9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4.5L4 7.5L10 1.5"/></svg>
</button>`).join("")}
</div>` : ""}
</div>`;
}).join("")}
</div>
</div>`;
html += `<div class="sv-colophon">
<svg class="rd-end" width="16" height="4" viewBox="0 0 16 4" aria-hidden="true"><rect x="0" width="4" height="4"/><rect x="6" width="4" height="4"/><rect x="12" width="4" height="4"/></svg>
<span class="sv-version">${esc(info.version ? `Relay ${info.version}` : "Relay")}</span>
</div>`;
settingsViewEl.innerHTML = html;
for (const row of settingsViewEl.querySelectorAll("[data-perm-open]")) {
row.addEventListener("click", (e) => {
e.stopPropagation();
const key = row.getAttribute("data-perm-open");
permTrayOpen = permTrayOpen === key ? null : key;
renderSettings();
});
}
for (const b of settingsViewEl.querySelectorAll("[data-perm]")) {
b.addEventListener("click", (e) => {
e.stopPropagation();
setProtoPref(`proto.perm.${b.getAttribute("data-perm")}`, b.getAttribute("data-mode"));
// Choosing folds the tray: the row's value line is the confirmation.
permTrayOpen = null;
renderSettings();
});
}
for (const sw of settingsViewEl.querySelectorAll("[data-agent-app]")) {
sw.addEventListener("click", (e) => {
e.stopPropagation();
setAgentAppEnabled(sw.getAttribute("data-agent-app"), sw.getAttribute("aria-checked") !== "true");
renderSettings();
});
}
for (const select of settingsViewEl.querySelectorAll("[data-chat-agent-model],[data-chat-agent-effort]")) {
select.addEventListener("change", async (event) => {
event.stopPropagation();
const provider = select.getAttribute("data-chat-agent-model") || select.getAttribute("data-chat-agent-effort");
if (!provider || !chatAgentPreferences || !window.relay.saveChatAgentPreferences) return;
const route = { ...(chatAgentPreferences[provider] || {}) };
if (select.hasAttribute("data-chat-agent-model")) route.model = select.value;
else route.effort = select.value;
chatAgentPreferences = { ...chatAgentPreferences, [provider]:route };
chatAgentPreferencesError = "";
try { chatAgentPreferences = await window.relay.saveChatAgentPreferences({ [provider]:route }); }
catch (error) { chatAgentPreferencesError = error?.message || "Could not save that agent default."; }
renderSettings();
});
}
wireSettings();
}
function wireSettings() {
const upd = document.getElementById("svUpdate");
if (upd) {
upd.addEventListener("click", async () => {
upd.disabled = true;
upd.textContent = "Updating…";
try { await window.relay.updateNow(); } catch {}
});
}
for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
z.addEventListener("click", (e) => e.stopPropagation());
}
for (const sw of settingsViewEl.querySelectorAll("[data-quiet]")) {
sw.addEventListener("click", () => toggleQuietPref(sw.getAttribute("data-quiet"), sw));
}
const connectClaude = document.getElementById("svConnectClaude");
if (connectClaude) connectClaude.addEventListener("click", connectClaudeFromSettings);
const slackConnect = document.getElementById("svSlackConnect");
if (slackConnect) slackConnect.addEventListener("click", connectSlackFromSurface);
const slackDisconnect = document.getElementById("svSlackDisconnect");
const slackCard = document.getElementById("svSlackCard");
const slackDisconnectPanel = document.getElementById("svSlackDisconnectConfirmPanel");
const slackDisconnectCancel = document.getElementById("svSlackDisconnectCancel");
const setSlackDisconnectExpanded = (expanded, { returnFocus = false } = {}) => {
slackDisconnectConfirm = expanded;
slackCard?.classList.toggle("is-confirming", expanded);
slackDisconnect?.setAttribute("aria-expanded", expanded ? "true" : "false");
if (slackDisconnect) slackDisconnect.disabled = expanded || slackConnectionBusy;
if (slackDisconnectPanel) {
slackDisconnectPanel.setAttribute("aria-hidden", expanded ? "false" : "true");
if (expanded) slackDisconnectPanel.removeAttribute("inert");
else slackDisconnectPanel.setAttribute("inert", "");
}
if (expanded) requestAnimationFrame(() => slackDisconnectCancel?.focus({ preventScroll:true }));
else if (returnFocus) slackDisconnect?.focus({ preventScroll:true });
};
if (slackDisconnect) slackDisconnect.addEventListener("click", () => setSlackDisconnectExpanded(true));
const cancelSlackDisconnect = () => setSlackDisconnectExpanded(false, { returnFocus:true });
if (slackDisconnectCancel) slackDisconnectCancel.addEventListener("click", cancelSlackDisconnect);
if (slackCard) slackCard.addEventListener("keydown", (event) => {
if (event.key === "Escape" && slackDisconnectConfirm && !slackConnectionBusy) {
event.preventDefault();
cancelSlackDisconnect();
}
});
const slackDisconnectConfirmButton = document.getElementById("svSlackDisconnectConfirm");
if (slackDisconnectConfirmButton) slackDisconnectConfirmButton.addEventListener("click", async () => {
if (slackConnectionBusy || !window.relay.slackDisconnect) return;
slackConnectionBusy = true;
slackConnectionError = "";
renderSettings();
let result = null;
try { result = await window.relay.slackDisconnect(); }
catch (error) { result = { ok:false, error:error?.message || "Could not disconnect Slack." }; }
slackConnectionBusy = false;
slackDisconnectConfirm = false;
slackConnectionError = result?.ok === false ? result.error || "Could not disconnect Slack." : "";
if (result?.ok) slackConnectionInfo = { state:"disconnected" };
renderSettings();
});
for (const button of settingsViewEl.querySelectorAll("[data-provider-connect]")) {
button.addEventListener("click", () => connectProviderFromSettings(button.getAttribute("data-provider-connect")));
}
for (const button of settingsViewEl.querySelectorAll("[data-provider-setup-prompt]")) {
button.addEventListener("click", async () => {
const provider = button.getAttribute("data-provider-setup-prompt");
try {
await navigator.clipboard.writeText(providerSetupPrompt(provider));
providerAuthNote = `${providerAppName(provider)} setup prompt copied.`;
providerAuthError = "";
} catch {
providerAuthError = "Could not copy the setup prompt.";
}
renderSettings();
});
}
for (const button of settingsViewEl.querySelectorAll("[data-provider-details]")) {
button.addEventListener("click", () => {
const provider = button.getAttribute("data-provider-details");
const willOpen = button.getAttribute("aria-expanded") !== "true";
providerDetailsOpen = willOpen ? provider : "";
for (const other of settingsViewEl.querySelectorAll("[data-provider-details]")) {
const open = willOpen && other === button;
other.setAttribute("aria-expanded", open ? "true" : "false");
const detail = document.getElementById(other.getAttribute("aria-controls"));
if (detail) {
detail.classList.toggle("open", open);
detail.setAttribute("aria-hidden", open ? "false" : "true");
detail.toggleAttribute("inert", !open);
}
}
});
}
for (const button of settingsViewEl.querySelectorAll("[data-provider-enable]")) {
button.addEventListener("click", () => setProviderEnabledFromSettings(
button.getAttribute("data-provider-enable"),
button.getAttribute("data-enabled") !== "1",
));
}
for (const button of settingsViewEl.querySelectorAll("[data-device-approve]")) {
button.addEventListener("click", () => approveDeviceFromSettings(button.getAttribute("data-device-approve")));
}
// Managing addresses lives on the web (Clerk owns verification and the
// reverification challenge), so the pill's job is to get you there rather
// than to reimplement it. Without this the account panel showed an email the
// user had no way to change from the surface they actually live in.
const accountEl = document.getElementById("svAccount");
if (accountEl) {
accountEl.addEventListener("click", () => {
if (window.relay.openUrl) {
window.relay.openUrl((settingsInfo && settingsInfo.settingsPath) || "/account/settings");
}
});
}
const switchEl = document.getElementById("svSwitch");
if (switchEl) switchEl.addEventListener("click", beginPairFlow);
const signOutEl = document.getElementById("svSignOut");
if (signOutEl) signOutEl.addEventListener("click", onSignOutClick);
const connectEl = document.getElementById("svConnect");
if (connectEl) connectEl.addEventListener("click", submitPairCode);
const setupLink = document.getElementById("svSetupLink");
if (setupLink) setupLink.addEventListener("click", () => {
if (window.relay.openUrl) window.relay.openUrl((settingsInfo && settingsInfo.setupPath) || "/app/setup");
});
const codeCells = [...settingsViewEl.querySelectorAll("[data-sv-code-index]")];
if (codeCells.length) {
const saveCells = () => {
svDraft = codeCells.map((cell) => cell.value).join("");
svError = "";
const errEl = document.getElementById("svErr");
if (errEl) errEl.textContent = ""; // clear inline, no rebuild mid-typing
};
const placeCode = (raw, startIndex = 0) => {
const characters = String(raw || "").replace(/[^A-Z0-9]/gi, "").toUpperCase().slice(0, 8 - startIndex);
characters.split("").forEach((character, offset) => { codeCells[startIndex + offset].value = character; });
saveCells();
const nextIndex = Math.min(startIndex + characters.length, codeCells.length - 1);
codeCells[nextIndex].focus();
codeCells[nextIndex].select();
};
for (const codeCell of codeCells) {
const index = Number(codeCell.getAttribute("data-sv-code-index"));
codeCell.addEventListener("mousedown", () => setInteractive(true));
codeCell.addEventListener("paste", (event) => {
event.preventDefault();
const pasted = event.clipboardData?.getData("text") || "";
placeCode(pasted, pasted.replace(/[^A-Z0-9]/gi, "").length >= 8 ? 0 : index);
});
codeCell.addEventListener("input", () => {
const typed = String(codeCell.value || "").replace(/[^A-Z0-9]/gi, "").toUpperCase();
codeCell.value = typed.slice(-1);
saveCells();
if (codeCell.value && index < codeCells.length - 1) codeCells[index + 1].focus();
});
codeCell.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); submitPairCode(); return; }
if (event.key === "ArrowLeft" && index > 0) { event.preventDefault(); codeCells[index - 1].focus(); return; }
if (event.key === "ArrowRight" && index < codeCells.length - 1) { event.preventDefault(); codeCells[index + 1].focus(); return; }
if (event.key === "Backspace" && !codeCell.value && index > 0) {
event.preventDefault();
codeCells[index - 1].value = "";
saveCells();
codeCells[index - 1].focus();
}
});
}
if (svFocusCodeOnce) {
svFocusCodeOnce = false;
const firstEmpty = codeCells.find((cell) => !cell.value) || codeCells[codeCells.length - 1];
setTimeout(() => firstEmpty.focus(), 30);
}
}
}
function stopProviderAuthPolling() {
if (providerAuthPollTimer) clearTimeout(providerAuthPollTimer);
providerAuthPollTimer = null;
}
async function refreshProviderInventory({ force = false, seq = settingsLoadSeq } = {}) {
if (!window.relay.providerInventory) return;
providerInventoryRefreshing = true;
providerInventoryError = "";
updateProviderInventoryState();
let result = null;
try { result = await window.relay.providerInventory({ refresh:true, force }); } catch (error) {
result = { ok:false, error:error?.message || "Could not refresh integrations." };
}
if (seq !== settingsLoadSeq) return;
providerInventoryRefreshing = false;
providerInventoryError = result?.error || (result?.ok === false ? "Could not refresh integrations." : "");
if (result?.checkedAt) providerInventoryCheckedAt = result.checkedAt;
if (typeof result?.stale === "boolean") providerInventoryStale = result.stale;
const changed = applyProviderInventoryResult(result);
if (activeView === "settings" && changed) renderSettings();
else updateProviderInventoryState();
}
async function refreshProviderAuth({ poll = false } = {}) {
let result = null;
try { result = window.relay.providerAuthStatus ? await window.relay.providerAuthStatus() : null; } catch (error) {
result = { ok:false, error:error?.message || "Could not check agent connections." };
}
const previousError = providerAuthError;
if (result?.ok === false) providerAuthError = result.error || "Could not check agent connections.";
else if (result?.providers) providerAuthError = "";
const changed = applyProviderAuthResult(result);
if (providerAuthInfo?.providers && Object.values(providerAuthInfo.providers).every((item) => item?.connected || !item?.enabled)) {
providerAuthError = "";
}
for (const provider of ["claude", "codex"]) {
if (providerReadyForWork(provider)) settleWorkProviderPrompts(provider);
}
if (activeView === "settings" && (changed || providerAuthError !== previousError)) renderSettings();
if (poll && providerAuthBusy) {
const provider = providerAuthBusy;
const item = providerAuthInfo?.providers?.[provider];
if (item?.connected && item?.enabled) {
providerAuthNote = `${item.label} connected with ${item.detail}.`;
providerAuthBusy = "";
stopProviderAuthPolling();
settleWorkProviderPrompts(provider);
if (activeView === "settings") renderSettings();
refreshProviderInventory({ force:true }).catch(() => {});
} else if (!item?.busy) {
providerAuthError = item?.detail || "Authorization did not finish.";
providerAuthBusy = "";
stopProviderAuthPolling();
settleWorkProviderPrompts(provider, providerAuthError);
if (activeView === "settings") renderSettings();
} else {
stopProviderAuthPolling();
providerAuthPollTimer = setTimeout(() => refreshProviderAuth({ poll:true }), 900);
}
}
}
async function connectProviderFromSettings(provider, workRelayId = "") {
if (!provider || providerAuthBusy) return;
providerAuthBusy = provider;
providerAuthError = "";
providerAuthNote = "";
if (activeView === "settings") renderSettings();
else if (workRelayId && activeView === "reader" && String(readerId) === String(workRelayId)) renderReader();
let result = null;
try { result = await window.relay.providerAuthConnect(provider); } catch (error) { result = { ok:false, error:error?.message }; }
if (!result?.ok) {
providerAuthBusy = "";
providerAuthError = result?.error || "Could not start authorization.";
settleWorkProviderPrompts(provider, providerAuthError);
if (activeView === "settings") renderSettings();
else if (workRelayId && activeView === "reader" && String(readerId) === String(workRelayId)) renderReader();
return;
}
if (result.alreadyConnected) {
providerAuthBusy = "";
providerAuthNote = `${provider === "claude" ? "Claude Code" : "Codex"} is already signed in with the required subscription.`;
await refreshProviderAuth();
settleWorkProviderPrompts(provider);
refreshProviderInventory({ force:true }).catch(() => {});
return;
}
stopProviderAuthPolling();
providerAuthPollTimer = setTimeout(() => refreshProviderAuth({ poll:true }), 900);
}
async function setProviderEnabledFromSettings(provider, enabled) {
if (!provider || providerAuthBusy) return;
providerAuthError = "";
providerAuthNote = "";
let result = null;
try { result = await window.relay.providerAuthSetEnabled(provider, enabled); } catch (error) { result = { ok:false, error:error?.message }; }
if (!result?.ok) providerAuthError = result?.error || "Could not update this connection.";
else providerAuthNote = `${provider === "claude" ? "Claude Code" : "Codex"} ${enabled ? "enabled" : "disabled"} in Relay.`;
await refreshProviderAuth();
if (result?.ok) refreshProviderInventory({ force:true }).catch(() => {});
}
async function approveDeviceFromSettings(deviceId) {
if (!deviceId || deviceApprovalBusy || !window.relay.approveE2eeDevice) return;
deviceApprovalBusy = deviceId;
deviceApprovalError = "";
deviceApprovalNote = "";
renderSettings();
let result = null;
try { result = await window.relay.approveE2eeDevice(deviceId); } catch (error) {
result = { ok:false, error:error?.message || "Could not approve this device." };
}
deviceApprovalBusy = "";
if (!result?.ok) {
deviceApprovalError = result?.error || "Could not approve this device.";
renderSettings();
return;
}
deviceApprovalNote = result.historyError
? `Device approved for new encrypted messages. Prior history is still waiting: ${result.historyError}`
: "Device approved. Its encrypted history transfer has been offered.";
try { deviceApprovalInfo = await window.relay.e2eeDeviceApprovals(); } catch {}
renderSettings();
}
// Notification switches are phrased positively while storage remains the
// original hidden/muted booleans. Optimistic: flip the visible state and the
// local sound gate first, then reconcile from main's authoritative answer.
async function toggleQuietPref(key, el) {
if (!el || el.disabled || !settingsInfo) return;
const nextChecked = el.getAttribute("aria-checked") !== "true";
const next = el.getAttribute("data-quiet-inverted") === "1" ? !nextChecked : nextChecked;
el.setAttribute("aria-checked", nextChecked ? "true" : "false");
el.disabled = true;
if (key === "soundsMuted") soundsMuted = next;
let result = null;
try {
result = key === "pillHidden"
? await window.relay.setPillHidden(next)
: await window.relay.setSoundsMuted(next);
} catch (error) {
result = { ok: false, error: error?.message };
}
const applied = result?.ok === true ? next : Boolean(result?.[key]);
settingsInfo = { ...settingsInfo, [key]: applied };
if (key === "soundsMuted") soundsMuted = applied;
renderSettings();
}
// Switch account / sign in: open the pairing page in the browser and reveal
// the inline code-entry row in the same motion.
function beginPairFlow() {
if (svBusy) return;
resetSignOutArm();
svError = "";
svNote = "";
svPairOpen = true;
svFocusCodeOnce = true;
if (window.relay.openUrl) window.relay.openUrl((settingsInfo && settingsInfo.setupPath) || "/app/setup");
renderSettings();
}
async function submitPairCode() {
if (svBusy) return;
const code = svDraft.trim();
if (!code) {
svError = "Enter the pairing code from your browser.";
renderSettings();
return;
}
svBusy = true;
svError = "";
renderSettings();
let res = null;
try {
res = await window.relay.pairWithCode({ code });
} catch (err) {
res = { ok: false, error: (err && err.message) || "Could not connect this device." };
}
svBusy = false;
if (res && res.ok) {
svDraft = "";
svPairOpen = false;
svNote = accountChangeNote(res.email ? `Connected as ${res.email}.` : "Connected.", res.daemon);
} else {
svError = (res && res.error) || "Could not connect this device.";
}
renderSettings();
}
// What an account change actually did — never what it hoped to do. `daemon`
// is the verified restart status from the main process. Relay cannot restart
// an agent session it did not start, and every open one keeps its Relay
// tools bound to the previous account until it is restarted; its next tool
// call will say so, but the person changing accounts should hear it here
// first.
function accountChangeNote(lead, daemon) {
const service =
daemon === "restarted" ? "Relay restarted."
: daemon === "not_installed" ? "Relay's background service is not installed on this computer."
: daemon === "failed" ? "Relay's background service did not restart — run `relay repair-desktop`, or restart this computer."
: "Restarting Relay…";
return `${lead} ${service} Restart any open Claude Code or Codex sessions — their Relay tools are still on the previous account.`;
}
// Sign out is destructive-ish (this device stops receiving relays), so the
// button arms on the first click and only a second click within 4s commits.
async function onSignOutClick() {
if (svBusy) return;
if (!svSignOutArmed) {
svSignOutArmed = true;
if (svSignOutTimer) clearTimeout(svSignOutTimer);
svSignOutTimer = setTimeout(() => {
svSignOutTimer = null;
svSignOutArmed = false;
renderSettings();
}, 4000);
renderSettings();
return;
}
resetSignOutArm();
svBusy = true;
svError = "";
svNote = "";
renderSettings();
let res = null;
try {
res = await window.relay.signOut();
} catch (err) {
res = { ok: false, error: (err && err.message) || "Could not sign out." };
}
svBusy = false;
if (res && res.ok) svNote = accountChangeNote("Signed out.", res.daemon);
else svError = (res && res.error) || "Could not sign out.";
renderSettings();
}
async function loadSettings() {
const seq = ++settingsLoadSeq;
loadAgentSurfaces().catch(() => {});
const accountTask = (async () => {
let info = null;
try { info = window.relay.accountInfo ? await window.relay.accountInfo() : null; } catch {}
if (seq !== settingsLoadSeq) return;
const before = JSON.stringify(settingsInfo);
settingsInfo = info && info.ok !== false ? info : { paired:false, name:"", email:"", deviceName:"", version:"" };
// Account state is the navigation-critical content. Paint it as soon as
// its local read finishes; provider health must never hold this card up.
if (JSON.stringify(settingsInfo) !== before) renderSettings();
})();
const deviceApprovalTask = (async () => {
if (!window.relay.e2eeDeviceApprovals) return;
let result = null;
try { result = await window.relay.e2eeDeviceApprovals(); } catch (error) {
result = { ok:false, error:error?.message || "Could not check device approvals." };
}
if (seq !== settingsLoadSeq) return;
deviceApprovalInfo = result?.ok === false ? { available:false, pendingDevices:[] } : result;
deviceApprovalError = result?.ok === false ? result.error || "Could not check device approvals." : "";
renderSettings();
})();
const slackTask = (async () => {
if (payload.features?.slack !== true) return;
if (seq !== settingsLoadSeq) return;
await refreshSlackConnection({ preserveWaiting:true });
})();
// Provider profiles and integration health are only read when Agent
// connections can render; outside local builds the pill never inspects
// the CLIs' local state (the section is requests substrate — Sven,
// 2026-08-17).
const connectionsOn = payload.features?.agentConnections === true;
const authTask = (async () => {
if (!connectionsOn) return;
let result = null;
try { result = window.relay.providerAuthStatus ? await window.relay.providerAuthStatus() : null; } catch (error) {
result = { ok:false, error:error?.message || "Could not check agent connections." };
}
if (seq !== settingsLoadSeq) return;
const previousError = providerAuthError;
if (result?.ok === false) providerAuthError = result.error || "Could not check agent connections.";
else if (result?.providers) providerAuthError = "";
if (applyProviderAuthResult(result) || providerAuthError !== previousError) renderSettings();
})();
const inventoryTask = (async () => {
if (!connectionsOn || !window.relay.providerInventory) return;
let result = null;
try { result = await window.relay.providerInventory({ refresh:false }); } catch {}
if (seq !== settingsLoadSeq) return;
if (result?.checkedAt) providerInventoryCheckedAt = result.checkedAt;
if (typeof result?.stale === "boolean") providerInventoryStale = result.stale;
providerInventoryError = result?.error || "";
providerInventoryRefreshing = true;
const changed = applyProviderInventoryResult(result);
if (changed) renderSettings();
else updateProviderInventoryState();
// The exact CLI-backed health check is deliberately off the click path.
// Main-process TTL + in-flight sharing make bursts of focus/visibility
// events converge on one scan.
refreshProviderInventory({ seq }).catch(() => {});
})();
const chatAgentTask = (async () => {
if (payload.features?.requests !== true || !window.relay.chatAgentPreferences) return;
try {
const result = await window.relay.chatAgentPreferences();
if (seq !== settingsLoadSeq) return;
chatAgentPreferences = result;
chatAgentPreferencesError = "";
} catch (error) {
if (seq !== settingsLoadSeq) return;
chatAgentPreferencesError = error?.message || "Could not load chat agent defaults.";
}
renderSettings();
})();
await Promise.allSettled([accountTask, slackTask, deviceApprovalTask, authTask, inventoryTask, chatAgentTask]);
}
// Provider state is live product state, not something the user should have
// to refresh manually. Re-read it whenever Settings becomes visible again
// after an external provider-owned authorization window or app switch.
window.addEventListener("focus", () => {
if (activeView === "settings") {
loadSettings();
} else if (activeView === "slack" && payload.features?.slack === true) {
refreshSlackConnection({ preserveWaiting:true });
}
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (activeView === "settings") loadSettings();
else if (activeView === "slack" && payload.features?.slack === true) refreshSlackConnection({ preserveWaiting:true });
}
});
// Slack OAuth completes in the browser, not in this renderer. Focus and
// visibility events are not guaranteed while the Pill remains open behind
// that browser window, so keep both Slack connection faces truthful without
// a manual refresh. The tab and Settings card share the same lightweight
// status call; all other Settings checks retain their focus/entry cadence.
setInterval(() => {
if (payload.features?.slack === true && (activeView === "settings" || activeView === "slack") && rendererSurfaceActive()) {
refreshSlackConnection({ preserveWaiting:true });
}
}, SLACK_CONNECTION_POLL_MS);
// Slack Events reach the API asynchronously. While a Slack-backed channel
// is actually on screen, keep that one transcript on a messaging cadence;
// hidden/list views retain the much slower summary refresh in main. The
// overlap guard in refreshActiveCanonicalChat keeps slow networks from
// stacking requests.
setInterval(() => { refreshActiveCanonicalChat(); }, ACTIVE_CANONICAL_CHAT_POLL_MS);
window.addEventListener("focus", () => { refreshActiveCanonicalChat(); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refreshActiveCanonicalChat();
});
if (signInBannerEl) signInBannerEl.addEventListener("click", (e) => {
e.stopPropagation();
activeView = "settings";
commitNavigation();
beginPairFlow(); // the banner IS the sign-in affordance: browser + code row
});
// ---------- agent-installed first run ----------
// This renderer never receives the installation client secret, activation
// token or PKCE verifier. The high-level preload calls below are the entire
// UI boundary; main/core owns credentials, browser launch and consumption.
function googleMark() {
return `<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.205c0-.638-.057-1.252-.164-1.841H9v3.481h4.844a4.14 4.14 0 0 1-1.797 2.715v2.258h2.909c1.702-1.567 2.684-3.878 2.684-6.613Z"/><path fill="#34A853" d="M9 18c2.43 0 4.468-.806 5.956-2.182l-2.909-2.258c-.806.54-1.835.859-3.047.859-2.344 0-4.328-1.585-5.037-3.714H.956v2.332A9 9 0 0 0 9 18Z"/><path fill="#FBBC05" d="M3.963 10.705A5.41 5.41 0 0 1 3.681 9c0-.592.102-1.168.282-1.705V4.963H.956A9 9 0 0 0 0 9c0 1.452.347 2.827.956 4.037l3.007-2.332Z"/><path fill="#EA4335" d="M9 3.581c1.321 0 2.507.454 3.441 1.346l2.581-2.581C13.464.892 11.426 0 9 0A9 9 0 0 0 .956 4.963l3.007 2.332C4.672 5.166 6.656 3.581 9 3.581Z"/></svg>`;
}
function signupFailureMessage(reason, fallback) {
const detail = String(reason?.code || reason?.message || reason || "").toLowerCase();
if (detail.includes("credential_unavailable")) {
return payload.account?.credentialStore === "native-v1"
? "macOS Keychain is unavailable. Restart your Mac, sign in normally, then open Relay to migrate this credential once."
: "Relay cannot read its protected credential file. Check ~/.relay ownership and permissions, then try again.";
}
if (detail.includes("credential_store_permissions") || detail.includes("credential_store_error") || detail.includes("saved account credential")) {
return "Relay cannot read its protected credential file. Check ~/.relay ownership and permissions, then try again.";
}
if (detail.includes("credential_not_found") || detail.includes("credential_missing")) {
return "Relay’s saved account credential is missing. Reconnect this computer.";
}
if (detail.includes("credential_empty") || detail.includes("credential_corrupt")) {
return "Relay’s saved account credential is damaged. Reconnect this computer.";
}
if (detail.includes("invalid_email_code_format")) return "Enter the 6-digit code.";
if (detail.includes("invalid_email_code")) return "That code isn’t right or has expired. Try again.";
if (detail.includes("email_code_cooldown")) return "A code was just sent. Check your inbox or wait a moment.";
if (detail.includes("email_rate_limited")) return "Too many codes were requested. Try again later.";
if (detail.includes("authorization_expired")) return "Setup expired. Start again.";
if (detail.includes("authorization_changed")) return "Setup changed securely. Resume setup to continue.";
if (detail.includes("authorization_recovery_failed")) return "Relay could not recover the approved setup. Restart setup to create a new one-time approval.";
if (detail.includes("request_timeout")) return "Relay took too long to respond. Try again.";
if (detail.includes("network_unavailable")) return "Relay couldn’t connect. Check your internet and try again.";
if (detail.includes("linux_desktop_disabled")) return "New Linux desktop connections are temporarily paused. Try again later.";
if (detail.includes("invalid_email")) return "Enter a valid email address.";
if (detail.includes("browser_open_failed")) return "Relay couldn’t open your secure sign-in page. Try again.";
if (detail.includes("secret_removal_failed")) return "Relay could not safely remove the old one-time setup approval. No replacement was created.";
if (detail.includes("already_connected")) return "Relay is already connected on this computer.";
if (detail.includes("setup_restart_required")) return "Relay found an unfinished one-time approval. Restart setup to replace it safely.";
if (detail.includes("setup_state_unavailable")) return "Relay couldn’t read its setup state. Check the protected Relay folder, then try again.";
if (detail.includes("setup_unavailable")) return "Relay setup is temporarily unavailable. Try again.";
if (detail.includes("authorization is unavailable") || detail.includes("stored authorization is invalid")) {
return "Relay couldn’t secure setup on this computer. Cancel setup and start again.";
}
return fallback;
}
function signupFailure(reason, fallback) {
signupBusy = false;
const detail = String(reason?.code || reason?.message || reason || "").toLowerCase();
if (
detail.includes("setup_restart_required") ||
detail.includes("authorization_recovery_failed") ||
detail.includes("pkce_verification_failed")
) signupStage = "restart-required";
else if (detail.includes("authorization_expired")) signupStage = "expired";
signupError = signupFailureMessage(reason, fallback);
renderSignup();
}
function cancelSetupButton() {
return `<button class="su-link" id="suCancel" type="button"${signupBusy ? " disabled" : ""}>Cancel setup</button>`;
}
function bindSignupCancel() {
document.getElementById("suCancel")?.addEventListener("click", async () => {
stopSignupPolling();
signupBusy = true; signupError = ""; renderSignup();
try {
await window.relay.installationAuthCancel();
signupPollFailures = 0; signupPollErrorVisible = false; signupConnectionCommitted = false;
signupBusy = false; signupStage = "installed"; signupAccount = null; signupEmail = "";
window.relay.dismiss?.();
} catch (reason) { signupFailure(reason, "Relay could not cancel setup safely."); }
});
}
function applyInstallationState(state) {
if (!state || typeof state !== "object") return;
if (state.account && typeof state.account === "object") signupAccount = state.account;
if (state.status === "pending_approval") signupStage = "approval";
else if (state.status === "approved" || state.status === "consumed") signupStage = "finishing";
else if (state.status === "expired") signupStage = "expired";
}
function stopSignupPolling() {
if (signupPollTimer) clearTimeout(signupPollTimer);
signupPollTimer = null;
}
function retryInstallationPolling() {
signupPollFailures = 0;
signupPollErrorVisible = false;
signupError = "";
renderSignup();
pollInstallationState({ immediate:true });
}
function bindSignupResume() {
document.getElementById("suResume")?.addEventListener("click", retryInstallationPolling);
}
function pollInstallationState({ immediate = false } = {}) {
stopSignupPolling();
if (!window.relay.installationAuthState || payload.account?.paired !== false) return;
if (signupPollFailures >= SIGNUP_POLL_STOP_FAILURE) return;
const delay = immediate
? 0
: Math.min(SIGNUP_POLL_BASE_MS * (2 ** Math.min(signupPollFailures, 3)), SIGNUP_POLL_MAX_MS);
signupPollTimer = setTimeout(async () => {
try {
if (signupConnectionCommitted) {
onPayload(await window.relay.refresh());
} else {
let state = await window.relay.installationAuthState();
applyInstallationState(state);
if (signupStage === "finishing" && window.relay.installationAuthResume) {
state = await window.relay.installationAuthResume();
applyInstallationState(state);
if (state?.status === "consumed") {
signupConnectionCommitted = true;
onPayload(await window.relay.refresh());
}
}
}
signupPollFailures = 0;
if (signupPollErrorVisible) signupError = "";
signupPollErrorVisible = false;
renderSignup();
} catch (reason) {
signupPollFailures += 1;
const detail = String(reason?.code || reason?.message || reason || "").toLowerCase();
let terminalRecovery = false;
if (
detail.includes("setup_restart_required") ||
detail.includes("authorization_recovery_failed") ||
detail.includes("pkce_verification_failed")
) { signupStage = "restart-required"; terminalRecovery = true; }
else if (detail.includes("authorization_expired")) { signupStage = "expired"; terminalRecovery = true; }
if (terminalRecovery || signupPollFailures >= SIGNUP_POLL_SURFACE_FAILURE) {
signupPollErrorVisible = true;
signupError = signupFailureMessage(reason, "Relay couldn’t check setup safely. Resume setup to try again.");
renderSignup();
}
}
if (payload.account?.paired === false && ["google", "finishing"].includes(signupStage)) {
pollInstallationState();
}
}, delay);
}
async function resumeInstallationSetup() {
if (signupBusy || !window.relay.installationAuthResume) return;
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthResume();
signupBusy = false;
signupPollFailures = 0;
signupPollErrorVisible = false;
if (state?.status === "pending_identity" || state?.status === "idle") signupStage = "method";
applyInstallationState(state);
if (state?.status === "consumed") {
signupConnectionCommitted = true;
try { onPayload(await window.relay.refresh()); } catch {}
}
renderSignup();
if (["google", "finishing"].includes(signupStage)) pollInstallationState();
} catch (reason) {
signupFailure(reason, "Relay couldn’t resume setup safely.");
}
}
async function restartInstallationSetup({ forceGoogleSelection = false } = {}) {
if (signupBusy || !window.relay.installationAuthRestart) return;
stopSignupPolling();
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthRestart();
signupBusy = false;
signupStage = "method";
signupAccount = null;
signupEmail = "";
signupPollFailures = 0;
signupPollErrorVisible = false;
signupConnectionCommitted = false;
signupForceGoogleSelection = forceGoogleSelection;
applyInstallationState(state);
renderSignup();
} catch (reason) {
signupFailure(reason, "Relay could not restart setup safely. No replacement was created.");
}
}
async function connectChatFromSignup(provider) {
if (signupBusy) return;
const connect = provider === "claude" ? window.relay.connectClaude : window.relay.connectChatGPT;
const label = provider === "claude" ? "Claude" : "ChatGPT";
if (!connect) return;
signupBusy = true; signupError = ""; signupNote = ""; renderSignup();
try {
const result = await connect();
if (!result?.ok) throw new Error(result?.error || `${label} setup could not be opened.`);
signupBusy = false;
signupNote = provider === "claude"
? "Relay’s address is copied. In Claude, choose +, Add custom connector, then paste it."
: "Continue in your browser to install Relay in ChatGPT.";
renderSignup();
} catch (reason) {
signupFailure(reason, `${label} setup could not be opened.`);
}
}
async function completeSignupTutorial() {
if (signupBusy || !window.relay.completeSetupTutorial) return;
signupBusy = true; signupError = ""; renderSignup();
try {
const result = await window.relay.completeSetupTutorial();
if (!result?.ok) throw new Error(result?.error || "Relay could not finish setup.");
signupBusy = false; signupNote = ""; signupStage = "installed";
payload.ui = { ...payload.ui, setupTutorialPending:false };
renderAll();
} catch (reason) {
signupFailure(reason, "Relay could not finish setup. Try again.");
}
}
function renderSignup() {
if (!signupBodyEl) return;
const busy = signupBusy ? " disabled" : "";
const error = signupError ? `<p class="su-note su-error" role="alert">${esc(signupError)}</p>` : "";
const note = signupNote ? `<p class="su-note" role="status">${esc(signupNote)}</p>` : "";
if (signupStage === "credential-recovery") {
const status = payload.account?.credentialStatus || "unavailable";
const reason = payload.account?.credentialError || "";
const legacyKeychain = payload.account?.credentialStore === "native-v1";
const canReconnect = status === "missing" || status === "corrupt";
const title = canReconnect ? "Reconnect this computer." : legacyKeychain ? "Recover Relay’s account." : "Repair Relay’s storage.";
const copy = reason === "config_corrupt"
? "This computer’s Relay account configuration is damaged. Relay preserved the corrupt file for inspection and did not start a new signup automatically."
: reason === "config_unavailable"
? "Relay cannot read this computer’s account configuration. Check the file permissions, then try again."
: reason === "remote_authorization_rejected"
? "Relay’s service no longer accepts this computer’s saved sign-in. Reconnect the same account to replace it; your Relay history stays where it is."
: canReconnect
? `This computer still has Relay account metadata, but its saved credential is ${status === "missing" ? "missing" : "damaged"}. Relay did not sign you out or start a new signup automatically.`
: legacyKeychain
? "This Mac still has Relay’s older Keychain credential, but macOS did not release it. Restart the Mac, sign in normally, and Relay will migrate it once without asking for your password."
: "Relay cannot read its owner-only credential file. Check the ownership and permissions of ~/.relay, then try again.";
const primary = canReconnect
? `<button class="su-primary" id="suCredentialReconnect" type="button"${busy}>Reconnect account</button>`
: `<button class="su-primary" id="suCredentialRetry" type="button"${busy}>Try again</button>`;
const retry = canReconnect ? `<button class="su-link" id="suCredentialRetry" type="button"${busy}>Check saved credential again</button>` : "";
signupBodyEl.innerHTML = `<p class="su-step">Account recovery</p><h1 class="su-title">${title}</h1><p class="su-copy">${copy}</p><div class="su-form">${primary}${retry}</div>${error}`;
document.getElementById("suCredentialRetry")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
const result = await window.relay.credentialRetry();
if (result?.account) payload.account = result.account;
signupBusy = false;
if (result?.ok) {
signupStage = "installed";
try { onPayload(await window.relay.refresh()); } catch { renderAll(); }
return;
}
signupError = signupFailureMessage(result?.account?.credentialError, "Relay still cannot access the saved credential.");
renderAll();
} catch (reason) { signupFailure(reason, "Relay could not check the saved credential again."); }
});
document.getElementById("suCredentialReconnect")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
const result = await window.relay.signOut();
if (!result?.ok) throw new Error(result?.error || "Relay could not prepare this computer to reconnect.");
} catch (reason) { signupFailure(reason, "Relay could not prepare this computer to reconnect."); }
});
return;
}
if (signupStage === "chat-setup") {
signupBodyEl.innerHTML = `<p class="su-step">One last thing</p><h1 class="su-title">Use Relay in your chats.</h1><p class="su-copy">Connect Claude now. You can also do this later from Settings.</p><div class="su-chat-list"><button class="su-chat-option" id="suConnectClaude" type="button"${signupBusy || !window.relay.connectClaude ? " disabled" : ""}><span class="su-chat-copy"><span class="su-chat-name">Sign in with Claude</span><span class="su-chat-detail">Copy Relay’s address and open Claude Connectors.</span></span><span class="su-chat-action">Set up</span></button></div><div class="su-agent-note"><strong>Claude Code and Codex do not need this.</strong> They are already connected through the Companion.</div>${note}${error}<div class="su-form su-chat-footer"><button class="su-primary" id="suChatDone" type="button"${busy}>Done</button><button class="su-link" id="suChatSkip" type="button"${busy}>Skip for now</button></div>`;
document.getElementById("suConnectClaude")?.addEventListener("click", () => connectChatFromSignup("claude"));
document.getElementById("suChatDone")?.addEventListener("click", completeSignupTutorial);
document.getElementById("suChatSkip")?.addEventListener("click", completeSignupTutorial);
return;
}
if (signupStage === "restart-required") {
signupBodyEl.innerHTML = `<p class="su-step">Setup interrupted</p><h1 class="su-title">Restart this setup.</h1><p class="su-copy">Relay cannot safely finish the current one-time approval. Restart deletes only that approval before creating a replacement.</p><div class="su-form"><button class="su-primary" id="suRestartSetup" type="button"${busy}>Restart setup</button>${cancelSetupButton()}</div>`;
document.getElementById("suRestartSetup")?.addEventListener("click", () => restartInstallationSetup());
bindSignupCancel();
return;
}
if (signupStage === "resume") {
signupBodyEl.innerHTML = `<p class="su-step">Setup in progress</p><h1 class="su-title">Continue your setup.</h1><p class="su-copy">Relay kept the unfinished one-time approval on this computer. Resume it, or explicitly restart just this setup approval.</p><div class="su-form"><button class="su-primary" id="suResumeSetup" type="button"${busy}>Resume setup</button><button class="su-link" id="suRestartSetup" type="button"${busy}>Restart setup</button>${cancelSetupButton()}</div>${error}`;
document.getElementById("suResumeSetup")?.addEventListener("click", resumeInstallationSetup);
document.getElementById("suRestartSetup")?.addEventListener("click", () => restartInstallationSetup());
bindSignupCancel();
return;
}
if (signupStage === "installed") {
signupBodyEl.innerHTML = `<p class="su-step">Agent installed</p><h1 class="su-title">Relay is ready for you.</h1><p class="su-copy">Your agent finished the install. Connect your account to this computer.</p><div class="su-form"><button class="su-primary" id="suContinue" type="button"${busy}>Continue</button>${cancelSetupButton()}</div>${error}`;
document.getElementById("suContinue")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthBegin();
signupBusy = false; signupStage = "method"; applyInstallationState(state); renderSignup();
if (signupStage === "finishing") pollInstallationState();
} catch (reason) { signupFailure(reason, "Relay could not start account setup."); }
});
bindSignupCancel();
return;
}
if (signupStage === "method") {
signupBodyEl.innerHTML = `<p class="su-step">Your account</p><h1 class="su-title">How would you like to continue?</h1><p class="su-copy">Your agent can’t see your identity or sign-in details.</p><div class="su-form"><button class="su-secondary su-google" id="suGoogle" type="button"${busy}>${googleMark()} Continue with Google</button><div class="su-divider">or</div><button class="su-primary" id="suEmail" type="button"${busy}>Continue with email</button>${cancelSetupButton()}</div>${error}`;
document.getElementById("suGoogle")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthGoogle({ forceAccountSelection:signupForceGoogleSelection });
signupForceGoogleSelection = false;
signupBusy = false; signupStage = "google"; applyInstallationState(state); renderSignup(); pollInstallationState();
} catch (reason) { signupFailure(reason, "Google sign-in did not open."); }
});
document.getElementById("suEmail")?.addEventListener("click", () => { signupStage = "email"; signupError = ""; renderSignup(); });
bindSignupCancel();
return;
}
if (signupStage === "email") {
signupBodyEl.innerHTML = `<p class="su-step">Email</p><h1 class="su-title">What’s your email?</h1><p class="su-copy">Relay sends you a one-time code. Your agent never sees it.</p><form class="su-form" id="suEmailForm"><label class="su-label">Email<input class="su-input" id="suEmailInput" type="email" autocomplete="email" placeholder="you@example.com" value="${esc(signupEmail)}" required /></label><button class="su-primary" type="submit"${busy}>Send code</button><button class="su-link" id="suEmailBack" type="button">Back</button>${cancelSetupButton()}</form>${error}`;
const input = document.getElementById("suEmailInput");
document.getElementById("suEmailBack")?.addEventListener("click", () => { signupStage = "method"; signupError = ""; renderSignup(); });
document.getElementById("suEmailForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
signupEmail = String(input?.value || "").trim();
if (!signupEmail || !input?.checkValidity()) { input?.reportValidity(); return; }
signupBusy = true; signupError = ""; renderSignup();
try {
await window.relay.installationAuthEmailStart(signupEmail);
signupBusy = false; signupStage = "code"; renderSignup();
} catch (reason) { signupFailure(reason, "Relay could not send the code."); }
});
bindSignupCancel();
return;
}
if (signupStage === "code") {
signupBodyEl.innerHTML = `<p class="su-step">Check your email</p><h1 class="su-title">Enter your code.</h1><p class="su-copy">We sent a 6-digit code to ${esc(signupEmail)}. Check spam if you don’t see it.</p><form class="su-form" id="suCodeForm"><label class="su-label">One-time code<input class="su-input su-code" id="suCodeInput" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}" required /></label><button class="su-primary" type="submit"${busy}>Verify</button><button class="su-link" id="suCodeResend" type="button"${busy}>Send another code</button><button class="su-link" id="suCodeBack" type="button"${busy}>Use another email</button>${cancelSetupButton()}</form>${error}`;
const input = document.getElementById("suCodeInput");
document.getElementById("suCodeBack")?.addEventListener("click", () => { signupStage = "email"; signupError = ""; renderSignup(); });
document.getElementById("suCodeResend")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
await window.relay.installationAuthEmailStart(signupEmail);
signupBusy = false; renderSignup();
} catch (reason) { signupFailure(reason, "Relay could not send another code."); }
});
document.getElementById("suCodeForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const code = String(input?.value || "").trim();
if (!/^[0-9]{6}$/.test(code)) { input?.reportValidity(); return; }
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthEmailVerify(code);
signupBusy = false; applyInstallationState(state); renderSignup();
} catch (reason) { signupFailure(reason, "That code did not work."); }
});
bindSignupCancel();
return;
}
if (signupStage === "google") {
const retry = signupError ? `<button class="su-primary" id="suResume" type="button"${busy}>Try again</button>` : "";
signupBodyEl.innerHTML = `<p class="su-step">Google sign-in</p><h1 class="su-title">Finish with Google.</h1><p class="su-copy">Complete Google sign-in in your default browser.</p><div class="su-account"><span class="su-account-name">Waiting for Google…</span><span class="su-account-email">Your agent cannot see this sign-in.</span></div><div class="su-form">${retry}${cancelSetupButton()}</div>${error}`;
bindSignupResume();
bindSignupCancel();
return;
}
if (signupStage === "approval") {
const account = signupAccount || {};
signupBodyEl.innerHTML = `<p class="su-step">Ready to connect</p><h1 class="su-title">Connect this computer?</h1><p class="su-copy">Check the account and agent apps before you approve.</p><div class="su-account"><span class="su-account-name">${esc(account.displayName || account.email || "Relay account")}</span>${account.email ? `<span class="su-account-email">${esc(account.email)}</span>` : ""}<span class="su-device">This computer<br />Claude Code · Cowork · Codex</span></div><div class="su-form"><button class="su-primary" id="suApprove" type="button"${busy}>Connect Relay</button><button class="su-link" id="suAnother" type="button"${busy}>Change account</button>${cancelSetupButton()}</div>${error}`;
document.getElementById("suApprove")?.addEventListener("click", async () => {
signupBusy = true; signupError = ""; renderSignup();
try {
const state = await window.relay.installationAuthApprove();
signupBusy = false; signupStage = "finishing"; applyInstallationState(state);
if (state?.status === "consumed") {
signupConnectionCommitted = true;
try { onPayload(await window.relay.refresh()); } catch {}
}
renderSignup(); pollInstallationState();
} catch (reason) { signupFailure(reason, "Relay could not connect this account."); }
});
document.getElementById("suAnother")?.addEventListener("click", async () => {
await restartInstallationSetup({ forceGoogleSelection:true });
});
bindSignupCancel();
return;
}
if (signupStage === "expired") {
signupBodyEl.innerHTML = `<p class="su-step">Setup expired</p><h1 class="su-title">Start setup again.</h1><p class="su-copy">The one-time setup authorization expired without connecting this computer. Restart removes only that expired approval before creating a replacement.</p><div class="su-form"><button class="su-primary" id="suRestart" type="button"${busy}>Restart setup</button></div>${error}`;
document.getElementById("suRestart")?.addEventListener("click", () => restartInstallationSetup());
return;
}
const resume = signupError ? `<div class="su-form"><button class="su-primary" id="suResume" type="button"${busy}>Resume setup</button></div>` : "";
signupBodyEl.innerHTML = `<p class="su-step">Almost done</p><h1 class="su-title">Finishing setup…</h1><p class="su-copy">Relay is getting this computer ready.</p><div class="su-checks"><div class="su-check"><span class="su-check-mark">✓</span>Account verified</div><div class="su-check"><span class="su-check-mark">✓</span>This computer approved</div><div class="su-check su-check-active"><span class="su-check-mark" aria-hidden="true"></span>Connecting Claude Code, Cowork, and Codex</div></div>${resume}${error}`;
bindSignupResume();
}
// Deterministic visual states for the overlay's screenshot contract. It is
// absent in production and cannot affect account or authorization state.
if (window.relay.isTestOverlay) {
window.__relaySignupPreview = (stage, data = {}) => {
signupStage = String(stage || "installed");
signupEmail = String(data.email || "alex@example.com");
signupAccount = data.account || { displayName:"Alex Rivera", email:"alex@example.com" };
signupBusy = false;
signupError = String(data.error || "");
signupNote = String(data.note || "");
const tutorial = signupStage === "chat-setup";
payload.account = { paired:tutorial };
payload.ui = { ...payload.ui, setupTutorialPending:tutorial };
renderAll();
};
}
// ---------- views / tabs ----------
// A navigation is one atomic renderer task: state is already final, the
// destination paints while still hidden, then applyView reveals it and
// retargets the native frame. Calling applyView before renderAll exposed an
// empty/stale destination for a frame and every caller then paid for a
// second applyView inside renderAll. Scroll is restored in the same task,
// before Chromium can paint the destination at its default origin.
function commitNavigation({ outerScrollTop = null } = {}) {
if (activeView !== "contacts") {
cvOpenGeneration += 1;
cvOpeningKey = null;
}
const focused = document.activeElement;
if (applyView._last && applyView._last !== activeView
&& focused && focused !== document.body && typeof focused.blur === "function") {
// A hidden textarea can otherwise retain Electron focusability until a
// later poll removes it, stealing the first click after navigation.
focused.blur();
}
if (activeView !== "threads") threadComposerEntryFocus = null;
syncTabs();
renderAll();
if (outerScrollTop !== null && scrollEl) scrollEl.scrollTop = Math.max(0, Number(outerScrollTop) || 0);
}
function syncTabs() {
// requestDetail belongs to Sent; conversations belong to CHAT (the
// Chat tab lists them; a thread detail is its drill-down), and contacts
// is Chat's pushed pane — its tab is retired.
const tabView = activeView === "requestDetail" ? "tasks"
: activeView === "threads" ? threadsSource
: activeView === "contacts" ? "contacts"
: activeView === "reader" ? (readerSource || "relays")
: activeView;
for (const tab of tabEls) {
const view = tab.getAttribute("data-view");
tab.classList.toggle("gone",
(view === "tasks" && payload.features?.requests !== true)
|| (view === "slack" && payload.features?.slack !== true));
tab.classList.toggle("active", view === tabView);
}
}
function applyView() {
// Visibility is committed only after renderAll has built the destination.
// Toggle layout first; retarget the native/card frame last so its first
// animated frame already contains the final view rather than a blank pane.
threadsViewEl.classList.toggle("chat-max", chatExpanded);
relaysViewEl.classList.toggle("hidden", activeView !== "relays");
slackViewEl.classList.toggle("hidden", activeView !== "slack");
chatViewEl.classList.toggle("hidden", activeView !== "chat");
threadsViewEl.classList.toggle("hidden", activeView !== "threads");
sentViewEl.classList.toggle("hidden", activeView !== "sent");
tasksViewEl.classList.toggle("hidden", activeView !== "tasks");
readerViewEl.classList.toggle("hidden", activeView !== "reader");
requestDetailViewEl.classList.toggle("hidden", activeView !== "requestDetail");
contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
settingsViewEl.classList.toggle("hidden", activeView !== "settings");
// Entry side-effects fire only when the view actually CHANGED — applyView
// is re-asserted on every render pass now (self-healing visibility), and
// refreshing sent/settings on each pass would ping-pong with the payload
// pushes those refreshes cause.
const viewChanged = applyView._last !== activeView;
applyView._last = activeView;
if (viewChanged) dismissReactionPickers();
if (activeView === "settings") {
if (viewChanged) {
loadSettings(); // renderAll painted the cached card; refresh it now
}
} else {
resetSignOutArm(); // leaving the view disarms the confirm state
}
if (activeView === "slack" && payload.features?.slack === true && viewChanged) {
refreshSlackConnection({ preserveWaiting:true });
}
// A room owns its sent-cache refresh in hydrateThreadEntry(), where the
// matching entry-follow token keeps the newest message visible until the
// asynchronous outbound half has actually rendered.
if (activeView === "relays" && viewChanged && window.relay.refreshSent) {
window.relay.refreshSent().catch(() => {}); // conversations include the sent side
}
if (activeView !== "requestDetail" && requestDetailTimer) {
clearInterval(requestDetailTimer);
requestDetailTimer = null;
}
if (activeView === "contacts" && viewChanged) {
applyContactsPane();
loadContacts();
loadGroups(); // counts on the segment must be right before it is tapped
} else if (activeView !== "contacts") {
cvFormEl.classList.add("hidden");
}
if (activeView === "sent" && viewChanged && window.relay.refreshSent) {
window.relay.refreshSent().catch(() => {});
}
// The reader — or an expanded conversation — holds the card at READER size.
syncCardSize(activeView === "reader" || (activeView === "threads" && chatExpanded));
}
document.getElementById("chatExpandBtn").addEventListener("click", (e) => {
e.stopPropagation();
const { groups, people } = chatSections();
const latest = [...groups, ...people].sort((a, b) => new Date(b.latest.at || 0) - new Date(a.latest.at || 0))[0];
if (latest) openRoom(latest.threadId, latest.name);
});
for (const tab of tabEls) {
tab.addEventListener("click", () => {
dismissReactionPickers();
const view = tab.getAttribute("data-view") || "relays";
// A tab hop supersedes any room-to-list View Transition still retiring.
// Leaving its captured transaction alive lets deferred payload work race
// the new destination and can leave the new tab active while its face is
// hidden. Retire the obsolete snapshots before committing navigation.
if (roomViewTransition) {
supersedeRoomViewTransition();
}
// Tab hops over an open conversation move the HIGHLIGHT, never the
// screen (David): Chat owns every conversation, and the tab it came
// from keeps a claim too — hop freely between them, nothing re-renders.
// Tapping the tab that is ALREADY lit is the way out: that goes home.
if (activeView === "threads" && (view === "chat" || view === threadsCameFrom)) {
const lit = threadsSource === "chat" ? "chat" : threadsCameFrom;
if (view !== lit) { threadsSource = view === "chat" ? "chat" : threadsCameFrom; syncTabs(); return; }
// second tap on the lit tab: fall through and navigate to its list
}
if (view === activeView) return;
activeView = view;
commitNavigation();
});
}
function renderAll() {
// Build the destination BEFORE applyView reveals/resizes it. This ordering
// is the transition contract: one DOM paint, one visibility commit, one
// frame target. It removes the blank-frame and double-rebuild paths while
// preserving applyView as the sole visibility writer.
const enteringView = applyView._last !== activeView;
const paintSurface = rendererSurfaceActive();
const credentialRecovery = ["unavailable", "missing", "corrupt"].includes(payload.account?.credentialStatus);
const tutorialPending = !credentialRecovery && payload.account?.paired !== false && payload.ui?.setupTutorialPending === true;
if (credentialRecovery) {
stopSignupPolling();
signupStage = "credential-recovery";
} else if (tutorialPending) {
stopSignupPolling();
signupStage = "chat-setup";
}
const needsSignup = credentialRecovery || payload.account?.paired === false || tutorialPending;
cardEl.classList.toggle("signup", needsSignup);
signupViewEl?.classList.toggle("gone", !needsSignup);
if (needsSignup) {
if (paintSurface) {
renderSignup();
surfacePaintCount += 1;
surfaceRenderDeferred = false;
} else {
surfaceRenderDeferred = true;
}
syncCardSize(false);
if (!credentialRecovery && payload.account?.paired === false && !signupStateLoaded && window.relay.installationAuthState) {
signupStateLoaded = true;
window.relay.installationAuthState().then((state) => {
if (state?.status === "pending_identity") signupStage = "resume";
applyInstallationState(state);
if (rendererSurfaceActive()) renderSignup();
else surfaceRenderDeferred = true;
if (["google", "finishing"].includes(signupStage)) pollInstallationState();
}).catch((reason) => {
signupError = signupFailureMessage(reason, "Relay couldn’t check the saved setup. Try again.");
if (rendererSurfaceActive()) renderSignup();
else surfaceRenderDeferred = true;
});
}
return;
}
stopSignupPolling();
// Feature policy arrives with the first main-process payload, after the
// static HTML has already painted. Reconcile the tab strip on every
// payload-driven render so a disabled surface never survives as stale
// startup chrome until the user navigates somewhere else.
syncTabs();
syncDismissControl();
// Signed out: the Relays empty state carries a slim sign-in affordance.
if (signInBannerEl) {
signInBannerEl.classList.toggle("gone", !payload.account || payload.account.paired !== false);
}
// Relays badge/count reflect only the items that show in the Relays tab
// (the clean split keeps task_request / share_approval out of this count).
// ONE unread is ONE number. Relays and Chat were counting the same arrivals
// twice — the badges read 7 and 7 for seven relays (David). Relays owns the
// count; Chat only claims what Relays does not already show.
const reqThreads = requestThreadIds();
const sentByRelayId = new Map((payload.sent || [])
.map((item) => [String(item.relayId || item.id || ""), item])
.filter(([id]) => Boolean(id)));
const viewerEmail = String(payload.account?.email || "").trim().toLowerCase();
const relayUnreadIds = new Set(
(payload.relays || [])
.filter((r) => r.unread
&& isRelayListKind(r)
&& !onRequestThread(r, reqThreads)
&& !relayIsSelfAuthored(r, sentByRelayId.get(String(r.id || r.relayId || "")), viewerEmail))
.map((r) => r.id),
);
const unread = relayUnreadIds.size;
setCount(unread);
setBadge(relaysBadgeEl, unread);
setBadge(slackBadgeEl, payload.features?.slack === true ? (payload.slackChats || []).reduce(
(sum, chat) => sum + Math.max(0, Number(chat?.unreadCount || 0)), 0) : 0);
markAllReadEl.classList.toggle("gone", activeView !== "relays" || unread === 0);
markAllReadEl.disabled = unread === 0;
setBadge(tasksBadgeEl, requestsUnreadCount());
if (paintSurface) {
if (activeView === "chat") renderChat();
if (activeView === "relays") renderRelays();
else if (activeView === "slack") renderSlack();
else if (activeView === "threads") renderThreads();
else if (activeView === "sent") renderSent();
else if (activeView === "tasks") renderTasksBoard();
else if (activeView === "reader") renderReader();
else if (activeView === "requestDetail") renderRequestDetail();
else if (activeView === "settings" && enteringView) renderSettings();
surfacePaintCount += 1;
surfaceRenderDeferred = false;
} else {
surfaceRenderDeferred = true;
}
applyView();
// Every render rebuilds the mirror's element, so re-attach the poll to
// whatever is on screen now and drop the ones whose card just closed.
syncRunMirrors();
if (paintSurface && (activeView === "tasks" || activeView === "reader")) loadSchedules();
// contacts renders from its own load path
}
function flushDeferredSurfaceRender() {
if (!surfaceRenderDeferred || !rendererSurfaceActive()) return;
surfaceRenderDeferred = false;
renderAll();
}
markAllReadEl.addEventListener("click", async () => {
if (markAllReadEl.disabled || unreadCount === 0) return;
markAllReadEl.disabled = true;
markAllReadEl.textContent = "Marking…";
const result = await window.relay.markAllRead();
markAllReadEl.textContent = "Mark all as read";
if (!result || !result.ok) {
markAllReadEl.disabled = false;
markAllReadEl.title = (result && result.error) || "Could not mark relays as read.";
return;
}
markAllReadEl.title = "";
});
function syncDismissControl() {
const ui = (payload && payload.ui) || {};
const canDismiss = ui.canDismiss !== false;
const surface = ui.reopenSurface || "the Relay icon";
closeXEl.disabled = !canDismiss;
closeXEl.classList.toggle("unavailable", !canDismiss);
closeXEl.title = canDismiss
? `Hide Relay - reopen from ${surface}`
: "Hide Relay unavailable because the Relay status icon did not load.";
closeXEl.setAttribute("aria-label", canDismiss ? "Hide Relay" : "Relay status icon unavailable");
}
let lastPayloadSignature = "";
let canonicalAccountIdentity = "";
function accountCacheIdentity(account) {
if (!account || account.paired === false) return "";
const id = String(account.userId || account.id || "").trim().toLowerCase();
const email = String(account.email || "").trim().toLowerCase();
return id || email;
}
function clearCanonicalAccountCaches() {
cvOpenGeneration += 1;
cvOpeningKey = null;
contactChatAnchors.clear();
canonicalChatDetails.clear();
canonicalChatFingerprints.clear();
canonicalChatDetailProjectionKeys.clear();
canonicalChatHydrations.clear();
canonicalChatLatestStartedGeneration.clear();
canonicalChatLatestAppliedGeneration.clear();
canonicalReadGeneration.clear();
canonicalReadFailures.clear();
liveCanonicalArrivalIds.clear();
activeCanonicalChatRefresh = null;
}
function onPayload(next) {
if (!next || typeof next !== "object") return;
if (readerMorphSnapshot) {
readerMorphDeferredPayload = next;
return;
}
if (roomViewTransition) {
roomViewTransitionDeferredPayload = next;
return;
}
const incomingAccount = next.account || payload.account;
const nextAccountIdentity = accountCacheIdentity(incomingAccount);
if ((canonicalAccountIdentity && nextAccountIdentity !== canonicalAccountIdentity)
|| (!nextAccountIdentity && incomingAccount?.paired === false)) {
clearCanonicalAccountCaches();
}
canonicalAccountIdentity = nextAccountIdentity;
const incomingRelays = Array.isArray(next.relays) ? next.relays : [];
const projectedRelays = incomingRelays.map((row) => {
const id = String((row && row.id) || "");
if (!id || !optimisticReadIds.has(id)) return row;
if (row.unread === false || row.state === "read") {
optimisticReadIds.delete(id);
return row;
}
return { ...row, unread: false, state: "read" };
});
payload = {
account: incomingAccount,
ui: next.ui || payload.ui,
features: next.features || payload.features,
relays: projectedRelays,
sent: Array.isArray(next.sent) ? next.sent : [],
outbox: Array.isArray(next.outbox) ? next.outbox : [],
requests: Array.isArray(next.requests) ? next.requests : [],
contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
chats: Array.isArray(next.chats) ? next.chats : (payload.chats || []),
slackChats: Array.isArray(next.slackChats) ? next.slackChats : (payload.slackChats || []),
};
const paintSurface = rendererSurfaceActive();
if (paintSurface && activeView === "threads" && threadDetailId) {
const surface = conversationSurface();
const room = chatRoomForThread(threadDetailId, surface);
const includeSlack = slackMessagesVisible(room);
if ((isSlackIntegratedRoom(room) || directContactAnchorForChatId(room?.chatId)) && room?.chatId) {
requestCanonicalChatDetail(room, surface, { includeSlack }).then((result) => {
reconcileCanonicalChatResult(result, {
chatId:room.chatId,
surface,
markArrivals:true,
readVisible:true,
});
}).catch(() => {});
}
}
// The device's own queue is the truth about unsent messages; this renderer
// only mirrors it. Doing this on every payload is what lets a queued bubble
// survive a repaint, a room switch and a restart of the pill itself.
syncOutboxProjection();
if (payload.features?.requests !== true && ["tasks", "requestDetail"].includes(activeView)) {
activeView = "relays";
}
if (payload.features?.slack !== true && (activeView === "slack" || threadsSource === "slack")) {
activeView = "relays";
threadsSource = "relay";
}
// Delivery can happen after navigation. If its room is the visible Chat
// transcript, receipt of the full payload is itself a read event.
if (paintSurface) readVisibleChatRoom();
else surfaceRenderDeferred = true;
const configuredDuration = Number(next.ui && next.ui.notificationDurationMs);
notificationDurationMs = Number.isFinite(configuredDuration) && configuredDuration >= 250
? configuredDuration
: 7000;
soundsMuted = Boolean(next.ui && next.ui.soundsMuted);
if (!soundsMuted) primeAudio();
// The daemon pushes the full payload every poll whether or not anything
// changed. Rebuilding the row DOM for an identical payload is pure waste and
// — on large inboxes — starves this renderer until user input queues up.
// Skip the re-render when nothing observable changed.
const signature = JSON.stringify(payload);
if (signature === lastPayloadSignature && !pendingRelaysRender) return;
lastPayloadSignature = signature;
renderAll();
}
window.relay.onInbox(onPayload);
if (window.relay.onOpenRelay) window.relay.onOpenRelay(async (input) => {
const messageId = String(input?.messageId || "");
const chatId = String(input?.chatId || "");
if (!messageId || !chatId) return;
try {
const result = await window.relay.canonicalChat(chatId);
const chat = result?.chat || result;
if (!chat?.chatId) return;
storeCanonicalChatDetail(chat);
// A Slack button is a deliberate open, just like clicking the menu-bar
// mark. Never leave the requested reader clipped inside the 244×44 pill.
openFull();
openReader(messageId, "threads");
} catch {}
});
// The renderer holds the only connectivity signal either process has. A
// regained network flushes the queue at once — a message must not sit out
// the backoff its last attempt earned during an outage that is over.
window.addEventListener("online", () => { window.relay.networkOnline?.().catch?.(() => {}); });
if (window.relay.onNewRelay) window.relay.onNewRelay((row, opts) => {
if (opts && opts.ghost) ghostArrival(row); // pill dismissed -> free-standing banner
else notifyArrival(row, opts);
});
if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
// The OS window was just re-shown; reset notification dwell state until the
// pointer enters the card again.
if (window.relay.onShown) window.relay.onShown(() => {
interactive = false;
syncContinuousMotion();
flushDeferredSurfaceRender();
});
if (window.relay.onOpenDone) window.relay.onOpenDone((id) => { stopOpening(id); retireOpeningNote(id); });
// Visible confirmation for "Open in current chat". Two honest states:
// waiting — staged for an idle chat; ONLY that chat's next turn can ingest
// it, so the note stays up (surviving re-renders) until delivery
// and tells the user how to pull it in themselves.
// delivered — main watched the consume-once injection file disappear; the
// instruction is IN the chat. Green check, then fade.
// A Codex bridge/automation hand-off keeps the classic brief confirmation.
const deliveredChatNames = new Map(); // packetId -> chat the injection was staged for
if (window.relay.onInjected) window.relay.onInjected((id, info) => {
if (info.channel) {
// Wake path: a channel event starts a real turn even in an idle chat.
cancelNoteTimer(id);
setRowNote(id, "Pushed to your current Claude chat — waking it now…", "ok");
return;
}
if (info.awaitingTurn) {
// Persistent until delivery / the auto-fresh hop — never a silent limbo.
cancelNoteTimer(id);
// Name the destination chat when we know it: "your \u201cgranular-relay\u201d chat"
// tells the user WHERE to look; "your current chat" leaves them hunting
// (field report: the injection landed in a chat they never found).
if (info.chat) deliveredChatNames.set(id, info.chat);
const where = info.chat ? `your \u201c${info.chat}\u201d chat` : "your current Claude chat";
setRowNote(id, `Delivered into ${where} — lands when it next takes a turn. If it\u2019s idle: type anything there, or use Open in New Chat for instant.`, "ok");
return;
}
const hostName = info.host === "codex" ? "Codex" : "Claude";
setRowNote(id, `✓ Sent to your current ${hostName} chat — it will pick this up at its next checkpoint.`, "ok");
fadeRowNoteLater(id, 12000);
});
if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
const chat = deliveredChatNames.get(id);
setRowNote(id, `✓ Delivered — ${chat ? `your \u201c${chat}\u201d chat` : "your current Claude chat"} is acting on it now.`, "ok");
fadeRowNoteLater(id, 8000);
});
if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
setRowNote(id, "Your current chat was idle, so this is opening in a new chat instead.", "ok");
fadeRowNoteLater(id, 10000);
});
// An open that failed leaves the relay UNREAD on purpose (it never reached the user),
// so the row has to say what happened — otherwise the spinner just stops and the click
// looks like it worked. Persisted via rowNotes so a payload-push rebuild can't wipe
// the explanation while the user is reading it. Cleared by the next open of the row.
if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
if (!message) return;
cancelNoteTimer(id);
setRowNote(id, message, "err");
});
window.relay.refresh().then((p) => {
onPayload(p);
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
}).catch((e) => console.log("[overlay] refresh failed", e && e.message ? e.message : e));
// Which agent apps this Mac has — needed before the first "Open in {app}"
// is painted, and re-asked whenever Settings shows (main caches it 60s).
loadAgentSurfaces().catch(() => {});
// Every list names people from the contact book, so it has to be in memory
// before the first render rather than only once the Contacts tab is opened —
// otherwise the inbox shows account handles until you happen to go looking at
// your contacts. Best-effort: a failure just leaves the previous naming.
(async () => {
try {
const next = await window.relay.contacts();
if (!Array.isArray(next) || !next.length) return;
contactsList = next;
if (activeView === "relays") renderRelays();
else if (activeView === "sent") renderSent();
else if (activeView === "chat") renderChat();
else if (activeView === "threads") renderThreads();
} catch {}
})();
// Keep the relative "3m / 2h" times honest now that identical payloads are no longer
// re-pushed. Skip while the pointer is on the card (a rebuild under the pointer wipes
// hover state) and while dragging; the next tick catches up.
setInterval(() => {
if (dragging || interactive || !rendererSurfaceActive()) return;
if (activeView === "relays") renderRelays();
else if (activeView === "sent") renderSent();
}, 60000);
// ---------- self-diagnosis ----------
// A frame gap this long is not "slow", it is a visible freeze: the card stops
// moving and clicks appear dead. Record what the UI was doing so the next real
// occurrence on a real machine identifies its own cause (~/.relay/perf.log).
// The watcher itself must obey the surface lifecycle: an always-running rAF
// keeps Chromium producing frames for a folded transparent window.
const STALL_MS = 250;
let lastRenderCostMs = 0;
let frameWatchStats = () => ({ active:false, ticks:0 });
(function installFrameWatcher() {
let watchRaf = null;
let last = 0;
let ticks = 0;
function tick(now) {
if (!rendererSurfaceActive()) { watchRaf = null; return; }
const gap = now - last;
last = now;
ticks += 1;
if (gap > STALL_MS && window.relay.reportStall) {
window.relay.reportStall({
ms: gap,
kind: "frame",
rows: relaysListEl ? relaysListEl.children.length : 0,
animating: Boolean(raf),
collapsed,
lastRenderMs: lastRenderCostMs,
});
}
watchRaf = requestAnimationFrame(tick);
}
function setActive(active) {
if (!active) {
if (watchRaf !== null) cancelAnimationFrame(watchRaf);
watchRaf = null;
return;
}
if (watchRaf !== null) return;
last = performance.now();
watchRaf = requestAnimationFrame(tick);
}
continuousMotionListeners.add(setActive);
frameWatchStats = () => ({ active:watchRaf !== null, ticks });
setActive(syncContinuousMotion());
})();
// Time the list rebuild itself, so a stall report says whether rendering caused it.
const __renderRelays = renderRelays;
renderRelays = function timedRenderRelays(...args) {
const t0 = performance.now();
const out = __renderRelays.apply(this, args);
lastRenderCostMs = performance.now() - t0;
if (lastRenderCostMs > STALL_MS && window.relay.reportStall) {
window.relay.reportStall({ ms: lastRenderCostMs, kind: "render", rows: relaysListEl ? relaysListEl.children.length : 0, collapsed });
}
return out;
};
// Real Electron parity probes run in an isolated profile whose persisted
// schema is deliberately allowed to migrate. Install this only after the
// complete renderer has initialized, and never expose it in production.
if (window.relay.isTestOverlay) {
window.__relayMotionTest = {
state: () => {
const loopAnimations = document.getAnimations({ subtree:true }).filter((animation) => {
try { return animation.effect && animation.effect.getTiming().iterations === Infinity; }
catch { return false; }
});
return {
active:rendererSurfaceActive(),
motion:cardEl.dataset.continuousMotion,
collapsed,
activeView,
deferred:surfaceRenderDeferred,
surfacePaintCount,
frameWatch:frameWatchStats(),
loopAnimations:loopAnimations.map((animation) => animation.playState),
};
},
setCollapsed: (value) => { setCollapsed(Boolean(value)); return collapsed; },
setView: (view) => {
activeView = String(view || "relays");
commitNavigation();
return activeView;
},
render: (count = 1) => {
for (let i = 0; i < Math.max(1, Number(count) || 1); i += 1) renderAll();
return surfacePaintCount;
},
};
window.__relayParityOpenRequest = (fixture) => {
if (!fixture?.id) throw new Error("A Task fixture id is required");
payload.relays = [...(payload.relays || []).filter((row) => row.id !== fixture.id), fixture];
activeView = "tasks";
renderTasksBoard();
openReader(String(fixture.id), "tasks");
return true;
};
}
// Main boots with the designed expanded dimensions, but the renderer remains
// authoritative across reloads and future layout changes. Publish the actual
// initial target before readiness so the native hit rect can never retain a
// larger stale/default size until the first fold-expand transition.
publishCardSize(W.t, H.t, { phase:"settled", motionId:cardMotionId });
// LAST signal on purpose: every listener above is registered and the native
// hit rect is synchronized, so main may now deliver queued arrivals without
// a single one being dropped (FM-1).
if (window.relay.rendererReady) window.relay.rendererReady();
</script>
</body>
</html>