@gotcos/glasses-server 6.99.29 → 7.0.7
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.
- package/.env.example +18 -0
- package/CHANGELOG.md +475 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +79 -9
- package/server/web-control/app.js +1543 -65
- package/server/web-control/control.css +37 -6
- package/server/web-control/index.html +8 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +51 -6
- package/server/web-control/panes/cos-chat.js +268 -16
- package/server/web-control/panes/meetings.css +23 -4
- package/server/web-control/panes/meetings.js +25 -9
- package/server/web-control/panes/memories.css +18 -3
- package/server/web-control/panes/memories.js +130 -50
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +127 -26
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +50 -10
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +49 -18
- package/server/web-control/panes/today-actions.js +217 -50
- package/server/web-control/panes/work.css +11 -1
- package/server/web-control/panes/work.js +10 -2
- package/server/web-control/shell.js +20 -14
|
@@ -24,13 +24,13 @@ import {
|
|
|
24
24
|
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
25
25
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
26
26
|
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
27
|
-
type SessionSteerAvailability,
|
|
27
|
+
steerMidturnEnabled, type SessionSteerAvailability,
|
|
28
28
|
} from '../lib/thread-turn-queue.js'
|
|
29
29
|
import { readQueue, writeQueue, queuedThreadKeys } from '../lib/thread-turn-queue-store.js'
|
|
30
30
|
import type { DeliveryEvidence } from '../lib/queue-turn-evidence.js'
|
|
31
31
|
import { continueLiveEnabled } from '../lib/session-peer-inbox-deps.js'
|
|
32
32
|
import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
|
|
33
|
-
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
33
|
+
import { attachmentsDigest, FOOTER_LINE_REFUSED_COPY, hasFooterShapedLine, sealMatches, stripG2TurnFooter, TURN_ID_IN_USE_COPY, unvouched, type G2TurnProvenance, type OriginVerdict } from '../lib/g2-turn-provenance.js'
|
|
34
34
|
import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds, TURN_ATTACHMENT_COPY, turnAttachmentManifest, type TurnAttachmentStore } from '../lib/session-turn-attachments.js'
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -115,6 +115,23 @@ export interface ThreadTurnQueueDeps {
|
|
|
115
115
|
begin: (provider: string, threadId: string, clientTurnId: string, mode: 'turn' | 'steer', now: number) => boolean
|
|
116
116
|
settle: (provider: string, threadId: string, clientTurnId: string, exit: 'sent' | 'not_sent' | 'unknown', now: number) => boolean
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Steer mid-turn (COS_STEER_MIDTURN), optional: ask for a drain pass for this thread now, so a
|
|
120
|
+
* turn parked at a busy session that can take it mid-turn goes in within seconds instead of at
|
|
121
|
+
* the next 20 s sweep. Production: the shared drain kick (one pass in flight, ever). Absent: the
|
|
122
|
+
* sweep still delivers it, only later.
|
|
123
|
+
*/
|
|
124
|
+
kickDrain?: (threadId: string) => void
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Can this thread take a parked turn mid-turn right now (COS_STEER_MIDTURN)? The same shared rule
|
|
129
|
+
* as Session steer, plus the switch and the two members the drain's steer needs. Never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function midTurnAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe' | 'deliverLiveTurn' | 'continueLive' | 'attempts'>, provider: string, threadId: string, gateReason: string | null): SessionSteerAvailability | null {
|
|
132
|
+
if (!steerMidturnEnabled()) return null
|
|
133
|
+
if (typeof deps.deliverLiveTurn !== 'function' || !deps.attempts) return { available: false, reason: 'no_outside_channel' }
|
|
134
|
+
return steerAvailabilityFor(deps, provider, threadId, gateReason)
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
/**
|
|
@@ -210,6 +227,159 @@ function isRetryableDelivery(outcome: { reason?: string; serverRetryable?: boole
|
|
|
210
227
|
return queueableRefusal(outcome.reason)
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The verdict a drain hop with no parked origin is judged by: the loopback call it is. The front
|
|
232
|
+
* door reaches the same verdict through `originOf` on the drainer's own request (local socket,
|
|
233
|
+
* token shown), so a steered turn and a front-door turn are ledgered alike. Never footed.
|
|
234
|
+
*/
|
|
235
|
+
function localDrainVerdict(at: number): OriginVerdict {
|
|
236
|
+
return {
|
|
237
|
+
origin: { remote: '127.0.0.1', loopback: true, ownAddress: true, clientInstance: null, client: 'local', tokenPresented: true, webSession: false },
|
|
238
|
+
verifiedOrigin: false,
|
|
239
|
+
reason: 'local_socket',
|
|
240
|
+
judgedAt: at,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
|
|
245
|
+
type MidTurnOutcome = 'delivered' | 'settled' | 'held'
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
|
|
249
|
+
* of a busy Claude desk session through its peer inbox. Claude Code takes the frame at once (its
|
|
250
|
+
* `enqueue` row is the proof `deliverLiveTurn` waits for) and folds it in at its next tool step.
|
|
251
|
+
*
|
|
252
|
+
* Called only by `drainThread`, only for a row the drain would otherwise hold behind a busy gate,
|
|
253
|
+
* and only in FIFO order (the caller stops at the first row that holds).
|
|
254
|
+
*
|
|
255
|
+
* WHO MAY BE STEERED: a row never attempted (`attempts === 0`: no front-door attempt, no earlier
|
|
256
|
+
* steer, no restart requeue, so nothing about it is unknown), with no files (the peer inbox
|
|
257
|
+
* carries words only), on a thread the shared steer rule says can take it now.
|
|
258
|
+
*
|
|
259
|
+
* PROVENANCE IS CHECKED HERE AS THE TURNS ROUTE CHECKS IT, because this hop does not go through
|
|
260
|
+
* that front door: footer-shaped words are refused, the parked origin is carried (a single-use
|
|
261
|
+
* carry token, spent in this process), its seal must name this provider, thread and no files,
|
|
262
|
+
* a turn id held for another turn is refused, and only the server writes the footer. A refusal
|
|
263
|
+
* settles the row `refused` with its own reason, so it reads "Not sent" with the reason line.
|
|
264
|
+
*
|
|
265
|
+
* NEVER TWICE, in the same order as `steerTurn`: the intent row (`delivering`, `mode: 'steer'`,
|
|
266
|
+
* `journal: 1`, this boot) durably, then the attempt `pending`, then the frame, then the
|
|
267
|
+
* verdict, journal first. Unverified, a failed write or a throw: `refused`, `steer_unverified`,
|
|
268
|
+
* `deliveryState: 'unknown'`, never resent. Nothing reached the session: back to waiting, the
|
|
269
|
+
* attempt given back, so the turn-end front door sends it as before.
|
|
270
|
+
*
|
|
271
|
+
* NO AWAIT BEFORE THE INTENT ROW IS WRITTEN: the caller read this row as `waiting` in the same
|
|
272
|
+
* synchronous step, so a cancel cannot slip between that read and the `delivering` write.
|
|
273
|
+
*/
|
|
274
|
+
async function steerHeldTurn(
|
|
275
|
+
provider: string,
|
|
276
|
+
threadId: string,
|
|
277
|
+
turn: QueuedThreadTurn,
|
|
278
|
+
gateReason: string | null,
|
|
279
|
+
deps: ThreadTurnQueueDeps,
|
|
280
|
+
): Promise<MidTurnOutcome> {
|
|
281
|
+
if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
|
|
282
|
+
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
|
|
283
|
+
const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
|
|
284
|
+
if (!availability || !availability.available) return 'held'
|
|
285
|
+
const { clientTurnId, prompt } = turn
|
|
286
|
+
|
|
287
|
+
const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
|
|
288
|
+
const latest = readQueue(provider, threadId, deps.now())
|
|
289
|
+
const row = latest.find(t => t.clientTurnId === clientTurnId)
|
|
290
|
+
if (!row) return null
|
|
291
|
+
apply(row)
|
|
292
|
+
writeQueue(provider, threadId, latest, { durable: true })
|
|
293
|
+
return row
|
|
294
|
+
}
|
|
295
|
+
const refuse = (reason: string): MidTurnOutcome => {
|
|
296
|
+
const at = deps.now()
|
|
297
|
+
rewrite(row => { row.status = 'refused'; row.reason = reason; row.settledAt = at })
|
|
298
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
299
|
+
console.warn(`[thread-turn-queue] mid-turn refused provider=${provider} clientTurnId=${clientTurnId} reason=${reason}`)
|
|
300
|
+
return 'settled'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The front door's provenance gates, in its order.
|
|
304
|
+
let verdict: OriginVerdict | null = null
|
|
305
|
+
if (deps.provenance) {
|
|
306
|
+
if (hasFooterShapedLine(prompt)) return refuse('footer_line_refused')
|
|
307
|
+
const token = deps.provenance.issueCarry({ provider, threadId, clientTurnId, prompt })
|
|
308
|
+
const carried = token ? deps.provenance.takeCarry(token, clientTurnId, prompt) : null
|
|
309
|
+
// No carry could be made: leave it for the front door, which decides with its own carry.
|
|
310
|
+
if (carried === null) return 'held'
|
|
311
|
+
if (carried.seal !== null && !sealMatches(carried.seal, { provider, threadId, attachments: '' })) return refuse('queued_turn_tampered')
|
|
312
|
+
verdict = carried.verdict ?? localDrainVerdict(deps.now())
|
|
313
|
+
if (deps.provenance.reusesTurnId(clientTurnId, { prompt, verdict, provider, sessionId: threadId }, { continues: true })) return refuse('turn_id_in_use')
|
|
314
|
+
}
|
|
315
|
+
const sent = deps.provenance && verdict ? deps.provenance.footed(prompt, clientTurnId, verdict, provider) : prompt
|
|
316
|
+
const ledger = (via: string): void => {
|
|
317
|
+
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via, continues: true })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 1. The intent row, durably.
|
|
321
|
+
const intent = rewrite(row => {
|
|
322
|
+
row.status = 'delivering'
|
|
323
|
+
row.attempts = 1
|
|
324
|
+
row.mode = 'steer'
|
|
325
|
+
row.journal = 1
|
|
326
|
+
if (typeof deps.bootId === 'string' && deps.bootId.length > 0) row.deliveringBootId = deps.bootId
|
|
327
|
+
else delete row.deliveringBootId
|
|
328
|
+
})
|
|
329
|
+
if (!intent) return 'held'
|
|
330
|
+
const backToWaiting = (row: QueuedThreadTurn): void => {
|
|
331
|
+
row.status = 'waiting'
|
|
332
|
+
row.attempts = 0
|
|
333
|
+
delete row.mode
|
|
334
|
+
delete row.journal
|
|
335
|
+
delete row.deliveringBootId
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 2. The attempt `pending`, durably, before any frame.
|
|
339
|
+
let begun = false
|
|
340
|
+
try { begun = deps.attempts!.begin(provider, threadId, clientTurnId, 'steer', deps.now()) === true } catch { begun = false }
|
|
341
|
+
if (!begun) {
|
|
342
|
+
rewrite(backToWaiting)
|
|
343
|
+
console.warn(`[thread-turn-queue] mid-turn not attempted provider=${provider} clientTurnId=${clientTurnId}: the attempt journal could not record it`)
|
|
344
|
+
return 'held'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 3. The frame.
|
|
348
|
+
const startedAt = deps.now()
|
|
349
|
+
let live: { ok: boolean; reason: string } | null = null
|
|
350
|
+
try {
|
|
351
|
+
live = await deps.deliverLiveTurn!({ provider, sessionId: threadId, prompt: sent, verifyTimeoutMs: PEER_VERIFY_TIMEOUT_MS, clientTurnId })
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error(`[thread-turn-queue] mid-turn delivery threw: ${error instanceof Error ? error.message : error}`)
|
|
354
|
+
live = null
|
|
355
|
+
}
|
|
356
|
+
const at = deps.now()
|
|
357
|
+
const journal = (exit: 'sent' | 'not_sent' | 'unknown'): void => {
|
|
358
|
+
try { deps.attempts!.settle(provider, threadId, clientTurnId, exit, at) } catch { /* the reconcile reads `pending` as unknown */ }
|
|
359
|
+
}
|
|
360
|
+
console.log(`[thread-turn-queue] mid-turn provider=${provider} clientTurnId=${clientTurnId} hop=${live?.reason ?? 'threw'} ms=${Math.max(0, at - startedAt)} waitedMs=${Math.max(0, at - turn.queuedAt)}`)
|
|
361
|
+
|
|
362
|
+
// 4. The verdict, journal first, then the row.
|
|
363
|
+
if (live?.ok === true) {
|
|
364
|
+
journal('sent')
|
|
365
|
+
ledger('drain-steer')
|
|
366
|
+
rewrite(row => { row.status = 'delivered'; row.reason = 'steered'; row.settledAt = at })
|
|
367
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
368
|
+
return 'delivered'
|
|
369
|
+
}
|
|
370
|
+
if (live === null || live.reason === 'unverified' || live.reason === 'write_failed') {
|
|
371
|
+
journal('unknown')
|
|
372
|
+
ledger('drain-steer-unverified')
|
|
373
|
+
rewrite(row => { row.status = 'refused'; row.reason = 'steer_unverified'; row.deliveryState = 'unknown'; row.settledAt = at })
|
|
374
|
+
deps.provenance?.forgetQueued(provider, threadId, clientTurnId)
|
|
375
|
+
return 'settled'
|
|
376
|
+
}
|
|
377
|
+
// The transport's own word that nothing reached a session: the row waits for the turn end.
|
|
378
|
+
journal('not_sent')
|
|
379
|
+
rewrite(backToWaiting)
|
|
380
|
+
return 'held'
|
|
381
|
+
}
|
|
382
|
+
|
|
213
383
|
/**
|
|
214
384
|
* One drain pass over one thread.
|
|
215
385
|
*
|
|
@@ -243,9 +413,14 @@ export async function drainThread(
|
|
|
243
413
|
if (turn.status !== 'waiting') deps.provenance?.forgetQueued(provider, threadId, turn.clientTurnId)
|
|
244
414
|
}
|
|
245
415
|
|
|
416
|
+
// Steer mid-turn (COS_STEER_MIDTURN): FIFO per thread. Once a row ahead cannot go in mid-turn
|
|
417
|
+
// (or is still being delivered), nothing behind it may jump it; it waits for the turn end too.
|
|
418
|
+
let midTurnOrderHeld = false
|
|
419
|
+
|
|
246
420
|
for (const snapshot of queue) {
|
|
247
421
|
const turn = readQueue(provider, threadId, deps.now()).find(t => t.clientTurnId === snapshot.clientTurnId)
|
|
248
422
|
if (!turn) continue
|
|
423
|
+
if (turn.status === 'delivering') midTurnOrderHeld = true
|
|
249
424
|
if (turn.status !== 'waiting') continue
|
|
250
425
|
|
|
251
426
|
// Reuse only refusal within this synchronous pass. Rechecking a busy thread
|
|
@@ -284,7 +459,21 @@ export async function drainThread(
|
|
|
284
459
|
}
|
|
285
460
|
const decision = drainDecision(turn, seen, deps.now())
|
|
286
461
|
|
|
287
|
-
if (decision === 'hold') {
|
|
462
|
+
if (decision === 'hold') {
|
|
463
|
+
// Steer mid-turn: never during a cancel hold (the person said stop), only for the first
|
|
464
|
+
// rows in order, and only where the shared steer rule says this session can take it now,
|
|
465
|
+
// which also means the hold is the busy GATE: the rule answers only for a refusal that
|
|
466
|
+
// names a desk run (an attachable thread has no reason). Anything else holds as before.
|
|
467
|
+
const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
|
|
468
|
+
if (!midTurnOrderHeld && !cancelHeld) {
|
|
469
|
+
const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
|
|
470
|
+
if (outcome === 'delivered') { delivered += 1; continue }
|
|
471
|
+
if (outcome === 'settled') { retired += 1; continue }
|
|
472
|
+
}
|
|
473
|
+
midTurnOrderHeld = true
|
|
474
|
+
held += 1
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
288
477
|
if (decision === 'expire' || decision === 'give_up') {
|
|
289
478
|
turn.status = decision === 'expire' ? 'expired' : 'refused'
|
|
290
479
|
turn.reason = decision === 'expire' ? 'queued_turn_expired' : 'delivery_attempts_exhausted'
|
|
@@ -710,12 +899,27 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
710
899
|
|
|
711
900
|
writeQueue(provider, threadId, admitted.queue)
|
|
712
901
|
if (deps.provenance && verdict) deps.provenance.record({ turnId: clientTurnId, prompt, verdict, provider, sessionId: threadId, via: 'queue' })
|
|
902
|
+
// Steer mid-turn (COS_STEER_MIDTURN): the park answer stays the 202 every client reads (a
|
|
903
|
+
// lens that sees anything else says "could not queue"); the drain is asked to run now, and it
|
|
904
|
+
// puts the words into the running turn. `landsAt` (additive, only with the switch on) tells a
|
|
905
|
+
// client which line to show: `next_step` only when this row is first in line, has no files,
|
|
906
|
+
// and the session can take it now; `turn_end` otherwise.
|
|
907
|
+
const midTurn = midTurnAvailabilityFor(deps, provider, threadId, gate.reason)
|
|
908
|
+
let landsAt: 'next_step' | 'turn_end' | null = null
|
|
909
|
+
if (midTurn) {
|
|
910
|
+
const firstInLine = queuePosition(admitted.queue, clientTurnId) === 0 && !admitted.queue.some(t => t.status === 'delivering')
|
|
911
|
+
landsAt = midTurn.available && firstInLine && attach.ids.length === 0 ? 'next_step' : 'turn_end'
|
|
912
|
+
if (landsAt === 'next_step') {
|
|
913
|
+
try { deps.kickDrain?.(threadId) } catch { /* the 20 s sweep still delivers it */ }
|
|
914
|
+
}
|
|
915
|
+
}
|
|
713
916
|
return res.status(202).json({
|
|
714
917
|
queued: true,
|
|
715
918
|
clientTurnId,
|
|
716
919
|
position: queuePosition(admitted.queue, clientTurnId),
|
|
717
920
|
waitingOn: gate.reason,
|
|
718
921
|
...(steerFallback !== null ? { steerFallback } : {}),
|
|
922
|
+
...(landsAt !== null ? { landsAt } : {}),
|
|
719
923
|
})
|
|
720
924
|
})
|
|
721
925
|
|
|
@@ -4,10 +4,19 @@
|
|
|
4
4
|
* - GET /api/today/brief the cached summary; never generates.
|
|
5
5
|
* - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
|
|
6
6
|
* what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
|
|
7
|
-
* passed and the day's
|
|
7
|
+
* passed and the day's cap (Summaries per day, 7.0.5) is not spent. The Work cards in progress come from the
|
|
8
8
|
* server's own shared board read, never the body.
|
|
9
|
+
* 7.0.1: `"force": true` in the body is the Today refresh button. It skips the
|
|
10
|
+
* 10-minute interval and the "inputs unchanged" check, at most once every 30 s,
|
|
11
|
+
* inside the same daily cap. A throttled one answers reason `refresh_throttled` and
|
|
12
|
+
* `retryAfterMs`.
|
|
13
|
+
* 7.0.5: `"reason": "reconnect"` says the client's read just recovered from a failed
|
|
14
|
+
* one: that start skips the 10-minute interval, at most once per 10 minutes. The
|
|
15
|
+
* "inputs unchanged" check, the breaker, the switch and the daily cap still apply.
|
|
9
16
|
* - GET /api/today/brief/settings the switch and what set it.
|
|
10
17
|
* - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
|
|
18
|
+
* - GET /api/today/brief/settings/cap 7.0.5 "Summaries per day": { dailyCap, min: 1, max: 100, default: 100, usedToday }.
|
|
19
|
+
* - PUT /api/today/brief/settings/cap { dailyCap: number }, clamped to 1 to 100 and saved; answers the same shape.
|
|
11
20
|
* Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
|
|
12
21
|
*/
|
|
13
22
|
import { Router } from 'express'
|
|
@@ -61,7 +70,7 @@ export async function readSubagentsFor(keys: string[], now = Date.now()): Promis
|
|
|
61
70
|
return out
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
/** The server's own brief: the data folder,
|
|
73
|
+
/** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
|
|
65
74
|
export function defaultTodayBrief(): TodayBrief {
|
|
66
75
|
return createTodayBrief({
|
|
67
76
|
stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
|
|
@@ -95,6 +104,18 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
|
|
|
95
104
|
return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The body without `force` and `reason`, and what they asked. Anything but a boolean `force` is a bad request; 7.0.5
|
|
109
|
+
* `reason` may only be "reconnect".
|
|
110
|
+
*/
|
|
111
|
+
export function splitForce(raw: unknown): { body: unknown; force: boolean; reconnect: boolean } {
|
|
112
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('force' in raw || 'reason' in raw)) return { body: raw, force: false, reconnect: false }
|
|
113
|
+
const { force, reason, ...rest } = raw as Record<string, unknown>
|
|
114
|
+
if (force !== undefined && typeof force !== 'boolean') throw new TodayBriefInputError('invalid_today_brief')
|
|
115
|
+
if (reason !== undefined && reason !== 'reconnect') throw new TodayBriefInputError('invalid_today_brief')
|
|
116
|
+
return { body: rest, force: force === true, reconnect: reason === 'reconnect' }
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
99
120
|
const router = Router()
|
|
100
121
|
router.use('/today/brief', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
@@ -108,18 +129,21 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
108
129
|
if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
|
|
109
130
|
return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
|
|
110
131
|
}
|
|
132
|
+
// 7.0.1: `force` is the refresh button's flag, taken off before the rows are read (the rows reject unknown keys).
|
|
133
|
+
const { body, force, reconnect } = splitForce(req.body)
|
|
111
134
|
// The switch first: off reads nothing, not even the board.
|
|
112
135
|
if (!deps.brief.settings().enabled) {
|
|
113
|
-
normalizeTodayBriefInputs(
|
|
136
|
+
normalizeTodayBriefInputs(body)
|
|
114
137
|
return res.json(deps.brief.peek())
|
|
115
138
|
}
|
|
116
|
-
// Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval
|
|
117
|
-
// answers from the page's rows alone and reads neither the board nor any
|
|
118
|
-
|
|
119
|
-
const
|
|
139
|
+
// Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval or, forced,
|
|
140
|
+
// the 30 s refresh floor): a gated ask answers from the page's rows alone and reads neither the board nor any
|
|
141
|
+
// transcript.
|
|
142
|
+
const pageInputs = normalizeTodayBriefInputs(body)
|
|
143
|
+
const closed = deps.brief.gate({ force, reconnect })
|
|
120
144
|
if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
|
|
121
145
|
const work = workInProgress(await deps.board().catch(() => null))
|
|
122
|
-
let inputs = normalizeTodayBriefInputs(
|
|
146
|
+
let inputs = normalizeTodayBriefInputs(body, work)
|
|
123
147
|
const keys = subagentLookups(inputs)
|
|
124
148
|
if (keys.length && deps.subagents) {
|
|
125
149
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -127,7 +151,7 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
127
151
|
const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
|
|
128
152
|
inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
|
|
129
153
|
}
|
|
130
|
-
res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
|
|
154
|
+
res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
|
|
131
155
|
} catch (error) {
|
|
132
156
|
if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
|
|
133
157
|
console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
|
|
@@ -144,5 +168,16 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
|
|
|
144
168
|
}
|
|
145
169
|
try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
|
|
146
170
|
})
|
|
171
|
+
// 7.0.5 "Summaries per day" (Control Web Settings and COS Control use the same route).
|
|
172
|
+
router.get('/today/brief/settings/cap', (_req, res) => {
|
|
173
|
+
try { res.json(deps.brief.cap()) } catch { failed(res) }
|
|
174
|
+
})
|
|
175
|
+
router.put('/today/brief/settings/cap', (req, res) => {
|
|
176
|
+
const body = req.body
|
|
177
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'dailyCap') || typeof body.dailyCap !== 'number' || !Number.isFinite(body.dailyCap)) {
|
|
178
|
+
return res.status(400).json({ error: { code: 'invalid_today_brief_cap', message: 'Send { dailyCap: a number from 1 to 100 }.' } })
|
|
179
|
+
}
|
|
180
|
+
try { res.json(deps.brief.setDailyCap(body.dailyCap)) } catch { failed(res) }
|
|
181
|
+
})
|
|
147
182
|
return router
|
|
148
183
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Router } from 'express'
|
|
6
6
|
import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
|
|
7
|
+
import { calendarToday, readCalendar } from '../lib/calendar-source.js'
|
|
7
8
|
import { parsePrepOccurrence } from '../lib/prep-store.js'
|
|
8
9
|
import type { PrepOccurrence } from '../../shared/prep.js'
|
|
9
10
|
|
|
@@ -34,7 +35,8 @@ export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined
|
|
|
34
35
|
calendarId: pick('calendar_id', 'calendarId'),
|
|
35
36
|
eventId: pick('event_id', 'eventId') ?? null,
|
|
36
37
|
externalId: pick('external_id', 'externalId') ?? null,
|
|
37
|
-
|
|
38
|
+
// occurrence_start (EventKit's original occurrence date) first, so a moved occurrence keeps its draft.
|
|
39
|
+
startsAt: pick('occurrence_start', 'start_time', 'start', 'startsAt'),
|
|
38
40
|
endsAt: pick('end_time', 'end', 'endsAt') ?? null,
|
|
39
41
|
}) ?? undefined
|
|
40
42
|
} catch { return undefined }
|
|
@@ -327,7 +329,8 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
327
329
|
}
|
|
328
330
|
|
|
329
331
|
try {
|
|
330
|
-
const
|
|
332
|
+
const calRead = await readCalendar()
|
|
333
|
+
const cal = calRead.data as {
|
|
331
334
|
today_remaining?: Array<{
|
|
332
335
|
title?: string
|
|
333
336
|
start_time?: string
|
|
@@ -338,10 +341,14 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
|
|
|
338
341
|
minutes_until_next?: number
|
|
339
342
|
} | null
|
|
340
343
|
|
|
341
|
-
|
|
344
|
+
// The EventKit snapshot is a real read without the bridge; 'none' (denied or expired) never is.
|
|
345
|
+
const sourceReady = calRead.source === 'bridge' ? pythonBridgeAvailable() : calRead.source !== 'none'
|
|
346
|
+
const read = calendarAnswerWasRead(sourceReady, cal)
|
|
342
347
|
const remaining = cal?.today_remaining ?? []
|
|
343
348
|
const meaningful = remaining.find((evt) => {
|
|
344
349
|
if (evt.is_all_day) return false
|
|
350
|
+
// Snapshot rows say whether they may become next/prep (declined, shared-calendar travel, holidays).
|
|
351
|
+
if ((evt as { prep_worthy?: unknown }).prep_worthy === false) return false
|
|
345
352
|
return !isGenericNoiseEvent(evt.title || '')
|
|
346
353
|
})
|
|
347
354
|
|
|
@@ -394,6 +401,19 @@ export function _resetWelcomeContextCachesForTests(): void {
|
|
|
394
401
|
lastFailureLogAt.clear()
|
|
395
402
|
}
|
|
396
403
|
|
|
404
|
+
/**
|
|
405
|
+
* PH plan P1: Today's calendar for Control Web and desktop. Behind the /api token gate. Selected calendars only, the
|
|
406
|
+
* next 3 events. `connected: false` means "Calendar not connected", never an empty day.
|
|
407
|
+
*/
|
|
408
|
+
welcomeContextRouter.get('/calendar/today', async (_req, res) => {
|
|
409
|
+
try {
|
|
410
|
+
res.set('Cache-Control', 'no-store')
|
|
411
|
+
res.json(await calendarToday())
|
|
412
|
+
} catch {
|
|
413
|
+
res.status(503).json({ error: 'calendar_unavailable' })
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
|
|
397
417
|
welcomeContextRouter.get('/welcome-context', async (req, res) => {
|
|
398
418
|
const lat = parseCoord(req.query.lat, 'lat')
|
|
399
419
|
const lon = parseCoord(req.query.lon, 'lon')
|
|
@@ -6,6 +6,7 @@ import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage,
|
|
|
6
6
|
import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
|
|
7
7
|
import type { MeetingDescriptor } from '../lib/work-review-store.js'
|
|
8
8
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
9
|
+
import { workSessionActivity, waitingSessionIds } from '../lib/work-session-activity.js'
|
|
9
10
|
import { resolveSavedMeetingDetail } from './meetings.js'
|
|
10
11
|
import type { MeetingDetail } from '../lib/meeting-store.js'
|
|
11
12
|
|
|
@@ -28,11 +29,13 @@ export interface WorkBoardDependencies {
|
|
|
28
29
|
requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
|
|
29
30
|
/** 6.59.0: the bounded, shared board read (5 s). Defaults to one over `list`. */
|
|
30
31
|
readBoard?: WorkBoardReader<TaskBoardRow>
|
|
32
|
+
/** 7.0.1: each linked session's newest activity (lib/work-session-activity.ts), for the open list's supersede rule. */
|
|
33
|
+
sessionActivity: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
|
|
31
34
|
now: () => number
|
|
32
35
|
}
|
|
33
36
|
const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
|
|
34
37
|
stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, capture: captureWorkItem, captureCapabilities: workIntakeCapabilities, resolveMeeting: resolveSavedMeetingDetail,
|
|
35
|
-
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
|
|
38
|
+
requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), sessionActivity: ids => workSessionActivity(ids), now: () => Date.now() }
|
|
36
39
|
/** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
|
|
37
40
|
export const WORK_GLANCE_BOARD_AGE_MS = 10_000
|
|
38
41
|
|
|
@@ -82,7 +85,11 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
|
|
|
82
85
|
if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, items: [], total: 0, truncated: false })
|
|
83
86
|
const rows = await glanceBoard('open work')
|
|
84
87
|
if (!rows) return res.json({ version: 1, available: false, reason: 'Work board is unavailable. Refresh before trying again.', capabilities, items: [], total: 0, truncated: false })
|
|
85
|
-
|
|
88
|
+
// 7.0.1: a waiting item whose session moved on since its report is not waiting. Only those sessions are read; a
|
|
89
|
+
// read that fails keeps the list as it was.
|
|
90
|
+
const now = deps.now(), first = projectOpenWork(journal, rows, now), waiting = waitingSessionIds(first.items)
|
|
91
|
+
const sessionActivityMs = waiting.length ? await deps.sessionActivity(waiting).catch(() => undefined) : undefined
|
|
92
|
+
return res.json({ version: 1, available: true, capabilities, ...(sessionActivityMs?.size ? projectOpenWork(journal, rows, now, { sessionActivityMs }) : first) })
|
|
86
93
|
})
|
|
87
94
|
router.get('/work-board', async (_req, res) => {
|
|
88
95
|
try {
|
|
@@ -186,20 +186,26 @@ select.field {
|
|
|
186
186
|
padding: 4px calc(var(--gutter) + var(--safe-right)) calc(var(--safe-bottom) + 96px) calc(var(--gutter) + var(--safe-left));
|
|
187
187
|
}
|
|
188
188
|
.pane { max-width: 880px; }
|
|
189
|
+
/* 7.0.2 (A2, the Duo audit): the COS chat route ends at its composer (panes/messages.js sets ws-chat). The 96 px above
|
|
190
|
+
(and the 40 px from 600 px up) held room for a bottom bar that is gone; under the sticky composer it was blank page
|
|
191
|
+
that a focused box scrolled to and the closed keyboard left on screen. Two columns from 860 px keep a small margin. */
|
|
192
|
+
.workspace.ws-chat { padding-bottom: 0; }
|
|
193
|
+
@media (min-width: 860px) { .workspace.ws-chat { padding-bottom: calc(var(--safe-bottom) + 16px); } }
|
|
189
194
|
.pane-head { margin: 8px 0 18px; }
|
|
190
195
|
.pane-title { font: 500 40px/1.02 var(--display); letter-spacing: -0.015em; margin: 0; }
|
|
191
196
|
.pane-sub { color: var(--sub); margin: 6px 0 0; font-size: 15px; }
|
|
192
197
|
|
|
193
|
-
/* No bottom bar, on purpose. On
|
|
194
|
-
|
|
198
|
+
/* No bottom bar, on purpose. On a phone the browser owns the bottom edge (Safari's floating
|
|
199
|
+
address pill, Comet's and Chrome's own bars), and iOS 26 WebKit stops drawing the page under
|
|
195
200
|
its bar while any fixed element sits within ~60px of it. Navigation lives in the top bar
|
|
196
|
-
(
|
|
201
|
+
(folded screen) or the left rail (unfolded screen and up). */
|
|
197
202
|
.badge-dot { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--hot); }
|
|
198
203
|
|
|
199
204
|
.rail { display: none; }
|
|
200
205
|
|
|
201
206
|
/* ── Today ───────────────────────────────────────────────── */
|
|
202
207
|
.today-date { color: var(--sub); font-size: 15px; margin: 6px 0 0; }
|
|
208
|
+
.today-weather { color: var(--sub); font-size: 15px; margin: 2px 0 0; }
|
|
203
209
|
/* The session pet on Today: the pet COS Control shows, in the pose the Mac would pick. */
|
|
204
210
|
.today-pet { display: flex; flex-direction: column; align-items: center; margin: 6px 0 14px; min-height: 166px; }
|
|
205
211
|
.today-pet.hidden { display: none; }
|
|
@@ -287,6 +293,11 @@ select.field {
|
|
|
287
293
|
.next-up .when { font: 500 24px/1 var(--display); white-space: nowrap; }
|
|
288
294
|
.next-up .what { font-weight: 600; }
|
|
289
295
|
.next-up .sub { color: var(--sub); font-size: 14px; }
|
|
296
|
+
.next-later { padding: 0 16px 14px; font-size: 14px; }
|
|
297
|
+
.next-later .later-row { display: flex; gap: 12px; padding: 3px 0; min-width: 0; }
|
|
298
|
+
.next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
|
|
299
|
+
.next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
300
|
+
.next-later .later-asof { color: var(--sub); margin-top: 4px; }
|
|
290
301
|
|
|
291
302
|
/* ── Lists (sessions, meetings, messages) ────────────────── */
|
|
292
303
|
.list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
|
|
@@ -421,11 +432,13 @@ select.field {
|
|
|
421
432
|
.pane:not(#pane-today) .pane-head .pane-sub { margin-top: 0; }
|
|
422
433
|
}
|
|
423
434
|
|
|
424
|
-
/* ── Short screens: the
|
|
425
|
-
|
|
435
|
+
/* ── Short screens: the folded Duo is about 382x678 with the browser's own bar under that, and
|
|
436
|
+
landscape tablets and laptop windows are often under 720px tall. Height is the scarce axis, so
|
|
437
|
+
the type steps down here, not by width. */
|
|
426
438
|
@media (max-height: 720px) {
|
|
427
439
|
.pane-title { font-size: 34px; }
|
|
428
440
|
.today-date { font-size: 14px; margin-top: 4px; }
|
|
441
|
+
.today-weather { font-size: 14px; }
|
|
429
442
|
.ask-bar { min-height: 46px; margin-top: 12px; }
|
|
430
443
|
.story { font-size: 21px; line-height: 1.3; margin: 18px 2px; }
|
|
431
444
|
.run-panel { padding-top: 8px; }
|
|
@@ -436,8 +449,8 @@ select.field {
|
|
|
436
449
|
.rec-banner { margin-top: 10px; padding: 8px 8px 8px 14px; }
|
|
437
450
|
}
|
|
438
451
|
|
|
439
|
-
/* ──
|
|
440
|
-
axis there is plenty of. */
|
|
452
|
+
/* ── Unfolded Duo (about 1430x1000 landscape, 1000x1430 portrait), tablets, desktop: a labelled
|
|
453
|
+
left rail costs width, which is the axis there is plenty of. */
|
|
441
454
|
@media (min-width: 600px) {
|
|
442
455
|
:root { --gutter: 24px; }
|
|
443
456
|
.topbar { display: none; }
|
|
@@ -468,11 +481,11 @@ select.field {
|
|
|
468
481
|
.run-title, .run-meta { grid-column: 2; }
|
|
469
482
|
.composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
|
|
470
483
|
.toast { top: auto; bottom: 24px; }
|
|
471
|
-
.sheet-backdrop { padding-top: 8vh; }
|
|
484
|
+
.sheet-backdrop { padding-top: 8vh; padding-top: 8dvh; }
|
|
472
485
|
.sheet { max-width: 620px; border-radius: 18px; padding: 16px 18px 18px; max-height: 80dvh; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); }
|
|
473
486
|
}
|
|
474
487
|
|
|
475
|
-
/*
|
|
488
|
+
/* Wide but short (a landscape tablet, a short window): keep the rail's nine sections inside 720px. */
|
|
476
489
|
@media (min-width: 600px) and (max-height: 720px) {
|
|
477
490
|
.pane:not(#pane-today) .pane-head .pane-title { display: none; }
|
|
478
491
|
.pane:not(#pane-today) .pane-head { margin: 0 0 12px; }
|
|
@@ -504,6 +517,28 @@ select.field {
|
|
|
504
517
|
.pane.reading .pane-head.with-ask { display: flex; }
|
|
505
518
|
}
|
|
506
519
|
|
|
520
|
+
/* Duo tables: the divider between a list and its reader (app.js splitKit, W.splitKit), for Memories and Messages.
|
|
521
|
+
Hidden everywhere; each pane shows it only inside its own two-column breakpoint (panes/memories.css 760 px,
|
|
522
|
+
panes/cos-chat.css 860 px), so a phone never sees it. Sessions keeps its own copy of these rules (panes/sessions.css). */
|
|
523
|
+
.split-handle, .split-full-btn { display: none; }
|
|
524
|
+
.split-handle {
|
|
525
|
+
justify-content: center; align-items: center; position: sticky; top: calc(var(--safe-top) + 12px); align-self: start;
|
|
526
|
+
height: calc(100dvh - var(--safe-top) - 24px); cursor: col-resize; touch-action: none; user-select: none; -webkit-user-select: none;
|
|
527
|
+
-webkit-touch-callout: none; outline: none; z-index: 2;
|
|
528
|
+
}
|
|
529
|
+
/* The line is the split's own gap; the grip shows where to take it. */
|
|
530
|
+
.split-handle::after { content: ''; width: 4px; height: 36px; border-radius: 2px; background: var(--panel-border); transition: background 120ms ease, height 120ms ease; }
|
|
531
|
+
.split-handle:hover::after, .split-handle:focus-visible::after, .split-dragging > .split-handle::after { background: var(--accent); height: 52px; }
|
|
532
|
+
.split-handle:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 6px; }
|
|
533
|
+
/* A finger gets at least a 44 px target, 13 px over each pane's edge; a mouse keeps the divider's own column. */
|
|
534
|
+
@media (pointer: coarse) { .split-handle::before { content: ''; position: absolute; top: 0; bottom: 0; left: -13px; right: -13px; } }
|
|
535
|
+
/* While dragging, nothing in the panes selects or takes the pointer. */
|
|
536
|
+
.split-dragging { user-select: none; -webkit-user-select: none; cursor: col-resize; }
|
|
537
|
+
.split-dragging > :not(.split-handle) { pointer-events: none; }
|
|
538
|
+
.split-full-btn { margin-left: auto; flex: none; }
|
|
539
|
+
@media (pointer: coarse) { .split-full-btn { min-height: 44px; } }
|
|
540
|
+
@media (prefers-reduced-motion: reduce) { .split-handle::after { transition: none; } }
|
|
541
|
+
|
|
507
542
|
/* ── Desktop ─────────────────────────────────────────────── */
|
|
508
543
|
@media (min-width: 1100px) and (min-height: 721px) {
|
|
509
544
|
:root { --gutter: 40px; }
|
|
@@ -538,3 +573,38 @@ select.field {
|
|
|
538
573
|
.live-prose { margin-top: 8px; font-size: 15px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 14em; overflow: auto; color: var(--run-title); }
|
|
539
574
|
#session-status .link-btn { min-height: 0; padding: 0; }
|
|
540
575
|
.turn.user.pending { opacity: 0.72; }
|
|
576
|
+
|
|
577
|
+
/* ── Touch screens (6.99.30) ────────────────────────────── */
|
|
578
|
+
/* Every field that types is 16 px on a touch screen, at every width: under 16 px a phone browser zooms the page in on
|
|
579
|
+
focus (the unfolded Duo did it on Sessions' selects, Search, Find and the model picker, 600 px and up). A floor, not
|
|
580
|
+
a size: no field anywhere is set above 16 px. !important because each pane sets its own compact size with more
|
|
581
|
+
specific selectors; this one rule beats them all only on touch. */
|
|
582
|
+
@media (any-pointer: coarse) {
|
|
583
|
+
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image']):not([type='hidden']),
|
|
584
|
+
select, textarea { font-size: 16px !important; }
|
|
585
|
+
}
|
|
586
|
+
/* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
|
|
587
|
+
minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
|
|
588
|
+
gone. */
|
|
589
|
+
/* 7.0.2 (A8): a short touch screen at any width too (the unfolded Duo, 951x539 in Comet, is touch first). */
|
|
590
|
+
@media (max-width: 599px), (pointer: coarse) and (max-height: 720px) {
|
|
591
|
+
.cx-btn, .field { min-height: 44px !important; }
|
|
592
|
+
}
|
|
593
|
+
/* Touch screens: the small controls the canary measured under 44 px (nav 30, switches 30, a chip's remove and Retry 36,
|
|
594
|
+
camera and clip 38, Send 38, the question strip's steps 36, the calendar's days 34) reach 44. Floors only (min-*),
|
|
595
|
+
!important because each pane sizes its own with more specific rules. A text switch keeps its look and grows only its
|
|
596
|
+
hit area, so its gold rule stays under the word. */
|
|
597
|
+
@media (any-pointer: coarse) {
|
|
598
|
+
.nav-btn, .btn.small, .cc-chip-retry, .sq-step, .mt-day { min-height: 44px !important; }
|
|
599
|
+
.cc-chip-x, .cc-attach, .cc-camera { min-width: 44px !important; min-height: 44px !important; }
|
|
600
|
+
.cx-switch button::before { content: ''; position: absolute; inset: -7px -4px; }
|
|
601
|
+
}
|
|
602
|
+
/* 7.0.2 (A8): a short touch screen 600 px wide or more (the unfolded Duo, 951x539) keeps the phone Ask sheet: full width
|
|
603
|
+
from the top, no centred desktop card with 8 % of a 539 px screen spent above it (app.js isPhone says phone too). */
|
|
604
|
+
@media (min-width: 600px) and (pointer: coarse) and (max-height: 720px) {
|
|
605
|
+
.sheet-backdrop { padding-top: 0; }
|
|
606
|
+
.sheet { max-width: none; border-radius: 0 0 20px 20px; padding: calc(var(--safe-top) + 10px) var(--gutter) 14px; max-height: calc(100dvh - 90px); box-shadow: none; }
|
|
607
|
+
}
|
|
608
|
+
/* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
|
|
609
|
+
showed under loaded images: .link-btn's inline-flex beat [hidden]). */
|
|
610
|
+
[hidden] { display: none !important; }
|