heyquilt 0.3.121 → 0.3.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -2,25 +2,32 @@
2
2
  // windows, one connection per person (WebRTC); the setup for each pair travels in presence
3
3
  // through this app's server (POST /api/sessions/:id/voice, `voice` events). When you agreed to
4
4
  // transcripts and the owner has them on, your own speech is cut into clips at pauses and sent
5
- // to be written down. The parts with no browser in them are in voice-model.js.
6
- import { offersTo, newOfferId, nextStep, Segmenter, downsample, wavBytes, toBase64, rms, speakingNow, keepMissing } from './voice-model.js'
5
+ // to be written down. A screen you share goes the same way, on a video line every connection opens
6
+ // from the start, so sharing needs no new call setup. The parts with no browser in them are in
7
+ // voice-model.js.
8
+ import { offersTo, newOfferId, nextStep, Segmenter, downsample, wavBytes, toBase64, rms, speakingNow, keepMissing, leftCall, peopleInCall, callSound } from './voice-model.js'
7
9
 
8
10
  const GATHER_MS = 3000 // most addresses come in well under this; the rest aren't worth the wait
9
11
  const MIC = { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false }
12
+ // A screen: text stays sharp at a low frame rate. The desktop app picks what is shared (desktop/screen-share.js).
13
+ const SCREEN = { video: { frameRate: { ideal: 15, max: 30 } }, audio: false }
10
14
 
11
15
  export class VoiceCall {
12
16
  /**
13
17
  * `post(body)`: our part in the call to the app server, answers the call as it sees it.
14
18
  * `ice()`: { iceServers }. `onChange()`: something shown changed (who speaks, muted, left).
15
- * `getStream`: the microphone (tests give a tone).
19
+ * `onPeople(sound)`: someone came into the call ('callIn') or left it ('callOut').
20
+ * `getStream`: the microphone (tests give a tone). `getScreen`: a screen to share (tests draw one).
16
21
  */
17
- constructor ({ post, ice, clip, onChange = () => {}, getStream = () => navigator.mediaDevices.getUserMedia(MIC) }) {
18
- Object.assign(this, { post, ice, clip, onChange, getStream })
22
+ constructor ({ post, ice, clip, onChange = () => {}, onPeople = () => {}, getStream = () => navigator.mediaDevices.getUserMedia(MIC), getScreen = () => navigator.mediaDevices.getDisplayMedia(SCREEN) }) {
23
+ Object.assign(this, { post, ice, clip, onChange, onPeople, getStream, getScreen })
24
+ this.people = new Set() // names in the call with us: in voice, or kept through a relay blip
19
25
  this.in = false
20
26
  this.muted = false
21
27
  this.transcribing = false
22
28
  this.me = 0
23
- this.links = new Map() // peer id -> { pc, n, accepted, answered, failed, since, audio, analyser, name }
29
+ this.links = new Map() // peer id -> { pc, n, accepted, answered, failed, since, audio, analyser, name, screenTx, screen }
30
+ this.screen = null // the screen we share, while we do
24
31
  this.sig = {} // peer id -> the signal we leave them
25
32
  this.levels = new Map() // 'me' or peer id -> { speaking, at }
26
33
  this.view = null
@@ -45,7 +52,7 @@ export class VoiceCall {
45
52
  this.timer = setInterval(() => this.measure(), 100)
46
53
  // An offer nobody answered is made again after a while: look every few seconds, not only on news.
47
54
  this.retry = setInterval(() => { if (this.view) this.update(this.view) }, 5000)
48
- this.update(await this.post({ muted: false, transcribing, sig: {} }))
55
+ this.update(await this.post({ muted: false, transcribing, sharing: false, sig: {} }))
49
56
  }
50
57
 
51
58
  /** Leaves: every connection closes, the microphone stops, and presence says we're out. */
@@ -54,8 +61,11 @@ export class VoiceCall {
54
61
  this.in = false
55
62
  clearInterval(this.timer)
56
63
  clearInterval(this.retry)
64
+ this.stopShare()
57
65
  this.segmenter?.end()
58
66
  for (const id of [...this.links.keys()]) this.drop(id)
67
+ this.people = new Set()
68
+ this.counted = false
59
69
  this.stream?.getTracks().forEach((t) => t.stop())
60
70
  this.worklet?.disconnect()
61
71
  await this.ctx?.close().catch(() => {})
@@ -73,6 +83,35 @@ export class VoiceCall {
73
83
  this.onChange()
74
84
  }
75
85
 
86
+ get sharing () { return !!this.screen }
87
+
88
+ /** Shares a screen with everyone in the call: the system or the desktop app asks which. */
89
+ async share () {
90
+ if (!this.in || this.screen) return
91
+ const stream = await this.getScreen()
92
+ const track = stream.getVideoTracks()[0]
93
+ if (!track || !this.in || this.screen) { stream.getTracks().forEach((t) => t.stop()); return }
94
+ track.contentHint = 'detail'
95
+ // "Stop sharing" in the system's own bar, or the shared window closing, ends it here too.
96
+ track.addEventListener('ended', () => { if (this.screen === stream) this.stopShare() })
97
+ this.screen = stream
98
+ for (const l of this.links.values()) l.screenTx?.sender.replaceTrack(track).catch(() => {})
99
+ this.publish()
100
+ this.onChange()
101
+ }
102
+
103
+ stopShare () {
104
+ if (!this.screen) return
105
+ this.screen.getTracks().forEach((t) => t.stop())
106
+ this.screen = null
107
+ for (const l of this.links.values()) l.screenTx?.sender.replaceTrack(null).catch(() => {})
108
+ this.publish()
109
+ this.onChange()
110
+ }
111
+
112
+ /** The screen one person shares with us, once it arrives (they say they share in presence). */
113
+ screenOf (id) { return this.links.get(id)?.screen || null }
114
+
76
115
  /** Transcripts on or off for our own speech (we agreed, and the owner has them on). */
77
116
  async setTranscribing (on) {
78
117
  this.transcribing = !!on
@@ -92,14 +131,22 @@ export class VoiceCall {
92
131
  const here = new Set(view.peers.map((p) => p.id))
93
132
  // Someone missing from presence is often a relay blip, not a hang-up: their connection is
94
133
  // kept while it is up (keepMissing), and goes once it isn't or they've been gone too long.
134
+ // Someone still in the session but out of voice hung up: theirs goes at once.
95
135
  const t = Date.now()
96
136
  for (const [id, l] of [...this.links]) {
97
137
  if (here.has(id)) { l.missingSince = 0; continue }
98
138
  l.missingSince = l.missingSince || t
99
- if (!keepMissing({ state: l.pc.connectionState, missingSince: l.missingSince, now: t })) this.drop(id)
139
+ if (leftCall({ id, here: view.here }) || !keepMissing({ state: l.pc.connectionState, missingSince: l.missingSince, now: t })) this.drop(id)
100
140
  }
101
141
  for (const id of Object.keys(this.sig)) if (!here.has(Number(id)) && !this.links.has(Number(id))) delete this.sig[id]
102
142
  for (const p of view.peers) this.step(p).catch((err) => console.warn('voice', p.name, err))
143
+ // The join and leave sounds follow who is in the call, not presence: a relay blip plays neither.
144
+ const people = peopleInCall(view.peers, this.links)
145
+ const sound = callSound(this.people, people)
146
+ // Those already in when we join are no news: joining plays its own sound.
147
+ if (sound && this.counted) this.onPeople(sound)
148
+ this.people = people
149
+ this.counted = true
103
150
  this.onChange()
104
151
  }
105
152
 
@@ -108,7 +155,7 @@ export class VoiceCall {
108
155
  const what = nextStep({ me: this.me, peer: p.id, signal: p.signal, link })
109
156
  if (link) link.name = p.name
110
157
  if (what === 'offer') {
111
- const l = this.connect(p)
158
+ const l = this.connect(p, newOfferId(), { offering: true })
112
159
  const offer = await l.pc.createOffer()
113
160
  await l.pc.setLocalDescription(offer)
114
161
  await gathered(l.pc)
@@ -118,6 +165,10 @@ export class VoiceCall {
118
165
  } else if (what === 'answer') {
119
166
  const l = this.connect(p, p.signal.n)
120
167
  await l.pc.setRemoteDescription({ type: 'offer', sdp: p.signal.sdp })
168
+ // Their offer's video line, open both ways: either of us can share. An app from before
169
+ // screen sharing offers none, and nobody's screen goes to or comes from it.
170
+ const tx = l.pc.getTransceivers().find((t) => t.receiver.track.kind === 'video')
171
+ if (tx) l.screenTx = screenLine(tx, this.screen)
121
172
  const answer = await l.pc.createAnswer()
122
173
  await l.pc.setLocalDescription(answer)
123
174
  await gathered(l.pc)
@@ -131,13 +182,16 @@ export class VoiceCall {
131
182
  }
132
183
  }
133
184
 
134
- /** A fresh connection to one person, replacing any old one. */
135
- connect (p, n = newOfferId()) {
185
+ /** A fresh connection to one person, replacing any old one. The one offering adds the video line for a screen. */
186
+ connect (p, n = newOfferId(), { offering = false } = {}) {
136
187
  this.drop(p.id, { keepSig: true })
137
- const pc = new RTCPeerConnection({ iceServers: this.servers?.iceServers || [] })
188
+ // One transport for the voice and the screen: its addresses are in the offer once.
189
+ const pc = new RTCPeerConnection({ iceServers: this.servers?.iceServers || [], bundlePolicy: 'max-bundle' })
138
190
  for (const t of this.stream.getAudioTracks()) pc.addTrack(t, this.stream)
139
- const l = { pc, n, accepted: false, answered: false, failed: false, since: Date.now(), name: p.name }
191
+ const l = { pc, n, accepted: false, answered: false, failed: false, since: Date.now(), name: p.name, screenTx: null, screen: null }
192
+ if (offering) l.screenTx = screenLine(pc.addTransceiver('video', { direction: 'sendrecv' }), this.screen)
140
193
  pc.ontrack = (e) => {
194
+ if (e.track.kind === 'video') { l.screen = new MediaStream([e.track]); this.onChange(); return }
141
195
  if (l.audio) return
142
196
  const s = e.streams[0] || new MediaStream([e.track])
143
197
  l.audio = new Audio()
@@ -181,7 +235,7 @@ export class VoiceCall {
181
235
  publish () {
182
236
  if (!this.in) return
183
237
  if (this.sending) { this.again = true; return }
184
- this.sending = this.post({ muted: this.muted, transcribing: this.transcribing, sig: this.sig })
238
+ this.sending = this.post({ muted: this.muted, transcribing: this.transcribing, sharing: !!this.screen, sig: this.sig })
185
239
  .then((view) => { this.sending = null; this.update(view) }, () => { this.sending = null })
186
240
  .finally(() => { if (this.again) { this.again = false; this.publish() } })
187
241
  }
@@ -221,6 +275,21 @@ export class VoiceCall {
221
275
  }
222
276
  }
223
277
 
278
+ /**
279
+ * A connection's video line for a screen: open both ways, the codecs that suit a screen first (and
280
+ * fewer of them, so the offer stays small: voice.js MAX_SDP), and our screen on it if we share one.
281
+ */
282
+ function screenLine (tx, screen) {
283
+ tx.direction = 'sendrecv'
284
+ try {
285
+ const codecs = RTCRtpReceiver.getCapabilities('video').codecs.filter((c) => /^video\/(VP9|VP8|rtx)$/i.test(c.mimeType))
286
+ if (codecs.some((c) => !/rtx/i.test(c.mimeType))) tx.setCodecPreferences(codecs.sort((a, b) => /VP9/i.test(b.mimeType) - /VP9/i.test(a.mimeType)))
287
+ } catch {}
288
+ const track = screen?.getVideoTracks()[0]
289
+ if (track) tx.sender.replaceTrack(track).catch(() => {})
290
+ return tx
291
+ }
292
+
224
293
  /** Waits until the connection has gathered its addresses (or GATHER_MS), so one offer or answer carries them all. */
225
294
  function gathered (pc) {
226
295
  if (pc.iceGatheringState === 'complete') return Promise.resolve()
@@ -141,3 +141,42 @@ export function keepMissing ({ state, missingSince, now = Date.now(), graceMs =
141
141
  const gone = now - missingSince
142
142
  return state === 'connected' ? gone < connectedMs : gone < graceMs
143
143
  }
144
+
145
+ /**
146
+ * Whether someone missing from the call's presence hung up: they are still in the session
147
+ * (`here`, every presence id the app server sees) but no longer in voice. Someone gone from the
148
+ * session altogether may be a relay blip (keepMissing).
149
+ */
150
+ export const leftCall = ({ id, here }) => Array.isArray(here) && here.includes(id)
151
+
152
+ /** Who is in the call with us, by name: in voice now, or missing from presence with their connection kept. */
153
+ export function peopleInCall (peers, links) {
154
+ return new Set([...peers.map((p) => p.name), ...[...links.values()].map((l) => l.name)].filter(Boolean))
155
+ }
156
+
157
+ /**
158
+ * Whose shared screen fills the middle of the session: the one picked while they still share,
159
+ * otherwise the first to share. `hidden`: those whose screen was hidden (until they stop sharing).
160
+ */
161
+ export function screenShown ({ peers, picked = null, hidden = new Set() }) {
162
+ const sharing = peers.filter((p) => p.sharing && !hidden.has(p.id))
163
+ return (sharing.find((p) => p.id === picked) || sharing[0])?.id ?? null
164
+ }
165
+
166
+ /** The sound for a change in who is in the call: 'callIn', 'callOut' or null. */
167
+ export function callSound (before, now) {
168
+ if ([...now].some((n) => !before.has(n))) return 'callIn'
169
+ if ([...before].some((n) => !now.has(n))) return 'callOut'
170
+ return null
171
+ }
172
+
173
+ /**
174
+ * What to say when sharing a screen didn't start: nothing when the person backed out of the picker
175
+ * (AbortError from the desktop app, "by user" from a browser), otherwise why, so the button never
176
+ * does nothing in silence.
177
+ */
178
+ export function shareRefused (err) {
179
+ if (!err || err.name === 'AbortError' || /by user/i.test(err.message || '')) return ''
180
+ if (err.name === 'NotAllowedError') return "Quilt can't share your screen. On a Mac, allow Quilt in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen Quilt."
181
+ return `Couldn't share your screen: ${err.message || err.name}`
182
+ }
package/src/ui/voice.js CHANGED
@@ -2,10 +2,12 @@
2
2
  // going on is pinned to the bottom of the chat, above the message box: who is in it, Join, or
3
3
  // once you're in it mute, leave, a ring around whoever speaks and the latest lines as each is
4
4
  // written down. A transcribed call says so before your microphone opens. Finished calls stay in
5
- // the chat as cards that open their transcript in place. The audio is voice-call.js; the call as
6
- // the app's server sees it comes as `voice` events.
5
+ // the chat as cards that open their transcript in place. A screen someone shares fills the middle
6
+ // of the session, the chat staying on the right; Hide puts back what was there. The audio and the
7
+ // screens are voice-call.js; the call as the app's server sees it comes as `voice` events.
7
8
  import { $, esc, api, toast, avatar, I, TOKEN, clock } from './common.js'
8
9
  import { VoiceCall } from './voice-call.js'
10
+ import { screenShown, shareRefused } from './voice-model.js'
9
11
  import { playSound } from './sounds.js'
10
12
 
11
13
  const views = new Map() // session id -> the call as that session's server sees it
@@ -19,15 +21,44 @@ let options = false // the transcript choices, open in the pinned call
19
21
  const opened = new Set() // finished calls whose transcript is open in the chat
20
22
  const full = new Map() // call id -> its lines, once opened
21
23
  const checked = new Set() // sessions whose leftover "in the call" (from before a reload) was cleared
24
+ let picked = null // whose shared screen was picked to show, when several share
25
+ const hiddenScreens = new Set() // presence ids whose screen was hidden, until they stop sharing
26
+ let actualSize = false // a shared screen at its own size (scrolling) instead of fitted
27
+ let picking = null // { sources, blocked, resolve }: the desktop app asks which screen or window to share
28
+ let stageDrawn = ''
29
+ let screenVideo = null // the shared screen's picture: one element kept across Hide and Show, so it keeps its last frame
30
+ let selfClosed = false // our own screen's preview closed, until we stop sharing
31
+ let selfVideo = null // our own screen's preview: one element, like screenVideo
22
32
  let drawn = '' // what the pinned call was last drawn from: redrawn only when that changes, so a click is never lost mid-press
23
33
  let callsDrawn = ''
24
34
 
35
+ const NO_SCREEN = "Quilt can't see your screen. Allow it for Quilt in System Settings → Privacy & Security → Screen & System Audio Recording, then quit and reopen Quilt."
25
36
  const NO_MIC = "Quilt can't use your microphone. Allow it for Quilt in System Settings → Privacy & Security → Microphone, then try again."
26
37
 
27
38
  /** "Call" in the chat's header, shown while no call is going. */
28
39
  export const callButtonMarkup = () => `<button type="button" class="chat-call" id="chat-call" data-call-join="ask" title="Start a voice call in this session" hidden>${I.headset}<span>Call</span></button>`
29
40
  /** The call going on, pinned above the message box. */
30
41
  export const callPinMarkup = () => '<div class="call-pin" id="call-pin" role="region" aria-label="Voice call" hidden></div>'
42
+ /** A shared screen (or what to share), over the middle of the session. */
43
+ export const screenStageMarkup = () => '<div class="screen-stage" id="screen-stage" role="region" aria-label="Shared screen" hidden></div><div class="self-share" id="self-share" role="region" aria-label="What you are sharing" hidden></div>'
44
+
45
+ // The desktop app with no system picker (Windows, macOS before 15) asks here which screen or
46
+ // window to share (desktop/screen-share.js): the choices fill the middle, the answer goes back.
47
+ window.quiltDesktop?.onPickScreen?.((ask) => pickScreen(ask))
48
+
49
+ /** Shows what can be shared (`sources`, or `blocked` when the system doesn't let Quilt see the screen); resolves to the id picked, or null. */
50
+ export function pickScreen ({ sources = [], blocked = false } = {}) {
51
+ endPicking(null)
52
+ if (!shown || !live || live.sid !== shown.id) return Promise.resolve(null)
53
+ return new Promise((resolve) => { picking = { sources, blocked, resolve }; renderCall() })
54
+ }
55
+
56
+ function endPicking (id) {
57
+ if (!picking) return
58
+ const { resolve } = picking
59
+ picking = null
60
+ resolve(id)
61
+ }
31
62
 
32
63
  /**
33
64
  * A session is on screen: wire the call into its chat. `who()`: { owner, me, colorOf(name) } now;
@@ -39,32 +70,28 @@ export function mountCall (id, signal, who, chatChanged = () => {}) {
39
70
  options = false
40
71
  drawn = ''
41
72
  callsDrawn = ''
73
+ stageDrawn = ''
42
74
  $('#chat-pane')?.addEventListener('click', onClick, { signal })
75
+ $('#screen-stage')?.addEventListener('click', onClick, { signal })
76
+ $('#self-share')?.addEventListener('click', onClick, { signal })
43
77
  const tick = setInterval(renderCall, 20000) // the call's minutes
44
78
  signal.addEventListener('abort', () => clearInterval(tick))
45
79
  api('GET', `/api/sessions/${id}/voice`).then((v) => voiceEvent(id, v), () => {})
46
80
  renderCall()
47
81
  }
48
82
 
49
- export function unmountCall () { shown = null; asking = false; options = false }
83
+ export function unmountCall () { endPicking(null); shown = null; asking = false; options = false }
50
84
 
51
85
  /** The call as the app's server sees it now (a `voice` event, or an answer). */
52
86
  export function voiceEvent (id, view) {
53
- const was = views.get(id)
54
87
  views.set(id, view)
55
88
  // Left in the call by a window that closed or reloaded without leaving: out now.
56
89
  if (!checked.has(id)) {
57
90
  checked.add(id)
58
91
  if (view.in && !(live && live.sid === id)) { api('POST', `/api/sessions/${id}/voice`, { in: false }).then((v) => voiceEvent(id, v), () => {}); return }
59
92
  }
60
- if (live && live.sid === id) {
61
- // Someone came into or left the call we're in.
62
- const before = new Set((was?.peers || []).map((p) => p.id))
63
- const now = new Set(view.peers.map((p) => p.id))
64
- if ([...now].some((p) => !before.has(p))) playSound('callIn', { force: true })
65
- else if ([...before].some((p) => !now.has(p))) playSound('callOut', { force: true })
66
- live.call.update(view)
67
- }
93
+ // The call we're in: it plays the join and leave sounds itself (onPeople), so a relay blip plays neither.
94
+ if (live && live.sid === id) live.call.update(view)
68
95
  if (shown && shown.id === id) {
69
96
  renderCall()
70
97
  // A call ended: its card joins the chat. The call going on now changes every few seconds and
@@ -97,7 +124,8 @@ async function join (id, transcribing) {
97
124
  }
98
125
  throw err
99
126
  }),
100
- onChange: () => { if (shown && shown.id === id) renderCall() }
127
+ onChange: () => { if (shown && shown.id === id) renderCall() },
128
+ onPeople: (sound) => playSound(sound, { force: true })
101
129
  })
102
130
  live = { sid: id, call }
103
131
  await call.join({ transcribing })
@@ -116,6 +144,12 @@ async function leave () {
116
144
  const { sid, call } = live
117
145
  live = null
118
146
  options = false
147
+ endPicking(null)
148
+ picked = null
149
+ hiddenScreens.clear()
150
+ if (screenVideo) screenVideo.srcObject = null
151
+ selfClosed = false
152
+ if (selfVideo) selfVideo.srcObject = null
119
153
  const v = await call.leave().catch(() => null)
120
154
  if (v) voiceEvent(sid, v)
121
155
  playSound('callOut', { force: true })
@@ -130,7 +164,7 @@ window.addEventListener('pagehide', () => {
130
164
 
131
165
  async function onClick (e) {
132
166
  const t = e.target.closest('button')
133
- if (!t || !shown || !t.closest('#chat-call, #call-pin, .call-card')) return
167
+ if (!t || !shown || !t.closest('#chat-call, #call-pin, #screen-stage, #self-share, .call-card')) return
134
168
  const id = shown.id
135
169
  const view = views.get(id) || EMPTY
136
170
  const d = t.dataset
@@ -145,6 +179,32 @@ async function onClick (e) {
145
179
  } else if (d.callOptions !== undefined) {
146
180
  options = !options
147
181
  renderCall()
182
+ } else if (d.callShare !== undefined) {
183
+ if (!live) return
184
+ if (live.call.sharing) live.call.stopShare()
185
+ else live.call.share().catch((err) => { const why = shareRefused(err); if (why) toast(why) })
186
+ } else if (d.screenShow) {
187
+ picked = Number(d.screenShow)
188
+ hiddenScreens.clear()
189
+ renderCall()
190
+ } else if (d.screenHide !== undefined) {
191
+ // Hidden until they stop sharing: everyone sharing now, so another screen doesn't take its place.
192
+ for (const p of view.peers) if (p.sharing) hiddenScreens.add(p.id)
193
+ renderCall()
194
+ } else if (d.selfShare) {
195
+ selfClosed = d.selfShare === 'close'
196
+ renderCall()
197
+ } else if (d.screenSize) {
198
+ actualSize = d.screenSize === 'actual'
199
+ renderCall()
200
+ } else if (d.screenFull !== undefined) {
201
+ $('#screen-stage .ss-view')?.requestFullscreen?.().catch(() => {})
202
+ } else if (d.screenSrc) {
203
+ endPicking(d.screenSrc)
204
+ renderCall()
205
+ } else if (d.screenCancel !== undefined) {
206
+ endPicking(null)
207
+ renderCall()
148
208
  } else if (d.callMute !== undefined) {
149
209
  live?.call.setMuted(!live.call.muted)
150
210
  } else if (d.callLeave !== undefined) {
@@ -207,7 +267,14 @@ export function renderCall () {
207
267
  // The latest lines change every few seconds: they're drawn on their own, so the controls above
208
268
  // them (Join, mute, leave) are never redrawn under a click.
209
269
  const lines = here && view.recent.length ? view.recent.map((l) => lineHtml(l, colorOf)).join('') : ''
210
- const key = JSON.stringify([id, here, joining, asking, options, owner, elsewhere, view.transcribe, others.map((p) => [p.id, p.name, p.color, p.muted, p.transcribing]), me.name, me.color, c && [c.muted, c.transcribing], minutes(view.since || Date.now())])
270
+ // A screen hidden stays hidden until its person stops sharing; the next time they share, it shows.
271
+ for (const pid of [...hiddenScreens]) if (!others.some((p) => p.id === pid && p.sharing)) hiddenScreens.delete(pid)
272
+ const showing = here ? screenShown({ peers: others, picked, hidden: hiddenScreens }) : null
273
+ renderStage({ here, showing, others, colorOf })
274
+ // Closing the preview lasts until we stop sharing; the next share shows it again.
275
+ if (!c?.sharing) selfClosed = false
276
+ renderSelf(c)
277
+ const key = JSON.stringify([id, here, joining, asking, options, owner, elsewhere, view.transcribe, others.map((p) => [p.id, p.name, p.color, p.muted, p.transcribing, p.sharing]), me.name, me.color, c && [c.muted, c.transcribing, c.sharing], selfClosed, showing, minutes(view.since || Date.now())])
211
278
  if (key === drawn && (pin.hidden || pin.childElementCount)) { paintLines(pin, lines); paintTalk(); return }
212
279
  drawn = key
213
280
  linesDrawn = null
@@ -221,7 +288,7 @@ export function renderCall () {
221
288
  <div class="cp-two"><button type="button" class="btn sm primary" data-call-join="transcribed">${others.length ? 'Join' : 'Start'}, transcribed</button><button type="button" class="btn sm" data-call-join="plain">${others.length ? 'Join' : 'Start'}, not transcribed</button></div>`
222
289
  } else if (!here) {
223
290
  pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><span class="call-faces">${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>
224
- <span class="cp-what"><b>${esc(names(others.map((p) => p.name)))} ${others.length === 1 ? 'is' : 'are'} in a call</b><span class="call-sub">${[dur, view.transcribe ? 'transcribed' : ''].filter(Boolean).join(' · ')}</span></span>
291
+ <span class="cp-what"><b>${esc(names(others.map((p) => p.name)))} ${others.length === 1 ? 'is' : 'are'} in a call</b><span class="call-sub">${[dur, view.transcribe ? 'transcribed' : '', ...others.filter((p) => p.sharing).map((p) => `${esc(p.name)} sharing a screen`)].filter(Boolean).join(' · ')}</span></span>
225
292
  <button type="button" class="btn sm primary cp-end" data-call-join="ask" ${elsewhere ? 'disabled title="Leave your other call first"' : others.length >= FULL ? 'disabled title="This call is full: calls hold six people for now."' : ''}>Join</button></div>`
226
293
  } else {
227
294
  const mine = c.transcribing && view.transcribe
@@ -235,16 +302,99 @@ export function renderCall () {
235
302
  ? `<div class="cp-opt"><span><b>Transcripts</b> · ${view.transcribe ? 'On: people who agree are written down for the session and its agents.' : 'Off: nothing said in calls is written down.'}</span><button type="button" class="btn sm" data-call-owner="${view.transcribe ? 'off' : 'on'}">${view.transcribe ? 'Turn off' : 'Turn on'}</button></div>`
236
303
  : ''
237
304
  pin.innerHTML = `<div class="cp-top"><span class="call-dot"></span><b>Call</b><span class="call-sub">${esc(dur)}</span>
238
- <button type="button" class="call-ic cp-end" data-call-mute aria-pressed="${c.muted}" title="${c.muted ? 'Unmute' : 'Mute'}" aria-label="${c.muted ? 'Unmute' : 'Mute'}">${c.muted ? I.micOff : I.mic}</button>
305
+ <button type="button" class="call-ic cp-end" data-call-share aria-pressed="${c.sharing}" title="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}" aria-label="${c.sharing ? 'Stop sharing your screen' : 'Share your screen'}">${c.sharing ? I.screenOff : I.screenShare}</button>
306
+ <button type="button" class="call-ic" data-call-mute aria-pressed="${c.muted}" title="${c.muted ? 'Unmute' : 'Mute'}" aria-label="${c.muted ? 'Unmute' : 'Mute'}">${c.muted ? I.micOff : I.mic}</button>
239
307
  <button type="button" class="call-ic bad" data-call-leave title="Leave the call" aria-label="Leave the call">${I.hangUp}</button></div>
240
308
  <div class="cp-row"><span class="call-faces">${face('me', me.name, me.color, c.muted)}${others.map((p) => face(p.id, p.name, p.color, p.muted)).join('')}</span>${tag}</div>
241
309
  ${options ? `<div class="cp-opts">${myChoice}${ownerChoice}</div>` : ''}
310
+ ${c.sharing ? `<div class="cp-share">${I.screenShare}<span class="cp-says">Sharing your screen</span>${selfClosed ? '<button type="button" class="btn sm ghost" data-self-share="open" title="Show what you share, in the corner">Show</button>' : ''}<button type="button" class="btn sm" data-call-share>Stop sharing</button></div>` : ''}
311
+ ${others.filter((p) => p.sharing).map((p) => `<div class="cp-share">${I.screenShare}<span class="cp-says">${esc(p.name)} is sharing a screen</span>${p.id === showing ? '<span class="call-sub">in the middle</span>' : `<button type="button" class="btn sm" data-screen-show="${p.id}">Show</button>`}</div>`).join('')}
242
312
  <div class="cp-lines" aria-live="polite" hidden></div>`
243
313
  }
244
314
  paintLines(pin, lines)
245
315
  paintTalk()
246
316
  }
247
317
 
318
+ /**
319
+ * The middle of the session while we're in the call: the screen someone shares (`showing`, a
320
+ * presence id), or what to share when the desktop app asks. Hidden otherwise, which puts back
321
+ * whatever was there. Redrawn only when what it shows changes; the picture itself never redraws.
322
+ */
323
+ function renderStage ({ here, showing, others, colorOf }) {
324
+ const stage = $('#screen-stage')
325
+ if (!stage) return
326
+ const p = showing != null ? others.find((x) => x.id === showing) : null
327
+ const sharers = others.filter((x) => x.sharing)
328
+ const open = !!(here && (picking || p))
329
+ stage.hidden = !open
330
+ const key = JSON.stringify(open ? (picking ? ['pick', picking.blocked, picking.sources.map((s) => s.id)] : ['screen', p.id, p.name, p.color, sharers.map((x) => [x.id, x.name])]) : null)
331
+ if (key !== stageDrawn) {
332
+ stageDrawn = key
333
+ stage.innerHTML = !open ? '' : picking ? pickerHtml(picking) : screenHtml(p, sharers, colorOf)
334
+ }
335
+ if (!open || picking) return
336
+ // Fit or its own size: the same picture, never reloaded.
337
+ stage.querySelector('.ss-view').classList.toggle('actual', actualSize)
338
+ for (const b of stage.querySelectorAll('[data-screen-size]')) {
339
+ const on = (b.dataset.screenSize === 'actual') === actualSize
340
+ b.classList.toggle('on', on)
341
+ b.setAttribute('aria-pressed', on)
342
+ }
343
+ const stream = live.call.screenOf(p.id)
344
+ if (!screenVideo) screenVideo = Object.assign(document.createElement('video'), { autoplay: true, muted: true, playsInline: true })
345
+ const video = screenVideo
346
+ const box = stage.querySelector('.ss-view')
347
+ const wait = stage.querySelector('.ss-wait')
348
+ if (video.parentNode !== box) box.prepend(video)
349
+ if (video.srcObject !== stream) video.srcObject = stream
350
+ // Taken out of the page (Hide) pauses it.
351
+ if (stream && video.paused) video.play().catch(() => {})
352
+ // Until its first picture arrives (a moment after they start sharing), say whose it will be.
353
+ wait.hidden = !!(stream && video.videoWidth)
354
+ video.onresize = () => { if (video.parentNode) video.parentNode.querySelector('.ss-wait').hidden = true }
355
+ }
356
+
357
+ /** What we share, small in the corner of the middle, so we see what everyone sees. `c`: our call, when we're in one. */
358
+ function renderSelf (c) {
359
+ const box = $('#self-share')
360
+ if (!box) return
361
+ const open = !!(c && c.sharing && c.screen && !selfClosed)
362
+ box.hidden = !open
363
+ if (!open) { if (selfVideo) selfVideo.srcObject = null; return }
364
+ if (!box.childElementCount) {
365
+ box.innerHTML = `<div class="sf-bar">${I.screenShare}<span>You're sharing this</span><button type="button" class="sf-x" data-self-share="close" title="Close the preview (you keep sharing)" aria-label="Close the preview">${I.x}</button></div><div class="sf-view"></div>`
366
+ }
367
+ if (!selfVideo) selfVideo = Object.assign(document.createElement('video'), { autoplay: true, muted: true, playsInline: true })
368
+ const view = box.querySelector('.sf-view')
369
+ if (selfVideo.parentNode !== view) view.append(selfVideo)
370
+ if (selfVideo.srcObject !== c.screen) selfVideo.srcObject = c.screen
371
+ if (selfVideo.paused) selfVideo.play().catch(() => {})
372
+ }
373
+
374
+ function screenHtml (p, sharers, colorOf) {
375
+ const chips = sharers.length > 1
376
+ ? `<div class="loom-seg" role="group" aria-label="Whose screen">${sharers.map((x) => `<button type="button" class="loom-chip${x.id === p.id ? ' on' : ''}" data-screen-show="${x.id}" aria-pressed="${x.id === p.id}">${esc(x.name)}</button>`).join('')}</div>`
377
+ : ''
378
+ return `<div class="loom-bar ss-bar">
379
+ <div class="loom-title ss-who">${avatar(p.name, p.color || colorOf(p.name))}<b>${esc(p.name)}'s screen</b><span class="hint">live in the call</span></div>
380
+ ${chips}<span class="spacer"></span>
381
+ <div class="loom-seg" role="group" aria-label="Size"><button type="button" class="loom-chip${actualSize ? '' : ' on'}" data-screen-size="fit" aria-pressed="${!actualSize}">Fit</button><button type="button" class="loom-chip${actualSize ? ' on' : ''}" data-screen-size="actual" aria-pressed="${actualSize}">Actual size</button></div>
382
+ <button type="button" class="loom-chip" data-screen-full>${I.fullScreen}<span>Full screen</span></button>
383
+ <button type="button" class="loom-chip" data-screen-hide title="Back to what was here">Hide</button>
384
+ </div>
385
+ <div class="ss-view${actualSize ? ' actual' : ''}"><p class="ss-wait">Waiting for ${esc(p.name)}'s screen…</p></div>`
386
+ }
387
+
388
+ function pickerHtml ({ sources, blocked }) {
389
+ const card = (s) => `<button type="button" class="ss-src" data-screen-src="${esc(s.id)}" title="${esc(s.name)}"><span class="ss-thumb">${s.thumb ? `<img src="${esc(s.thumb)}" alt="">` : I.screenShare}</span><span class="ss-name">${esc(s.name || (s.screen ? 'Screen' : 'Window'))}</span></button>`
390
+ const group = (title, list) => list.length ? `<h4>${title}</h4><div class="ss-grid">${list.map(card).join('')}</div>` : ''
391
+ const body = blocked
392
+ ? `<p class="ss-note">${esc(NO_SCREEN)}</p>`
393
+ : sources.length ? group('Screens', sources.filter((s) => s.screen)) + group('Windows', sources.filter((s) => !s.screen)) : '<p class="ss-note">There is nothing to share.</p>'
394
+ return `<div class="loom-bar ss-bar"><div class="loom-title"><b>Share your screen</b><span class="hint">Everyone in the call sees what you pick</span></div><span class="spacer"></span><button type="button" class="loom-chip" data-screen-cancel>Cancel</button></div>
395
+ <div class="ss-pick">${body}</div>`
396
+ }
397
+
248
398
  let linesDrawn = null
249
399
  /** The latest lines of the call, under its controls. */
250
400
  function paintLines (pin, html) {